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
97 changes: 97 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# ============================================
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ const eslintConfig = [
'node_modules/**',
'.next/**',
'.next-acceptance/**',
'.next-basepath/**',
'out/**',
'out-basepath/**',
'serve-root/**',
'build/**',
'next-env.d.ts',
'coverage/**',
Expand Down
19 changes: 19 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {
Expand All @@ -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: {
Expand All @@ -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: {
Expand Down
69 changes: 69 additions & 0 deletions scripts/serve-basepath.sh
Original file line number Diff line number Diff line change
@@ -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/<name> -> ../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
85 changes: 85 additions & 0 deletions tests/e2e/basepath/signup-redirect.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading