diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4a1c036..fd8fe469 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 @@ -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 diff --git a/.gitignore b/.gitignore index 508146df..3cd5af83 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index e36effc8..388fd874 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/e2e-backend/.env b/e2e-backend/.env new file mode 100644 index 00000000..3304e247 --- /dev/null +++ b/e2e-backend/.env @@ -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 diff --git a/e2e-backend/docker-compose.yml b/e2e-backend/docker-compose.yml new file mode 100644 index 00000000..0e7b0db0 --- /dev/null +++ b/e2e-backend/docker-compose.yml @@ -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 diff --git a/e2e/checkout.spec.ts b/e2e/checkout.spec.ts new file mode 100644 index 00000000..616c4faf --- /dev/null +++ b/e2e/checkout.spec.ts @@ -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