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
98 changes: 92 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ jobs:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: npm
Expand All @@ -27,8 +29,10 @@ jobs:
name: Type Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: npm
Expand All @@ -39,10 +43,92 @@ jobs:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run test

e2e:
name: E2E (Playwright + Spree)
# Repository secrets are not exposed to runs triggered from forks, nor
# to dependabot[bot] runs (those only get Dependabot secrets), so the
# Stripe-backed checkout E2E cannot run there — skip the job (shows as
# "skipped", not failed). The secrets context is unavailable in
# job-level `if`, so key presence is checked in the first step instead:
# same-repo runs fail loudly there when the keys are missing.
if: >-
github.actor != 'dependabot[bot]' &&
(github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository)
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- name: Fail if Stripe keys are not configured
env:
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
STRIPE_PUBLISHABLE_KEY: ${{ vars.STRIPE_PUBLISHABLE_KEY }}
run: |
if [ -z "$STRIPE_SECRET_KEY" ] || [ -z "$STRIPE_PUBLISHABLE_KEY" ]; then
echo "::error::STRIPE_SECRET_KEY (repository secret) and STRIPE_PUBLISHABLE_KEY (repository variable) must be set under Settings → Secrets and variables → Actions. Use a sk_test_…/pk_test_… pair from your own Stripe sandbox."
exit 1
fi
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: npm
- run: npm ci
# Key the browser cache on the Playwright version, not the whole
# lockfile — unrelated dependency bumps shouldn't force a ~150MB
# Chromium re-download.
- name: Get Playwright version
id: playwright-version
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
id: playwright-cache
with:
path: ~/.cache/ms-playwright
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
- name: Install Playwright system deps only
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: npx playwright install-deps chromium
- name: Boot Spree backend (Postgres + Redis + Spree 5.4.3.1)
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
# 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
- name: Dump Spree logs on failure
if: failure()
run: docker compose -f e2e-backend/docker-compose.yml logs --no-color web > spree.log || true
- name: Upload failure artifacts
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: playwright-report
path: |
playwright-report/
test-results/
spree.log
retention-days: 7
- name: Tear down
if: always()
run: docker compose -f e2e-backend/docker-compose.yml down -v
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,17 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env
.env.local
.env.e2e

# vercel
.vercel

# playwright
/playwright-report/
/test-results/
/blob-report/
/playwright/.cache/

# typescript
*.tsbuildinfo
next-env.d.ts
Expand Down
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,41 @@ npm run build
npm start
```

### Testing

Unit and integration tests run through Vitest:

```bash
npm test # one-shot
npm run test:watch # watch mode
```

End-to-end tests run through Playwright against a real Spree backend booted in Docker. The compose file at `e2e-backend/docker-compose.yml` ships Postgres + Redis + the official `ghcr.io/spree/spree:5.4.3.1` image — no `create-spree-app` setup required. Seeding and API-key creation go through the official [`@spree/cli`](https://spreecommerce.org/docs/developer/cli/quickstart) (`spree seed`, `spree sample-data`, `spree api-key create`), installed as a dev dependency.

```bash
# 1. Export a Stripe test-mode key pair from your own Stripe sandbox.
# Both keys must belong to the same account — Stripe no longer
# publishes a working sample secret key, and a mismatched pair makes
# the checkout payment step fail.
export STRIPE_PUBLISHABLE_KEY=pk_test_…
export STRIPE_SECRET_KEY=sk_test_…

# 2. Boot Spree + Postgres + Redis, seed sample data, register a Stripe
# payment gateway, mint a publishable key, and write .env.e2e.
npm run e2e:up

# 3. Run the suite. Playwright boots `next dev` against .env.e2e.
npm run test:e2e

# Optional: interactive UI mode.
npm run test:e2e:ui

# Tear everything down.
npm run e2e:down
```

The checkout test pays with card `4242 4242 4242 4242` through Stripe's [test mode](https://docs.stripe.com/keys). PaymentIntents land in whichever Stripe test account owns the keys you exported. In CI, set `STRIPE_SECRET_KEY` as a repository secret and `STRIPE_PUBLISHABLE_KEY` as a repository variable (Settings → Secrets and variables → Actions). The E2E job skips itself on fork PRs, where GitHub never exposes repository secrets.

## Project Structure

```
Expand Down
4 changes: 4 additions & 0 deletions e2e-backend/.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Read by @spree/cli to discover which port the CLI should poll for /up
# and to print the correct URLs in `spree init` output.
# Matches the host-side port mapping in docker-compose.yml (web: 4000:3000).
SPREE_PORT=4000
59 changes: 59 additions & 0 deletions e2e-backend/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Minimal Spree backend for E2E tests.
#
# Service is named `web` (Postgres + Redis + Spree) so the @spree/cli tool
# can find it via `docker compose exec web …`. Meilisearch and Sidekiq are
# intentionally omitted: Spree falls back to SQL search without Meili, and
# the worker is not required for the checkout golden path (transactional
# emails go through the storefront, not the Rails worker).
#
# Usage (driven by ../scripts/e2e/bootstrap-spree.sh):
# docker compose up -d --wait
# npx @spree/cli seed && npx @spree/cli sample-data
# npx @spree/cli api-key create --name E2E --type publishable
#
# Spree exposes the storefront API on http://localhost:4000/api/v3/store.
# Postgres and Redis are reachable only on the compose network — use
# `docker compose exec postgres psql -U postgres` etc. for debugging.

