diff --git a/.env.e2e b/.env.e2e new file mode 100644 index 0000000000..d8a04f141d --- /dev/null +++ b/.env.e2e @@ -0,0 +1,62 @@ +# Environment variables for running the e2e tests (Playwright). +# +# The Playwright config loads this file automatically for the test process. + +# ======== COPIED FROM .env ======== +API_URL=/editor +AWS_ACCESS_KEY= +AWS_REGION= +AWS_SECRET_KEY= +CORS_ALLOW_LOCALHOST=true +EMAIL_VERIFY_SECRET_TOKEN=whatever_you_want_this_to_be_it_only_matters_for_production +EXAMPLE_USER_EMAIL=examples@p5js.org +EXAMPLE_USER_PASSWORD=hellop5js +GG_EXAMPLES_USERNAME=generativedesign +GG_EXAMPLES_EMAIL=benedikt.gross@generative-gestaltung.de +GG_EXAMPLES_PASS=generativedesign +GITHUB_ID= +GITHUB_SECRET= +GOOGLE_ID= (use google+ api) +GOOGLE_SECRET= (use google+ api) +MAILGUN_DOMAIN= +MAILGUN_KEY= +ML5_LIBRARY_USERNAME=ml5 +ML5_LIBRARY_EMAIL=examples@ml5js.org +ML5_LIBRARY_PASS=helloml5 +S3_BUCKET= +S3_BUCKET_URL_BASE= +SESSION_SECRET=whatever_you_want_this_to_be_it_only_matters_for_production +TRANSLATIONS_ENABLED=true +UI_ACCESS_TOKEN_ENABLED=true +UPLOAD_LIMIT=250000000 + + +# ======== CHANGES FROM .env for e2e testing: ======== +# Separate database so e2e test data never touches the regular dev database +MONGO_URL=mongodb://localhost:27017/p5js-web-editor-e2e + +# Dedicated e2e ports (dev uses 8000/8002). Guarantees the Playwright-managed +# server never collides with — or accidentally reuses — a regular dev server. +PORT=9000 +PREVIEW_PORT=9002 +EDITOR_URL=http://localhost:9000 +PREVIEW_URL=http://localhost:9002 + +# (Redux DevTools dock is hidden by the shared fixture in e2e/fixtures.ts, +# which stubs the browser extension global — no env var needed) + +# --- TEST USER: ---- +# (username is formed with Date.now() to allow for parallel tests) +E2E_TEST_USERNAME_PREFIX=e2e +E2E_TEST_EMAIL_SUFFIX=@example.com +E2E_TEST_PASSWORD=e2e-Test-Password-1 + +# --- EMAIL SETUP: ---- +EMAIL_SENDER=from_e2e_test_run@example.com + +# Send emails over SMTP to a local mail catcher (Mailpit) instead of Mailgun. +# Mailpit's default ports are assumed everywhere (SMTP :1025, HTTP API :8025); +# the server and the specs fall back to them when no env vars are set. +EMAIL_TRANSPORT=smtp + + diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index d3603cbbf9..fefa3ec78a 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -23,6 +23,15 @@ jobs: timeout-minutes: 60 runs-on: ubuntu-latest + services: + # Local mail catcher: the app sends verification emails here over SMTP (port 1025) + # and the e2e tests read them back via Mailpit's REST API (port 8025) + mailpit: + image: axllent/mailpit:latest + ports: + - 1025:1025 + - 8025:8025 + steps: - name: Resolve PR head ref if: github.event_name == 'issue_comment' @@ -48,8 +57,6 @@ jobs: node-version: '18.20.x' cache: 'npm' - - name: Create .env file - run: cp .env.example .env - name: Start MongoDB uses: supercharge/mongodb-github-action@1.10.0 @@ -74,13 +81,16 @@ jobs: if: steps.playwright-cache.outputs.cache-hit == 'true' run: npx playwright install-deps chromium + # Start the app here (rather than leaving it to Playwright's webServer) + # so its output is captured to app.log for debugging. + # Playwright's reuseExistingServer detects it on :9000 and skips its own start. - name: Start app - run: npm start > app.log 2>&1 & + run: ENV_FILE=.env.e2e npm start > app.log 2>&1 & - name: Wait for app to be ready run: | - npx wait-on@7 http://localhost:8000 --timeout 180000 || { - echo "::error::App failed to become ready at http://localhost:8000 within 180s" + npx wait-on@7 http://localhost:9000 --timeout 180000 || { + echo "::error::App failed to become ready at http://localhost:9000 within 180s" echo "----- app.log -----" cat app.log exit 1 diff --git a/e2e/editor.spec.ts b/e2e/editor.spec.ts index 99eb31123d..4cdd880aa9 100644 --- a/e2e/editor.spec.ts +++ b/e2e/editor.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from './fixtures'; test.describe('editor page', () => { test.beforeEach(async ({ page }) => { @@ -50,7 +50,7 @@ test.describe('editor page', () => { // Wait for the sketch iframe src to confirm the sketch actually started await expect( page.locator('iframe[title="sketch preview"]') - ).toHaveAttribute('src', /8002/, { timeout: 10_000 }); + ).toHaveAttribute('src', /9002/, { timeout: 10_000 }); // Assert console output await expect( diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts new file mode 100644 index 0000000000..77fe6dea31 --- /dev/null +++ b/e2e/fixtures.ts @@ -0,0 +1,23 @@ +import { test as base, expect } from '@playwright/test'; + +/** + * Shared e2e test setup. Specs should import { test, expect } from here + * instead of '@playwright/test'. + * + * In development the app renders the Redux DevTools dock monitor whenever the + * browser has no Redux DevTools extension (see showReduxDevTools in + * client/store.ts) — which is always the case in Playwright's clean browser. + * Stubbing the extension global before the app loads keeps the dock monitor + * from rendering over the UI during tests. + */ +export const test = base.extend({ + page: async ({ page }, use) => { + await page.addInitScript(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).__REDUX_DEVTOOLS_EXTENSION__ = () => {}; + }); + await use(page); + } +}); + +export { expect }; diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 0000000000..8f46f715ea --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,34 @@ +import mongoose from 'mongoose'; + +/** + * Runs once before all e2e tests (see globalSetup in playwright.config.ts). + * Drops the e2e database so every run starts from a clean slate. + * + * MONGO_URL comes from .env.e2e, which playwright.config.ts loads before this + * runs. The app server (npm run start:e2e) points at the same database. + */ +export default async function globalSetup() { + const mongoUrl = process.env.MONGO_URL; + + if (!mongoUrl) { + throw new Error( + 'MONGO_URL is not set — expected it from .env.e2e (loaded by playwright.config.ts).' + ); + } + + // Safety guard: never drop anything that isn't explicitly an e2e database. + // Protects against a shell MONGO_URL (which overrides .env.e2e) pointing at + // a dev or production database. + const dbName = new URL(mongoUrl).pathname.replace(/^\//, ''); + if (!dbName.endsWith('-e2e')) { + throw new Error( + `Refusing to drop database "${dbName}": e2e databases must be named ` + + '"-e2e" (check MONGO_URL in .env.e2e or your shell environment).' + ); + } + + const connection = await mongoose.createConnection(mongoUrl).asPromise(); + await connection.dropDatabase(); + await connection.close(); + console.log(`[e2e global-setup] dropped database "${dbName}"`); +} diff --git a/e2e/signup.spec.ts b/e2e/signup.spec.ts new file mode 100644 index 0000000000..8bdc3e0210 --- /dev/null +++ b/e2e/signup.spec.ts @@ -0,0 +1,97 @@ +import { test, expect } from './fixtures'; + +/** + * Signup + email verification flow. + * + * Emails are captured by Mailpit (https://mailpit.axllent.org/), a local fake + * SMTP server, instead of Mailgun (EMAIL_TRANSPORT=smtp in .env.e2e), on + * Mailpit's default ports (SMTP :1025, HTTP API :8025). + * + * Both Mailpit and the app (with .env.e2e config, on port 9000) are started + * automatically by playwright.config.ts (webServer). The only local one-time + * setup is `brew install mailpit`. On CI, Mailpit runs as a service container + * (see e2e.yml) and is reused. + */ + +// Mailpit's default HTTP API port (also health-checked in playwright.config.ts) +const MAILPIT_API = 'http://localhost:8025/api/v1'; + +async function getVerificationLink(email: string): Promise { + // Email delivery is async, so poll Mailpit until the message arrives + let messageId: string | undefined; + await expect + .poll( + async () => { + const res = await fetch( + `${MAILPIT_API}/search?query=${encodeURIComponent(`to:${email}`)}` + ); + const data = await res.json(); + messageId = data.messages?.[0]?.ID; + return messageId; + }, + { + message: `Expected a verification email to ${email} to arrive in Mailpit`, + timeout: 15_000 + } + ) + .toBeTruthy(); + + const res = await fetch(`${MAILPIT_API}/message/${messageId}`); + const message = await res.json(); + const match = (message.HTML as string).match( + /href="(http[^"]*\/verify\?t=[^"]+)"/ + ); + expect( + match, + 'Expected the email to contain a /verify?t= link' + ).toBeTruthy(); + return match![1]; +} + +test.describe('signup and email verification', () => { + test('can sign up and verify email via the emailed link', async ({ + page + }) => { + // Unique per run so the duplicate username/email check passes + const uniqueId = `e2e${Date.now()}`; + const email = `${uniqueId}@example.com`; + const password = 'e2e-Test-Password-1'; + + await page.goto('/signup'); + + // Dismiss cookie banner if it appears (see editor.spec.ts for why querySelectorAll) + await page.evaluate(() => { + const btn = Array.from(document.querySelectorAll('button')).find((b) => + /allow essential|allow all/i.test(b.textContent ?? '') + ) as HTMLElement | undefined; + btn?.click(); + }); + + await page.locator('#username').fill(uniqueId); + await page.locator('#email').fill(email); + await page.locator('#password').fill(password); + await page.locator('#confirmPassword').fill(password); + + // The submit button stays disabled until the async duplicate + // username/email check resolves; click() auto-waits for enabled + await page.getByRole('button', { name: 'Sign Up', exact: true }).click(); + + // Successful signup logs the user in and redirects to the editor + await expect(page.locator('.editor-holder')).toBeVisible({ + timeout: 15_000 + }); + + // "Receive" the verification email and click its link + const verificationLink = await getVerificationLink(email); + await page.goto(verificationLink); + + // On success the verify page flashes a message for ~1s and then redirects + // home, so asserting on the message races the redirect. The redirect only + // happens for a valid token (invalid tokens stay on /verify showing an + // error), so assert the redirect, then the durable outcome via the API. + await expect(page).toHaveURL('/', { timeout: 15_000 }); + + const session = await page.request.get('/editor/session'); + expect((await session.json()).verified).toBe('verified'); + }); +}); diff --git a/index.js b/index.js index 0bc460ad45..15aae60429 100644 --- a/index.js +++ b/index.js @@ -5,7 +5,10 @@ if (process.env.NODE_ENV === 'production') { require('./dist/server.bundle.js'); require('./dist/previewServer.bundle.js'); } else { - let parsed = require('dotenv').config(); + // ENV_FILE lets scripts point at an alternate env file (e.g. .env.e2e for e2e tests) + let parsed = require('dotenv').config({ + path: process.env.ENV_FILE || '.env' + }); require('@babel/register')({ extensions: ['.js', '.jsx', '.ts', '.tsx'], presets: ['@babel/preset-env', '@babel/preset-typescript'] diff --git a/package-lock.json b/package-lock.json index 043700f10b..d5b1abea3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -163,6 +163,7 @@ "@testing-library/react": "^12.1.2", "@types/bcryptjs": "^2.4.6", "@types/classnames": "^2.3.0", + "@types/dotenv": "^6.1.1", "@types/friendly-words": "^1.2.2", "@types/jest": "^29.5.14", "@types/js-cookie": "^3.0.6", @@ -15525,6 +15526,16 @@ "integrity": "sha512-w5jZ0ee+HaPOaX25X2/2oGR/7rgAQSYII7X7pp0m9KgBfMP7uKfMfTvcpl5Dj+eDBbpxKGiqE+flqDr6XTd2RA==", "dev": true }, + "node_modules/@types/dotenv": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@types/dotenv/-/dotenv-6.1.1.tgz", + "integrity": "sha512-ftQl3DtBvqHl9L16tpqqzA4YzCSXZfi7g8cQceTz5rOlYtk/IZbFjAv3mLOQlNIgOaylCQWQoBdDQHPgEBJPHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/escodegen": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/escodegen/-/escodegen-0.0.6.tgz", @@ -50266,6 +50277,15 @@ "integrity": "sha512-w5jZ0ee+HaPOaX25X2/2oGR/7rgAQSYII7X7pp0m9KgBfMP7uKfMfTvcpl5Dj+eDBbpxKGiqE+flqDr6XTd2RA==", "dev": true }, + "@types/dotenv": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@types/dotenv/-/dotenv-6.1.1.tgz", + "integrity": "sha512-ftQl3DtBvqHl9L16tpqqzA4YzCSXZfi7g8cQceTz5rOlYtk/IZbFjAv3mLOQlNIgOaylCQWQoBdDQHPgEBJPHg==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/escodegen": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/escodegen/-/escodegen-0.0.6.tgz", diff --git a/package.json b/package.json index 0f4581b338..9e3c19d4e2 100644 --- a/package.json +++ b/package.json @@ -141,6 +141,7 @@ "@testing-library/react": "^12.1.2", "@types/bcryptjs": "^2.4.6", "@types/classnames": "^2.3.0", + "@types/dotenv": "^6.1.1", "@types/friendly-words": "^1.2.2", "@types/jest": "^29.5.14", "@types/js-cookie": "^3.0.6", diff --git a/playwright.config.ts b/playwright.config.ts index 379f33091c..0897052a5d 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -4,15 +4,22 @@ import { defineConfig, devices } from '@playwright/test'; * Read environment variables from file. * https://github.com/motdotla/dotenv */ -// import dotenv from 'dotenv'; -// import path from 'path'; -// dotenv.config({ path: path.resolve(__dirname, '.env') }); +import dotenv from 'dotenv'; +import path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '.env.e2e') }); + +const baseURL = process.env.EDITOR_URL || 'http://localhost:9000'; +// Mailpit's default HTTP API port; SMTP is on :1025 (see server/utils/mail.ts) +const mailPitBaseURL = 'http://localhost:8025'; /** * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ testDir: './e2e', + /* Drops the e2e database before each run so tests start from a clean slate */ + globalSetup: './e2e/global-setup', /* Timeout per individual test (w/ before & after hooks). Make CI longer than default 30s to accomodate */ timeout: process.env.CI ? 60_000 : 30_000, /* Run tests in files in parallel */ @@ -28,12 +35,41 @@ export default defineConfig({ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('')`. */ - baseURL: 'http://localhost:8000', - + baseURL, /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on-first-retry' }, + /** + * Servers auto-started before the tests (reused if already running): + * + * 1. Mailpit — local mail catcher for signup/verification emails, on its + * default ports. Requires the binary locally (`brew install mailpit`); + * on CI an already-running service container is reused instead. + * 2. The app — started with .env.e2e config on dedicated ports (9000/9002), + * so reuseExistingServer can never latch onto a regular dev server on + * 8000 (which would point at the real dev database and Mailgun). + * First start compiles webpack from scratch, hence the long timeout. + */ + webServer: [ + { + name: 'Mailpit', + command: 'mailpit', + url: `${mailPitBaseURL}/api/v1/info`, + reuseExistingServer: true, + timeout: 15_000 + }, + { + name: 'App server', + command: 'npm run start', + env: { ENV_FILE: '.env.e2e' }, + url: baseURL, + reuseExistingServer: true, + timeout: 120_000, + stdout: 'pipe' + } + ], + /* Configure projects for major browsers */ projects: [ { @@ -71,11 +107,4 @@ export default defineConfig({ // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, // }, ] - - /* Run your local dev server before starting the tests */ - // webServer: { - // command: 'npm run start', - // url: 'http://localhost:3000', - // reuseExistingServer: !process.env.CI, - // }, }); diff --git a/server/utils/mail.ts b/server/utils/mail.ts index bbe9d4fb37..4925a45663 100644 --- a/server/utils/mail.ts +++ b/server/utils/mail.ts @@ -2,15 +2,32 @@ import nodemailer from 'nodemailer'; import mg from 'nodemailer-mailgun-transport'; import { RenderedMailerData } from '../types/email'; -if (!process.env.MAILGUN_KEY) { - throw new Error('Mailgun key missing'); -} - -const auth = { - api_key: process.env.MAILGUN_KEY, - domain: process.env.MAILGUN_DOMAIN +// When EMAIL_TRANSPORT=smtp, emails are sent over plain SMTP (e.g. to a local +// mail catcher like Mailpit during development and e2e tests) instead of Mailgun. +const useSmtpTransport = process.env.EMAIL_TRANSPORT === 'smtp'; +const SMPT_TRANSPORT_CONFIG = { + host: 'localhost', + port: 1025, + secure: false }; +function createTransport(): nodemailer.Transporter { + if (useSmtpTransport) { + return nodemailer.createTransport(SMPT_TRANSPORT_CONFIG); + } + + if (!process.env.MAILGUN_KEY) { + throw new Error('Mailgun key missing'); + } + + const auth = { + api_key: process.env.MAILGUN_KEY, + domain: process.env.MAILGUN_DOMAIN + }; + + return nodemailer.createTransport(mg({ auth })); +} + /** Mail service class wrapping around mailgun */ class Mail { client: nodemailer.Transporter; @@ -18,7 +35,7 @@ class Mail { sendOptions: Pick; constructor() { - this.client = nodemailer.createTransport(mg({ auth })); + this.client = createTransport(); this.sendOptions = { from: process.env.EMAIL_SENDER };