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
24 changes: 24 additions & 0 deletions .env.e2e
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Environment override variables for running the e2e tests (Playwright).
# When APP_ENV=e2e (set by the npm e2e scripts), these values override .env
# and .env fills in the rest — see loadEnv.js for how the merge works.

# ----- DATABASE -----
# Separate database so e2e test data never touches the regular dev database
MONGO_URL=mongodb://localhost:27017/p5js-web-editor-e2e

# ----- PORT -----
# 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
6 changes: 3 additions & 3 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,12 @@ jobs:
run: npx playwright install-deps chromium

- name: Start app
run: npm start > app.log 2>&1 &
run: npm run start:e2e > 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
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ FROM base AS development
ENV NODE_ENV development
COPY package.json package-lock.json ./
RUN npm install
COPY .babelrc index.js nodemon.json ./
COPY .babelrc index.js nodemon.json loadEnv.js ./
COPY ./webpack ./webpack
COPY client ./client
COPY server ./server
Expand Down
2 changes: 2 additions & 0 deletions contributor_docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ These are the steps that happen when you deploy the application.
6. This image is pushed to [Docker Hub](https://hub.docker.com/r/catarak/p5.js-web-editor/) with a unique tag name (the Travis commit) and also to the `latest` tag.
7. The Kubernetes deployment is updated to image just pushed to Docker Hub on the cluster on Google Kubernetes Engine.

Note that deployed environments do not read `.env` files at all: when `NODE_ENV=production`, the app skips dotenv entirely and expects all configuration as real environment variables, which are injected by the Kubernetes deployment manifests (managed outside this repo). The `.env.production`/`.env.staging` files listed in `.gitignore` are only for hand-run ops scripts and local production testing — see the "Environment Files" section in [installation.md](./installation.md).

## Production Installation

You'll only need to do this if you're testing the production environment locally.
Expand Down
13 changes: 13 additions & 0 deletions contributor_docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,19 @@ If you don't have the full server environment running, you can launch a one-off

12. `$ docker-compose -f docker-compose-development.yml run app --rm bash -l`

## Environment Files

The repo has several `.env`-style files, and they are loaded in different situations:

| File | Committed? | Used when |
| --- | --- | --- |
| `.env` | no | Local development. Create it by copying `.env.example` (see steps above). |
| `.env.example` | yes | Template only — never loaded by the app. |
| `.env.e2e` | yes | End-to-end tests (`npm run e2e`, `e2e:headed`, `e2e:ci`). Merged *over* `.env`, so its values (test database, ports 9000/9002) win and `.env` fills in the rest. Contains no secrets. |
| `.env.production` / `.env.staging` | no | Only for hand-run ops scripts (e.g. `server/migrations/start.js`) and an optional `docker-compose.yml` override. **Deployed production/staging never read `.env` files** — environment variables are injected by Kubernetes (see [deployment.md](./deployment.md)). |

All env loading goes through [`loadEnv.js`](../loadEnv.js) at the repo root, which documents the merge rule. `playwright.config.ts` sets `APP_ENV=e2e` itself before loading env, so every way of running the tests (npm scripts, `npx playwright test`, VS Code) uses the e2e environment and can't accidentally hit your development database; `npm run start:e2e` starts the app server with the same overlay. The one intentional exception is `server/migrations/start.js`, a hand-run script that migrates the production database, so it loads `.env.production` directly. In the future, `loadEnv` might grow to accommodate a production overlay.

## S3 Bucket Configuration

See [this configuration guide](./s3_configuration.md) for information about how to configure your own S3 bucket. These instructions were adapted from [this gist](https://gist.github.com/catarak/70c9301f0fd1ac2d6b58de03f61997e3).
Expand Down
19 changes: 19 additions & 0 deletions e2e/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { test as base, expect } from '@playwright/test';

/**
* Shared e2e test setup. Specs should import { test, expect } from here
* instead of '@playwright/test'.
*
* - Hide Redux DevTools (see client/store.ts) by stubbing
*/
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 };
18 changes: 18 additions & 0 deletions e2e/helpers/cookie-banner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { Page } from '@playwright/test';

/**
* Dismisses the cookie consent banner if it is showing ("Allow essential"),
* keeping Google Analytics in cookieless mode. No-op when the banner is
* absent (e.g. consent was already stored earlier in the test).
*
* Note: uses document.querySelectorAll instead of page.locator as a
* workaround for the banner buttons rendering beyond the viewport.
*/
export async function dismissCookieBanner(page: Page): Promise<void> {
await page.evaluate(() => {
const btn = Array.from(document.querySelectorAll('button')).find((b) =>
/allow essential/i.test(b.textContent ?? '')
) as HTMLElement | undefined;
btn?.click();
});
}
14 changes: 14 additions & 0 deletions e2e/helpers/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/** Returns the env var's value, or throws if it is missing or empty —
* so the constants below are `string`, not `string | undefined`. */
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing ${name} in .env.e2e. Make sure it is set.`);
}
return value;
}

/** Test-user constants from .env.e2e (loaded by playwright.config.ts). */
export const usernamePrefix = requireEnv('E2E_TEST_USERNAME_PREFIX');
export const emailSuffix = requireEnv('E2E_TEST_EMAIL_SUFFIX');
export const password = requireEnv('E2E_TEST_PASSWORD');
29 changes: 29 additions & 0 deletions e2e/setup/drop-e2e-db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import mongoose from 'mongoose';

/**
* Drops the e2e database so every run starts from a clean slate.
* MONGO_URL comes from .env.e2e
*/
export async function dropE2eDatabase() {
const mongoUrl = process.env.MONGO_URL;

if (!mongoUrl) {
throw new Error('MONGO_URL is not set');
}

// 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 ` +
'"<name>-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}"`);
}
5 changes: 5 additions & 0 deletions e2e/setup/global-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { dropE2eDatabase } from './drop-e2e-db';

