Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 19 additions & 15 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@ jobs:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run check
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run check

typecheck:
name: Type Check
Expand All @@ -32,12 +33,13 @@ jobs:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npx tsc --noEmit
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm exec tsc --noEmit

test:
name: Unit Tests
Expand All @@ -46,12 +48,13 @@ jobs:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run test
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run test

e2e:
name: E2E (Playwright + Spree)
Expand Down Expand Up @@ -80,11 +83,12 @@ jobs:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: npm
- run: npm ci
cache: pnpm
- run: pnpm install --frozen-lockfile
# Key the browser cache on the Playwright version, not the whole
# lockfile — unrelated dependency bumps shouldn't force a ~150MB
# Chromium re-download.
Expand All @@ -99,23 +103,23 @@ jobs:
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
run: pnpm exec playwright install --with-deps chromium
- name: Install Playwright system deps only
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: npx playwright install-deps chromium
run: pnpm exec playwright install-deps chromium
- name: Boot Spree backend (Postgres + Redis + latest Spree)
run: docker compose -f e2e-backend/docker-compose.yml up -d --wait
- name: Seed Spree and issue API key
env:
# Test-mode Stripe key pair from one sandbox account, consumed by
# bootstrap-spree.sh to configure the Spree Stripe gateway. Scoped
# to this step so npm postinstall scripts and third-party actions
# to this step so dependency postinstall scripts and third-party actions
# in other steps never see the secret.
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
STRIPE_PUBLISHABLE_KEY: ${{ vars.STRIPE_PUBLISHABLE_KEY }}
run: ./scripts/e2e/bootstrap-spree.sh
- name: Run Playwright tests
run: npm run test:e2e
run: pnpm run test:e2e
- name: Dump Spree logs on failure
if: failure()
run: docker compose -f e2e-backend/docker-compose.yml logs --no-color web > spree.log || true
Expand Down
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -569,16 +569,16 @@ This project uses [Biome](https://biomejs.dev/) for linting and formatting (not

```bash
# Lint the codebase
npm run lint
pnpm run lint

# Format all files
npm run format
pnpm run format

# Run both lint and format checks
npm run check
pnpm run check
```

Always use `npm run check` before committing changes and fix any issues with `npm run format`.
Always use `pnpm run check` before committing changes and fix any issues with `pnpm run format`.

### Configuration

Expand Down
17 changes: 11 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -26,20 +26,25 @@
ARG NODE_VERSION=22-alpine


# ---- base: Node with pnpm (same version as package.json's `packageManager`) ----
FROM node:${NODE_VERSION} AS base
RUN npm install -g pnpm@10.33.4


# ---- deps: install production+dev dependencies for the build ----
FROM node:${NODE_VERSION} AS deps
FROM base AS deps
WORKDIR /app

# libc6-compat keeps a few native modules happy on Alpine (musl).
RUN apk add --no-cache libc6-compat

COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --include=dev
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile --store-dir /pnpm/store


# ---- builder: compile the Next.js app ----
FROM node:${NODE_VERSION} AS builder
FROM base AS builder
WORKDIR /app

ENV NEXT_TELEMETRY_DISABLED=1
Expand Down Expand Up @@ -69,7 +74,7 @@ COPY . .

RUN --mount=type=secret,id=sentry_auth_token,required=false \
SENTRY_AUTH_TOKEN="$(cat /run/secrets/sentry_auth_token 2>/dev/null || true)" \
npm run build
pnpm run build


# ---- runner: minimal runtime image ----
Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ The storefront lands in `apps/storefront/`.
1. Install dependencies:

```bash
npm install
pnpm install
```

2. Copy the environment file and configure:
Expand All @@ -129,7 +129,7 @@ These two are all you need to boot. Optional variables cover analytics, error tr
### Development

```bash
npm run dev
pnpm run dev
```

Open [http://localhost:3001](http://localhost:3001) in your browser.
Expand All @@ -141,13 +141,13 @@ Testing Apple Pay / Google Pay locally needs a public HTTPS URL (Stripe verifies
### Production Build

```bash
npm run build
npm start
pnpm run build
pnpm start
```

### Testing

Unit and integration tests run through Vitest (`npm test`); end-to-end tests run through Playwright against a real Spree backend in Docker (`npm run e2e:up && npm run test:e2e`). See the [Testing guide](https://spreecommerce.org/docs/developer/storefront/nextjs/testing) for the full E2E setup, Stripe test keys, CI, and running against your own backend.
Unit and integration tests run through Vitest (`pnpm test`); end-to-end tests run through Playwright against a real Spree backend in Docker (`pnpm run e2e:up && pnpm run test:e2e`). See the [Testing guide](https://spreecommerce.org/docs/developer/storefront/nextjs/testing) for the full E2E setup, Stripe test keys, CI, and running against your own backend.

## Multi-Region

Expand Down
155 changes: 113 additions & 42 deletions e2e/checkout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { expect, type FrameLocator, type Page, test } from "@playwright/test";
* Backend: e2e-backend/docker-compose.yml (Spree 5.4.3.1 with sample data).
* Payments: real Stripe test mode (pk_test_...) — card 4242 4242 4242 4242.
*
* Run with: npm run e2e:up && npm run test:e2e
* Run with: pnpm run e2e:up && pnpm run test:e2e
*/

const TEST_CARD = "4242424242424242";
Expand All @@ -19,12 +19,23 @@ const TEST_EMAIL = "e2e-buyer@example.com";
test("guest can complete a checkout with a Stripe test card", async ({
page,
}) => {
// The card-fill and submit steps below are retry loops (the Payment
// Element can remount mid-flow and wipe typed input) — they need
// headroom beyond the config's 120s budget.
test.setTimeout(300_000);
// 1. Open the products listing and pick the first available product.
// A click landing mid-hydration can be swallowed with the page staying
// put — so the click-then-navigate pair is a bounded retry, not one
// unbounded wait.
await page.goto("/us/en/products");
const firstProduct = page.locator('a[href*="/products/"]').first();
await expect(firstProduct).toBeVisible({ timeout: 15_000 });
await firstProduct.click();
await page.waitForURL(/\/products\/[^/]+/);
await expect(async () => {
if (!/\/products\/[^/]+/.test(page.url())) {
await firstProduct.click({ timeout: 5_000 });
}
await page.waitForURL(/\/products\/[^/]+/, { timeout: 5_000 });
}).toPass({ timeout: 30_000 });

// 2. Add to cart from the PDP. The cart drawer opens automatically after
// the server action resolves and the cart cookie is set — wait for the
Expand All @@ -34,14 +45,38 @@ test("guest can complete a checkout with a Stripe test card", async ({
await expect(addToCart).toBeEnabled({ timeout: 10_000 });
await addToCart.click();

// The drawer normally auto-opens once the server action resolves, but both
// the add itself and the auto-open can be lost to hydration. Check the
// drawer first: while it's open, the rest of the page is aria-hidden, so
// the header cart button is only consultable when the drawer is closed —
// its badge then tells a swallowed add (re-add) apart from a lost
// auto-open (open the drawer manually). The drawer also keeps
// re-rendering as the cart revalidates (its Express Checkout widget
// remounts), which can detach the link mid-click indefinitely — so read
// the link's target inside the retry and navigate to it instead of
// clicking it.
const cartButton = page.getByRole("button", { name: /open cart/i });
const drawerCheckout = page
.getByRole("dialog")
.getByRole("link", { name: /^checkout$/i });
await expect(drawerCheckout).toBeVisible({ timeout: 15_000 });
// The drawer keeps re-rendering as the cart revalidates (its Express
// Checkout widget remounts), which can detach the link mid-click
// indefinitely — navigate to the link's target instead of clicking it.
const checkoutHref = await drawerCheckout.getAttribute("href");
let checkoutHref: string | null = null;
await expect(async () => {
if (!(await drawerCheckout.count())) {
const badge = (await cartButton.textContent({ timeout: 3_000 })) ?? "";
if (/\d/.test(badge)) {
await cartButton.click({ timeout: 3_000 });
} else {
await addToCart.click({ timeout: 3_000 });
}
}
await expect(drawerCheckout).toBeVisible({ timeout: 5_000 });
checkoutHref = await drawerCheckout.getAttribute("href", {
timeout: 3_000,
});
if (!checkoutHref) {
throw new Error("Drawer checkout link has no href");
}
}).toPass({ timeout: 45_000 });
if (!checkoutHref) {
throw new Error("Drawer checkout link has no href");
}
Expand All @@ -51,7 +86,12 @@ test("guest can complete a checkout with a Stripe test card", async ({
// auto-save: address persists on container blur (no explicit "Continue"
// button). Email input has no <label> — its accessible name comes from
// `placeholder`, so use getByPlaceholder.
await page.getByPlaceholder(/email address/i).fill(TEST_EMAIL);
// The streamed checkout page transiently holds two copies of the contact
// form while hydrating — wait for the duplicate to collapse before
// filling anything.
const email = page.getByPlaceholder(/email address/i);
await expect(email).toHaveCount(1, { timeout: 20_000 });
await email.fill(TEST_EMAIL);
await fillAddress(page);

// Trigger the address auto-save by blurring the form. Clicking the
Expand All @@ -67,52 +107,83 @@ test("guest can complete a checkout with a Stripe test card", async ({

// 5. Pay with a Stripe test card. The Payment Element only renders
// after a session-based payment method is selected, which only
// appears once shipping is locked in. Several Stripe iframes share
// the "Secure payment input frame" title (an accessory frame mounts
// lazily next to the real form, before or after it), so resolve the
// frame that actually contains the card form rather than trusting
// mount order — a fill aimed at the wrong frame "succeeds" silently
// while the real card field stays empty.
// appears once shipping is locked in. Two hazards make this step a
// retry loop rather than a linear fill:
// - Several Stripe iframes share the "Secure payment input frame"
// title (an accessory frame mounts lazily next to the real form),
// so the card form's frame must be re-resolved every attempt — a
// fill aimed at the wrong frame "succeeds" silently while the real
// card field stays empty.
// - PaymentSection recreates the payment session whenever the cart
// total changes (e.g. the shipping-rate save landing after the
// Payment Element mounted), which remounts the Element and wipes
// anything already typed.
// Only values that still sit in the form after a settle pause are
// really in the form Pay Now will submit — anything else means a
// remount raced the fill, and the attempt runs again.
const stripeFrames = page.locator(
'iframe[title="Secure payment input frame"]',
);
let cardFrame: FrameLocator | undefined;
await expect(async () => {

const resolveCardFrame = async (): Promise<FrameLocator> => {
const frameCount = await stripeFrames.count();
for (let i = 0; i < frameCount; i++) {
const frame = stripeFrames.nth(i).contentFrame();
if (await frame.getByRole("textbox", { name: "Card number" }).count()) {
cardFrame = frame;
return;
return frame;
}
}
throw new Error("Card form has not rendered in any Stripe frame yet");
}).toPass({ timeout: 30_000 });
if (!cardFrame) {
throw new Error("Card form frame not resolved");
}
};

const cardNumber = cardFrame.getByRole("textbox", { name: "Card number" });
await cardNumber.fill(TEST_CARD);
// Stripe formats the value with spaces — assert the digits landed in
// THIS frame before paying, since a wrong-frame fill is silent.
await expect(cardNumber).toHaveValue(/4242/);
// The expiry field's accessible name varies across Payment Element
// mounts ("Expiration date" vs "Expiration (MM/YY)"); the placeholder
// is the stable handle.
await cardFrame.getByPlaceholder("MM / YY").fill("12 / 30");
await cardFrame.getByRole("textbox", { name: "Security code" }).fill("123");
// US card forms include their own required ZIP field (distinct from
// the shipping address) — Pay Now fails validation if it stays blank.
const zip = cardFrame.getByRole("textbox", { name: /zip code/i });
if (await zip.count()) {
await zip.fill("10001");
}
const fillCardForm = async (): Promise<void> => {
const cardFrame = await resolveCardFrame();
// fill() replaces the existing value, so re-running an attempt on an
// already-correct form is safe. Bounded action timeouts keep a
// mid-attempt remount from stalling the whole loop.
const cardNumber = cardFrame.getByRole("textbox", { name: "Card number" });
await cardNumber.fill(TEST_CARD, { timeout: 10_000 });
// The expiry field's accessible name varies across Payment Element
// mounts ("Expiration date" vs "Expiration (MM/YY)"); the placeholder
// is the stable handle.
const expiry = cardFrame.getByPlaceholder("MM / YY");
await expiry.fill("12 / 30", { timeout: 10_000 });
const cvc = cardFrame.getByRole("textbox", { name: "Security code" });
await cvc.fill("123", { timeout: 10_000 });
// US card forms include their own required ZIP field (distinct from
// the shipping address) — Pay Now fails validation if it stays blank.
const zip = cardFrame.getByRole("textbox", { name: /zip code/i });
if (await zip.count()) {
await zip.fill("10001", { timeout: 10_000 });
}
// A remount wipes the fields a beat after the fill "succeeds" — only
// accept values that survive the pause. (Stripe formats the number
// with spaces, hence the patterns.)
await page.waitForTimeout(1_500);
await expect(cardNumber).toHaveValue(/4242/, { timeout: 2_000 });
await expect(expiry).toHaveValue(/12/, { timeout: 2_000 });
await expect(cvc).toHaveValue("123", { timeout: 2_000 });
};

await expect(fillCardForm).toPass({ timeout: 60_000 });

// 6. Accept policies + submit.
// 6. Accept policies + submit. A remount can still land between the
// fill loop and the click, leaving Pay Now to fail inline validation
// against an emptied form — so re-verify the fill and click again on
// a timed-out attempt instead of waiting a full minute on a submit
// that can no longer succeed.
await page.getByRole("checkbox", { name: /i agree/i }).check();
await page.getByRole("button", { name: /pay now|place order/i }).click();
await page.waitForURL(/\/order-placed\//, { timeout: 60_000 });

await expect(async () => {
// A prior attempt's submit may have landed while its waitForURL had
// already timed out — never re-pay a completed order.
if (/\/order-placed\//.test(page.url())) return;
await fillCardForm();
await page
.getByRole("button", { name: /pay now|place order/i })
.click({ timeout: 10_000 });
await page.waitForURL(/\/order-placed\//, { timeout: 30_000 });
}).toPass({ timeout: 150_000, intervals: [1_000] });

// 7. Confirm the order summary rendered.
await expect(page.getByText(/order #/i)).toBeVisible();
Expand Down
Loading
Loading