diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 70a6fe60..bc088dea 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -205,6 +205,103 @@ jobs: if: always() run: echo "## Smoke Tests Complete" >> "$GITHUB_STEP_SUMMARY" + # ============================================ + # STEP 2b: basePath project-site smoke (#157) + # A GitHub Pages project-site fork needs a /RepoName basePath; the class of + # bug where auth redirects / hand-built URLs escape that prefix is invisible + # to the root-anchored suite. This job builds a DEDICATED basePath-prefixed + # export and serves it under /ScriptHammer, then runs the `basepath` project + # (tests/e2e/basepath/**) against it. Self-contained: builds its own artifact + # (the shared build-output is root-path), chromium-only, one fast spec. + # ============================================ + basepath: + name: basePath Project-Site Smoke + runs-on: ubuntu-latest + needs: build + timeout-minutes: 15 + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Install pnpm + uses: pnpm/action-setup@v6 + with: + version: 10.16.1 + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: 20.x + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install Playwright (chromium only) + run: | + for i in 1 2 3; do + pnpm exec playwright install --with-deps chromium && break + echo "Retry $i: playwright install failed, retrying in 10s..." + sleep 10 + done + + - name: Build basePath-prefixed export + run: bash scripts/serve-basepath.sh build + env: + BASE_PATH: /ScriptHammer + # The prefixed build must NOT be zeroed by the CNAME/DISABLE_BASE_PATH + # path — NEXT_PUBLIC_BASE_PATH (set by the script) wins in next.config. + NEXT_PUBLIC_EMAILJS_PUBLIC_KEY: ${{ secrets.EMAILJS_PUBLIC_KEY }} + NEXT_PUBLIC_EMAILJS_SERVICE_ID: ${{ vars.NEXT_PUBLIC_EMAILJS_SERVICE_ID }} + NEXT_PUBLIC_EMAILJS_TEMPLATE_ID: ${{ vars.NEXT_PUBLIC_EMAILJS_TEMPLATE_ID }} + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: pk_test_e2e_dummy_not_a_real_key + NEXT_PUBLIC_PAYPAL_CLIENT_ID: e2e_dummy_paypal_client_id + + - name: Start server (symlink under /ScriptHammer) + run: | + set -euo pipefail + BASE_PATH=/ScriptHammer bash scripts/serve-basepath.sh serve 3000 > serve.log 2>&1 & + SERVE_PID=$! + echo "serve started, PID=$SERVE_PID" + sleep 2 + if ! kill -0 "$SERVE_PID" 2>/dev/null; then + echo "::error::serve exited immediately after launch" + cat serve.log || true + exit 1 + fi + if ! npx wait-on http://localhost:3000/ScriptHammer/sign-up/ --timeout 60000 --verbose; then + echo "::error::wait-on timed out — prefixed serve never bound within 60s" + cat serve.log || true + ss -tlnp 2>/dev/null || true + exit 1 + fi + echo "serve is responding on http://localhost:3000/ScriptHammer/" + + - name: Run basePath project + run: | + pnpm exec playwright test \ + --project=basepath \ + --no-deps \ + --reporter=list \ + --timeout=30000 + env: + CI: true + SKIP_WEBSERVER: true + BASE_URL: http://localhost:3000/ScriptHammer + # The smoke spec intercepts the signup POST (no real user/email), but + # the shared globalSetup hard-requires these to validate prerequisites + # before ANY project runs — so supply the same set the smoke job uses. + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + TEST_USER_PRIMARY_EMAIL: ${{ vars.TEST_USER_PRIMARY_EMAIL }} + TEST_USER_PRIMARY_PASSWORD: ${{ secrets.TEST_USER_PRIMARY_PASSWORD }} + TEST_USER_TERTIARY_EMAIL: ${{ vars.TEST_USER_TERTIARY_EMAIL }} + TEST_USER_TERTIARY_PASSWORD: ${{ secrets.TEST_USER_TERTIARY_PASSWORD }} + + - name: basePath summary + if: always() + run: echo "## basePath Project-Site Smoke Complete" >> "$GITHUB_STEP_SUMMARY" + # ============================================ # STEP 3: Rate-limiting tests (ordered, clean IP) # ============================================ diff --git a/.gitignore b/.gitignore index fa041bf2..649d2310 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,11 @@ # next.js /.next/ /.next-acceptance/ +/.next-basepath/ /out/ +# basePath E2E (#157): prefixed export + symlink wrapper dir +/out-basepath/ +/serve-root/ # production /build diff --git a/eslint.config.mjs b/eslint.config.mjs index e1ab24e4..e5d3f8c9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -19,7 +19,10 @@ const eslintConfig = [ 'node_modules/**', '.next/**', '.next-acceptance/**', + '.next-basepath/**', 'out/**', + 'out-basepath/**', + 'serve-root/**', 'build/**', 'next-env.d.ts', 'coverage/**', diff --git a/playwright.config.ts b/playwright.config.ts index 3587c4b4..e2d8003b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -178,6 +178,20 @@ export default defineConfig({ }, }, + // basePath project (#157): runs ONLY against a dedicated + // NEXT_PUBLIC_BASE_PATH=/ScriptHammer build served under a /ScriptHammer + // prefix (see .github/workflows/e2e.yml). It inherits the prefixed + // baseURL from BASE_URL (global `use.baseURL`), needs no auth/setup + // (the smoke intercepts the signup POST), and is EXCLUDED from every + // root-anchored project below via `**/basepath/**` in their testIgnore. + { + name: 'basepath', + testMatch: '**/basepath/**', + use: { + ...devices['Desktop Chrome'], + }, + }, + // ============================================================ // PARALLEL PROJECTS: Pre-authenticated via storageState // These exclude rate-limiting, brute-force, and sign-up tests @@ -223,6 +237,7 @@ export default defineConfig({ '**/rate-limiting.spec.ts', '**/brute-force.spec.ts', '**/sign-up.spec.ts', + '**/basepath/**', // basePath project only (root-anchored build here) ], dependencies: ['setup'], use: { @@ -261,6 +276,7 @@ export default defineConfig({ '**/rate-limiting.spec.ts', '**/brute-force.spec.ts', '**/sign-up.spec.ts', + '**/basepath/**', // basePath project only (root-anchored build here) ], dependencies: ['setup'], use: { @@ -299,6 +315,7 @@ export default defineConfig({ '**/rate-limiting.spec.ts', '**/brute-force.spec.ts', '**/sign-up.spec.ts', + '**/basepath/**', // basePath project only (root-anchored build here) ], dependencies: ['setup'], use: { @@ -316,6 +333,7 @@ export default defineConfig({ '**/rate-limiting.spec.ts', '**/brute-force.spec.ts', '**/sign-up.spec.ts', + '**/basepath/**', // basePath project only (root-anchored build here) ], dependencies: ['setup'], use: { @@ -334,6 +352,7 @@ export default defineConfig({ '**/rate-limiting.spec.ts', '**/brute-force.spec.ts', '**/sign-up.spec.ts', + '**/basepath/**', // basePath project only (root-anchored build here) ], dependencies: ['setup'], use: { diff --git a/scripts/serve-basepath.sh b/scripts/serve-basepath.sh new file mode 100755 index 00000000..942b8fec --- /dev/null +++ b/scripts/serve-basepath.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# +# serve-basepath.sh — build + serve a basePath-prefixed static export so the +# `basepath` Playwright project (tests/e2e/basepath/**, issue #157) can exercise +# GitHub Pages project-site behavior that the root-anchored E2E suite can't see. +# +# The template deploys to a custom domain (basePath ''), so the normal E2E build +# serves at the root. A fork on a project-site needs a /RepoName basePath, and +# the whole class of "auth redirect / hand-built URL escapes the prefix" bug is +# invisible unless we actually build AND serve under that prefix. `serve` has no +# subpath flag, so we symlink a wrapper dir: serve-root/ -> ../out-basepath. +# +# Usage: +# scripts/serve-basepath.sh build # build the prefixed export only +# scripts/serve-basepath.sh serve [PORT] # symlink-serve an existing build (fg) +# scripts/serve-basepath.sh all [PORT] # build then serve (fg) +# +# Env: +# BASE_PATH the prefix, default /ScriptHammer +# OUT_DIR export dir, default out-basepath +# PORT serve port, default 3000 (arg overrides) + +set -euo pipefail + +BASE_PATH="${BASE_PATH:-/ScriptHammer}" +OUT_DIR="${OUT_DIR:-out-basepath}" +SERVE_ROOT="${SERVE_ROOT:-serve-root}" +# BASE_PATH is /ScriptHammer → the symlink name is ScriptHammer. +PREFIX_NAME="${BASE_PATH#/}" + +cmd="${1:-all}" +PORT="${2:-${PORT:-3000}}" + +build() { + echo "==> Building basePath-prefixed export (${BASE_PATH}) into ${OUT_DIR}/" + rm -rf "${OUT_DIR}" + # A non-empty NEXT_PUBLIC_BASE_PATH wins over detect-project.js (next.config.ts), + # so this bakes assets at ${BASE_PATH}/_next/* and injects the client basePath. + # + # NEXT_DIST_DIR does double duty: it keeps this build off the root .next AND, + # under `output: 'export'` on Next 15.5, the static export lands directly in + # the distDir (it does NOT create out/). So point distDir at OUT_DIR and the + # export is already where we want it — no mv, no out/ to stat. + NEXT_PUBLIC_BASE_PATH="${BASE_PATH}" NEXT_DIST_DIR="${OUT_DIR}" pnpm build + if [ ! -d "${OUT_DIR}/sign-up" ]; then + echo "::error::basePath export missing ${OUT_DIR}/sign-up — build did not export as expected" >&2 + exit 1 + fi + echo "==> Export ready: ${OUT_DIR}/ (sign-up present)" +} + +serve() { + echo "==> Symlink-serving ${OUT_DIR} under ${BASE_PATH} on :${PORT}" + rm -rf "${SERVE_ROOT}" + mkdir -p "${SERVE_ROOT}" + ln -s "../${OUT_DIR}" "${SERVE_ROOT}/${PREFIX_NAME}" + # -n: don't ask to copy to clipboard / open browser (non-interactive CI). + exec pnpm exec serve "${SERVE_ROOT}" -l "${PORT}" -n +} + +case "${cmd}" in + build) build ;; + serve) serve ;; + all) build; serve ;; + *) + echo "usage: $0 {build|serve|all} [PORT]" >&2 + exit 2 + ;; +esac diff --git a/tests/e2e/basepath/signup-redirect.spec.ts b/tests/e2e/basepath/signup-redirect.spec.ts new file mode 100644 index 00000000..04b39cf9 --- /dev/null +++ b/tests/e2e/basepath/signup-redirect.spec.ts @@ -0,0 +1,85 @@ +/** + * E2E Test: basePath-aware signup redirect (issue #157 / #154) + * + * This spec runs ONLY under the `basepath` Playwright project, which serves a + * dedicated `NEXT_PUBLIC_BASE_PATH=/ScriptHammer` build under a `/ScriptHammer` + * prefix (see .github/workflows/e2e.yml + scripts/serve-basepath.sh). The rest + * of the suite builds+serves at the ROOT, so this whole class of bug — auth + * redirect URLs that must carry the GitHub Pages project-site basePath — is + * structurally invisible to it. + * + * The signup flow builds `emailRedirectTo` via getRedirectUrl('/auth/callback') + * (src/contexts/AuthContext.tsx). The Supabase JS client turns that into a + * `redirect_to` query param on POST {SUPABASE_URL}/auth/v1/signup. On a + * project-site build the correct value is the PREFIXED, trailing-slash callback + * (`.../ScriptHammer/auth/callback/`). #154 fixed the construction; this pins it + * under a real basePath build without needing an email inbox or a real user: + * the signup POST is intercepted and fulfilled with a stub response. + */ + +import { test, expect } from '@playwright/test'; +import { dismissCookieBanner } from '../utils/test-user-factory'; + +const BASE_PATH = '/ScriptHammer'; + +test.describe('basePath-aware signup redirect (#157)', () => { + test('the signup POST carries the prefixed, trailing-slash redirect_to', async ({ + page, + }) => { + // Fail loudly if any prefixed asset 404s — that means the dedicated prefix + // build + symlink-serve is broken (the mechanism this project depends on). + const assetFailures: string[] = []; + page.on('response', (res) => { + const url = res.url(); + if (url.includes('/_next/') && res.status() >= 400) { + assetFailures.push(`${res.status()} ${url}`); + } + }); + + // Intercept the Supabase signup POST and fulfill it with a stub so no real + // user is created and no confirmation email is sent. We only care about the + // request URL the client built. + let signupRequestUrl: string | null = null; + await page.route('**/auth/v1/signup**', async (route) => { + signupRequestUrl = route.request().url(); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + id: '00000000-0000-0000-0000-000000000000', + email: 'basepath-smoke@example.com', + aud: 'authenticated', + role: 'authenticated', + }), + }); + }); + + // Navigate with the FULL prefixed path. A relative 'sign-up/' would resolve + // against baseURL (http://localhost:3000/ScriptHammer, no trailing slash) + // and drop the last segment → /sign-up/. An absolute path that includes the + // prefix is unambiguous — baseURL supplies only the origin. + await page.goto(`${BASE_PATH}/sign-up/`); + expect(page.url()).toContain(`${BASE_PATH}/sign-up`); + + await dismissCookieBanner(page); + + // Drive the sign-up form (fields: #email, #password, #confirm-password). + const email = `basepath-smoke-${Date.now()}@example.com`; + await page.locator('#email').fill(email); + await page.locator('#password').fill('TestPassword123!'); + await page.locator('#confirm-password').fill('TestPassword123!'); + await page.getByRole('button', { name: /sign up/i }).click(); + + // Wait for the intercepted POST to have fired. + await expect.poll(() => signupRequestUrl).not.toBeNull(); + + const redirectTo = new URL(signupRequestUrl!).searchParams.get( + 'redirect_to' + ); + const origin = new URL(page.url()).origin; + expect(redirectTo).toBe(`${origin}${BASE_PATH}/auth/callback/`); + + // The build's own assets must have loaded under the prefix. + expect(assetFailures, assetFailures.join('\n')).toHaveLength(0); + }); +});