name: storefront-e2e

services:
postgres:
image: postgres:18-alpine
environment:
POSTGRES_HOST_AUTH_METHOD: trust
healthcheck:
test: pg_isready -U postgres
interval: 5s
timeout: 5s
retries: 10

redis:
image: redis:7-alpine
healthcheck:
test: redis-cli ping
interval: 5s
timeout: 5s
retries: 10

web:
image: ghcr.io/spree/spree:5.4.3.1
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
environment:
DATABASE_URL: postgres://postgres@postgres:5432/spree_e2e
REDIS_URL: redis://redis:6379/0
SECRET_KEY_BASE: e2e_secret_key_base_not_for_production_use_only_e2e_e2e_e2e_e2e
RAILS_FORCE_SSL: "false"
RAILS_ASSUME_SSL: "false"
ports:
- "4000:3000"
healthcheck:
test: curl -f http://localhost:3000/up || exit 1
interval: 10s
timeout: 5s
retries: 20
start_period: 60s
153 changes: 153 additions & 0 deletions e2e/checkout.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { expect, type FrameLocator, type Page, test } from "@playwright/test";

/**
* Checkout golden-path E2E.
*
* Walks a guest user through products listing → PDP → cart → checkout,
* fills the shipping address, selects a delivery rate, pays with a Stripe
* test card, and confirms the order-placed page renders.
*
* 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
*/

const TEST_CARD = "4242424242424242";
const TEST_EMAIL = "e2e-buyer@example.com";

test("guest can complete a checkout with a Stripe test card", async ({
page,
}) => {
// 1. Open the products listing and pick the first available product.
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\/[^/]+/);

// 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
// drawer's Checkout link rather than racing the navigation by going
// straight to /cart (which would race the cookie write).
const addToCart = page.getByRole("button", { name: /add to cart/i });
await expect(addToCart).toBeEnabled({ timeout: 10_000 });
await addToCart.click();

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");
if (!checkoutHref) {
throw new Error("Drawer checkout link has no href");
}
await page.goto(checkoutHref);

// 3. Fill contact + shipping address. The checkout is single-page with
// 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);
await fillAddress(page);

// Trigger the address auto-save by blurring the form. Clicking the
// page heading takes focus out of the AddressFormFields container,
// which fires handleContainerBlur → tryAutoSave.
await page.getByRole("heading", { name: /shipping method/i }).click();

// 4. Pick the first available shipping rate. Spree sample data ships
// with at least one rate for US destinations.
const firstRate = page.getByRole("radio").first();
await expect(firstRate).toBeVisible({ timeout: 30_000 });
await firstRate.check();

// 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.
const stripeFrames = page.locator(
'iframe[title="Secure payment input frame"]',
);
let cardFrame: FrameLocator | undefined;
await expect(async () => {
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;
}
}
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");
}

// 6. Accept policies + submit.
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 });

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

async function fillAddress(page: Page) {
// The Country dropdown defaults alphabetically (Canada before US) — pick
// United States explicitly so the rest of the test data (NY state, ZIP
// 10001, US phone) is valid for the selected country.
await page.getByLabel(/country/i).selectOption({ label: "United States" });

await page
.getByLabel(/first name/i)
.first()
.fill("Test");
await page
.getByLabel(/last name/i)
.first()
.fill("Buyer");
await page
.getByLabel(/^address$/i)
.first()
.fill("123 Test St");
await page.getByLabel(/city/i).first().fill("New York");
await page
.getByLabel(/zip|postal code/i)
.first()
.fill("10001");
await page.getByLabel(/phone/i).first().fill("5555550100");

// With the country pinned to US the state field is always a <select>
// (disabled while the states list loads). selectOption auto-waits for
// the control to enable and for the option to be present.
await page
.getByLabel(/state|province/i)
.first()
.selectOption({ label: "New York" });
}
Loading
Loading