diff --git a/.env.e2e b/.env.e2e new file mode 100644 index 0000000000..dbf05c988b --- /dev/null +++ b/.env.e2e @@ -0,0 +1,39 @@ +API_URL=/editor +CORS_ALLOW_LOCALHOST=true +TRANSLATIONS_ENABLED=true +UI_ACCESS_TOKEN_ENABLED=false +UPLOAD_LIMIT=250000000 +PORT=8000 +PREVIEW_PORT=8002 +EDITOR_URL=http://localhost:8000 +PREVIEW_URL=http://localhost:8002 +MONGO_URL=mongodb://localhost:27017/p5js-web-editor +SESSION_SECRET=ci-session-secret +EMAIL_VERIFY_SECRET_TOKEN=ci-verify-token +# These accounts are used by the app to serve examples. +# Real values not needed for E2E tests — dummy strings prevent crashes. +EMAIL_SENDER=ci@example.com +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 +ML5_LIBRARY_USERNAME=ml5 +ML5_LIBRARY_EMAIL=examples@ml5js.org +ML5_LIBRARY_PASS=helloml5 +# Mailgun — non-empty string required to pass the startup check in +# server/utils/mail.ts. No emails are sent during E2E tests. +MAILGUN_KEY=dummy-mailgun-key +MAILGUN_DOMAIN=dummy.mailgun.org +# AWS S3 — non-empty strings required. No file uploads in E2E tests. +AWS_ACCESS_KEY=dummy-aws-access-key +AWS_SECRET_KEY=dummy-aws-secret-key +AWS_REGION=us-east-1 +S3_BUCKET=dummy-bucket +S3_BUCKET_URL_BASE=https://dummy-bucket.s3.amazonaws.com +# GitHub OAuth — not tested in E2E suite, dummy values prevent crash. +GITHUB_ID=dummy-github-id +GITHUB_SECRET=dummy-github-secret +# Google OAuth — not tested in E2E suite, dummy values prevent crash. +GOOGLE_ID=dummy-google-id +GOOGLE_SECRET=dummy-google-secret diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000000..a86f455ebc --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,67 @@ +name: E2E Tests + +on: + pull_request: + branches: + - develop + +jobs: + e2e: + name: End-to-end tests + environment: e2e-tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: '18.20.x' + cache: 'npm' + + - name: Start MongoDB + uses: supercharge/mongodb-github-action@1.10.0 + with: + mongodb-version: '6.0' + + - name: Create .env file + run: cp .env.e2e .env + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Start the app + run: npm run start & + + - name: Wait for app to be ready + run: | + for i in $(seq 1 120); do + if curl -sf http://localhost:8000 | grep -q "p5"; then + echo "App fully ready" + exit 0 + fi + + echo "Still bundling... $i/120" + sleep 5 + done + + echo "App did not become ready" + exit 1 + + - name: Run E2E tests + run: npm run e2e:ci + env: + CI: true + + - name: Upload Playwright report on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 + diff --git a/.gitignore b/.gitignore index e96e5328de..6c7bfee487 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,11 @@ localhost.key privkey.pem terraform/.terraform/ +# Playwright +test-results/ +playwright-report/ +.playwright/ + storybook-static duplicates.json diff --git a/package-lock.json b/package-lock.json index a0757fe864..3f30fb3c58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -147,6 +147,7 @@ "@babel/preset-env": "^7.14.7", "@babel/preset-react": "^7.14.5", "@babel/preset-typescript": "^7.27.1", + "@playwright/test": "^1.60.0", "@storybook/addon-actions": "^7.6.8", "@storybook/addon-docs": "^7.6.8", "@storybook/addon-essentials": "^7.6.8", @@ -7860,6 +7861,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@pmmmwh/react-refresh-webpack-plugin": { "version": "0.5.11", "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.11.tgz", @@ -30296,6 +30313,38 @@ "node": ">=6" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/please-upgrade-node": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz", @@ -42291,6 +42340,15 @@ "dev": true, "optional": true }, + "@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "requires": { + "playwright": "1.60.0" + } + }, "@pmmmwh/react-refresh-webpack-plugin": { "version": "0.5.11", "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.11.tgz", @@ -58551,6 +58609,22 @@ "find-up": "^3.0.0" } }, + "playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "requires": { + "fsevents": "2.3.2", + "playwright-core": "1.60.0" + } + }, + "playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true + }, "please-upgrade-node": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz", diff --git a/package.json b/package.json index 2a8c3ca370..c7b42669d8 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "test": "NODE_ENV=test jest", "test:watch": "NODE_ENV=test jest --watch", "test:ci": "npm run lint && npm run test", + "e2e": "playwright test --headed", + "e2e:ci": "playwright test", "fetch-examples": "cross-env NODE_ENV=development node ./server/scripts/fetch-examples.js", "fetch-examples-gg": "cross-env NODE_ENV=development node ./server/scripts/fetch-examples-gg.js", "fetch-examples-ml5": "cross-env NODE_ENV=development node ./server/scripts/fetch-examples-ml5.js", @@ -121,6 +123,7 @@ "@babel/preset-env": "^7.14.7", "@babel/preset-react": "^7.14.5", "@babel/preset-typescript": "^7.27.1", + "@playwright/test": "^1.60.0", "@storybook/addon-actions": "^7.6.8", "@storybook/addon-docs": "^7.6.8", "@storybook/addon-essentials": "^7.6.8", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000000..af611b3272 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,18 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/playwright', + testMatch: '**/*.spec.ts', + timeout: 120_000, + + use: { + baseURL: 'http://localhost:8000' + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] } + } + ] +}); diff --git a/tests/playwright/editor.spec.ts b/tests/playwright/editor.spec.ts new file mode 100644 index 0000000000..559283da2c --- /dev/null +++ b/tests/playwright/editor.spec.ts @@ -0,0 +1,99 @@ +import { test, expect } from '@playwright/test'; + +test.describe('p5.js Editor – Playwright E2E', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + + // Wait for the page to be interactive before checking for the banner + await page.waitForSelector('.CodeMirror', { timeout: 30_000 }); + + // Dismiss cookie banner via JS — handles the case where the button + // is outside the viewport due to the Redux DevTools sidebar + 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.waitForTimeout(400); + }); + + test('can execute code from the editor by clicking the Play button', async ({ + page + }) => { + const newCode = [ + 'function setup() {', + ' createCanvas(400, 400);', + '}', + '', + 'function draw() {', + ' background(220);', + " console.log('hi from sketch');", + ' noLoop();', + '}' + ].join('\n'); + + // Wait for CodeMirror to be ready + await page.waitForFunction( + () => { + const wrapper = document.querySelector('.CodeMirror') as any; + return (wrapper?.CodeMirror?.getValue?.() ?? '').length > 0; + }, + { timeout: 30_000 } + ); + + // Update code via CodeMirror API + Redux dispatch + // (confirmed working approach from earlier diagnostic work) + await page.evaluate((code) => { + const cm = (document.querySelector('.CodeMirror') as any).CodeMirror; + cm.setValue(code); + cm.refresh(); + + const root = document.querySelector('#root') as any; + const fiberKey = Object.keys(root).find((k) => + k.startsWith('__reactContainer') + ); + let node = root[fiberKey]; + let store: any = null; + while (node) { + if (node.memoizedProps?.store) { + store = node.memoizedProps.store; + break; + } + node = node.child; + } + if (!store) throw new Error('Redux store not found'); + + const selectedFile = store + .getState() + .files.find((f: any) => f.isSelectedFile); + if (!selectedFile) throw new Error('No selected file'); + + store.dispatch({ + type: 'UPDATE_FILE_CONTENT', + id: selectedFile.id, + content: code + }); + }, newCode); + + await page.waitForTimeout(500); + + // Click Play + await page.locator('#play-sketch').click(); + + // Wait for the sketch iframe to confirm the sketch actually started + await page.waitForFunction( + () => + Array.from(document.querySelectorAll('iframe')).some((f) => + (f as HTMLIFrameElement).src.includes('8002') + ), + { timeout: 10_000 } + ); + + // Assert console output + await expect( + page.locator('.preview-console__messages') + ).toContainText('hi from sketch', { timeout: 15_000 }); + }); +});