export default async function globalSetup() {
await dropE2eDatabase();
}
25 changes: 25 additions & 0 deletions e2e/setup/warn-to-start-e2e-server-separately.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* Pre-flight check if the e2e app server has already been started.
*
* Playwright's webServer will start one if not, but this logs a warning for better developer experience:
* - The app takes a few minutes to start & its output is easily missed, especially in Playwright's UI mode.
* - Recommend starting the server separately for more visibility.
*
* Advisory only: never throws, so it can never block the test run.
*/
export async function warnToStartE2eAppServerSeparately(): Promise<void> {
const editorUrl = process.env.EDITOR_URL || 'http://localhost:9000';

try {
await fetch(editorUrl, { signal: AbortSignal.timeout(2000) });
} catch {
console.warn(
`\n⚠️ [e2e] WARNING: No app server detected at ${editorUrl}.\n` +
' Playwright will start one for you, but the first webpack build is\n' +
' slow (a few minutes) and its output is hard to see from here.\n' +
' You may also end up with flakey tests that do not match CI behaviour.\n' +
' For more visibility, run `npm run start:e2e` in another terminal\n' +
' first — Playwright will detect and reuse it.\n'
);
}
}
19 changes: 8 additions & 11 deletions e2e/editor.spec.ts → e2e/specs/editor.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test';
import { test, expect } from '../fixtures';
import { dismissCookieBanner } from '../helpers/cookie-banner';

