Skip to content
Open
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
78 changes: 72 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,72 @@ 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)
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: Cache Playwright browsers
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- 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:
# Stripe secret key for the SpreeStripe::Gateway row. Set as a
# repository secret (Settings → Secrets and variables → Actions)
# to a Stripe test-mode key paired with the publishable key in
# the bootstrap script. PaymentIntents land in that test account.
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
run: ./scripts/e2e/bootstrap-spree.sh
- name: Run Playwright tests
run: npm run test:e2e
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: playwright-report
path: |
playwright-report/
test-results/
retention-days: 7
- 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 Spree logs on failure
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: spree-logs
path: 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
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,40 @@ 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 secret key. The publishable key is
# hardcoded to Stripe's public sample (pk_test_TYooMQ…); use the
# matching sample from https://docs.stripe.com/keys or your own
# Stripe test account's secret key.
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 secret key you exported. In CI, set `STRIPE_SECRET_KEY` as a repository secret (Settings → Secrets and variables → Actions).

## 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
64 changes: 64 additions & 0 deletions e2e-backend/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# 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 init --no-open
# npx @spree/cli api-key create --name E2E --type publishable
#
# Spree exposes the storefront API on http://localhost:4000/api/v3/store.

name: storefront-e2e

x-app: &app
image: ghcr.io/spree/spree:5.4.3.1
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
environment: &app-env
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"

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

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

web:
<<: *app
ports:
- "4000:3000"
healthcheck:
test: curl -f http://localhost:3000/up || exit 1
interval: 10s
timeout: 5s
retries: 20
start_period: 60s
151 changes: 151 additions & 0 deletions e2e/checkout.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { expect, type Locator, type Page, test } from "@playwright/test";

/**
* Checkout golden-path E2E.
*
* Walks a guest user through home → 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. Land on the home page (default market: us/en).
await page.goto("/us/en");
await expect(page).toHaveTitle(/.+/);

// 2. Navigate to 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\/[^/]+/);

// 3. 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 });
await drawerCheckout.click();
await page.waitForURL(/\/checkout\//);

// 4. 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();

// 5. 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();

// 6. 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. Wait for the Stripe iframe.
const stripeFrame = page
.frameLocator('iframe[name^="__privateStripeFrame"]')
.first();
await stripeFrame
.getByPlaceholder("1234 1234 1234 1234")
.fill(TEST_CARD, { timeout: 30_000 });
await stripeFrame.getByPlaceholder("MM / YY").fill("12 / 30");
await stripeFrame.getByPlaceholder("CVC").fill("123");

// 7. 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 });

// 8. 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 safeFill(page.getByLabel(/first name/i), "Test");
await safeFill(page.getByLabel(/last name/i), "Buyer");
await safeFill(page.getByLabel(/^address$/i).first(), "123 Test St");
await safeFill(page.getByLabel(/city/i), "New York");
await safeFill(page.getByLabel(/zip|postal code/i), "10001");
await safeFill(page.getByLabel(/phone/i), "5555550100");

// State/province renders in one of three shapes depending on country +
// load state in AddressFormFields:
// 1. enabled <select> — country has states, list is loaded
// 2. disabled <select> — country has states, list still loading
// 3. text <input> — country has no states
// Wait for it to leave the "loading" state, then dispatch by tag.
const stateField = page.getByLabel(/state|province/i).first();
if (!(await stateField.isVisible().catch(() => false))) return;

// Wait out the loading state so we don't try to select on a disabled <select>.
await stateField
.evaluate(
(el) =>
new Promise<void>((resolve) => {
const settled = () =>
!(el as HTMLSelectElement | HTMLInputElement).disabled;
if (settled()) return resolve();
const observer = new MutationObserver(() => {
if (settled()) {
observer.disconnect();
resolve();
}
});
observer.observe(el, {
attributes: true,
attributeFilter: ["disabled"],
});
setTimeout(() => {
observer.disconnect();
resolve();
}, 5_000);
}),
)
.catch(() => undefined);

const tagName = await stateField.evaluate((el) => el.tagName);
if (tagName === "SELECT") {
// Match the test's NY city + 10001 ZIP. Falls back to first option
// if New York isn't in the list (different country, etc.).
await stateField
.selectOption({ label: "New York" })
.catch(() => stateField.selectOption({ index: 1 }));
} else {
await stateField.fill("NY");
}
}

async function safeFill(locator: Locator, value: string) {
const target = locator.first();
await expect(target).toBeVisible({ timeout: 10_000 });
await target.fill(value);
}
1 change: 1 addition & 0 deletions lefthook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ pre-commit:
commands:
biome:
glob: "*.{js,ts,jsx,tsx,json,css}"
exclude: "package-lock.json"
run: npx biome check --write {staged_files} && git add {staged_files}
Loading
Loading