test.describe('editor page', () => {
test.beforeEach(async ({ page }) => {
Expand All @@ -9,14 +10,7 @@ test.describe('editor page', () => {
'Skip to Play Sketch'
);

// Dismiss cookie banner if it appears
// Note: we do document.querySelectorAll instead of page.locator as workaround due to the buttons on the banner being beyond the viewport
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 dismissCookieBanner(page);

// wait for page to fully load with all main IDE components:
await expect(page.locator('#play-sketch')).toBeVisible(); // play button
Expand Down Expand Up @@ -50,7 +44,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(
Expand All @@ -75,7 +69,10 @@ test.describe('editor page', () => {

// Attempt save via keyboard shortcut
await page.locator('.editor-holder').click();
await page.keyboard.press('ControlOrMeta+S');
// 'Control' (not ControlOrMeta): the app's isMac() checks the user agent,
// which the Desktop Chrome device emulates as Windows — so the app-level
// save shortcut expects Ctrl+S on every host, including Macs.
await page.keyboard.press('Control+S');

// Verify login prompt appears
await expect(
Expand Down
32 changes: 32 additions & 0 deletions e2e/specs/signup.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { test, expect } from '../fixtures';
import { dismissCookieBanner } from '../helpers/cookie-banner';
import { usernamePrefix, emailSuffix, password } from '../helpers/env';

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 = `${usernamePrefix}${Date.now()}`;
const email = `${uniqueId}${emailSuffix}`;

await page.goto('/signup');

await dismissCookieBanner(page);

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
});
// TODO: on next PR, email verification
});
});
9 changes: 1 addition & 8 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,12 @@ if (process.env.NODE_ENV === 'production') {
require('./dist/server.bundle.js');
require('./dist/previewServer.bundle.js');
} else {
let parsed = require('dotenv').config();
require('./loadEnv')();
require('@babel/register')({
extensions: ['.js', '.jsx', '.ts', '.tsx'],
presets: ['@babel/preset-env', '@babel/preset-typescript']
});
require('regenerator-runtime/runtime');
//// in development, let .env values override those in the environment already (i.e. in docker-compose.yml)
// so commenting this out makes the docker container work.
// if (process.env.NODE_ENV === 'development') {
// for (let key in parsed) {
// process.env[key] = parsed[key];
// }
// }
require('./server/server');
require('./server/previewServer');
}
14 changes: 14 additions & 0 deletions loadEnv.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
const path = require('path');
const dotenv = require('dotenv');

/**
* Loads .env — and, when APP_ENV=e2e, .env.e2e first so its values win
* (dotenv v2 never overwrites already-set vars; re-check if upgrading dotenv).
* Both files are optional; paths resolve relative to this file.
*/
module.exports = function loadEnv() {
if (process.env.APP_ENV === 'e2e') {
dotenv.config({ path: path.resolve(__dirname, '.env.e2e'), silent: true });
}
dotenv.config({ path: path.resolve(__dirname, '.env'), silent: true });
};
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"clean": "rimraf dist",
"start": "cross-env BABEL_DISABLE_CACHE=1 NODE_ENV=development nodemon index.js",
"start:prod": "cross-env NODE_ENV=production node index.js",
"start:e2e": "cross-env APP_ENV=e2e npm run start",
"lint": "eslint client server --ext .jsx --ext .js --ext .tsx --ext .ts",
"lint-fix": "eslint client server --ext .jsx --ext .js --ext .tsx --ext .ts --fix",
"build": "npm run build:client && npm run build:server && npm run build:examples",
Expand Down
43 changes: 27 additions & 16 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import { defineConfig, devices } from '@playwright/test';
import { warnToStartE2eAppServerSeparately } from './e2e/setup/warn-to-start-e2e-server-separately';

/**
* 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') });
// DO NOT REMOVE: makes sure that playwright is always run with the e2e env overrides. See loadEnv.js
process.env.APP_ENV = 'e2e';
// eslint-disable-next-line @typescript-eslint/no-var-requires
require('./loadEnv')();

const EDITOR_URL = process.env.EDITOR_URL || 'http://localhost:9000';

warnToStartE2eAppServerSeparately();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './e2e',
testDir: './e2e/specs',
globalSetup: './e2e/setup/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 */
Expand All @@ -28,12 +30,28 @@ 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: EDITOR_URL,

/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry'
},

/* Run your local dev server before starting the tests */
webServer: [
{
name: 'app (e2e env)',
command: 'npm run start:e2e',
url: EDITOR_URL,
/** CI > e2e.yml starts up the e2e app server separately for more logging */
reuseExistingServer: true,
/** stream the app server's startup logs into the terminal running Playwright */
stdout: 'pipe',
stderr: 'pipe',
/** First start compiles webpack from scratch, hence the long timeout. */
timeout: 180_000
}
],

/* Configure projects for major browsers */
projects: [
{
Expand Down Expand Up @@ -71,11 +89,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,
// },
});
3 changes: 1 addition & 2 deletions server/migrations/db_reformat.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
/* eslint-disable */
import mongoose from 'mongoose';
import path from 'path';
import { uniqWith, isEqual } from 'lodash';
require('dotenv').config({ path: path.resolve('.env') });
require('../../loadEnv')();
const ObjectId = mongoose.Types.ObjectId;
mongoose.connect(process.env.MONGO_URL);
mongoose.connection.on('error', () => {
Expand Down
Loading
Loading