Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
39 changes: 39 additions & 0 deletions .env.e2e
Original file line number Diff line number Diff line change
@@ -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
67 changes: 67 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -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

5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ localhost.key
privkey.pem
terraform/.terraform/

# Playwright
test-results/
playwright-report/
.playwright/

storybook-static
duplicates.json

Expand Down
74 changes: 74 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment thread
clairep94 marked this conversation as resolved.
"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",
Expand Down Expand Up @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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'] }
}
]
});
99 changes: 99 additions & 0 deletions tests/playwright/editor.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
Comment on lines +14 to +19

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is page.evaluate necessary?

does the below not work?

Suggested change
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();
});
const cookieDialog = page.getByRole('dialog');
if (await cookieDialog.isVisible({ timeout: 3_000 }).catch(() => false)) {
await cookieDialog.getByText(/allow essential/i).click();
await cookieDialog.waitFor({ state: 'hidden' });
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion! I tried this approach initially but the "Allow Essential" button is pushed outside the viewport by the Redux DevTools sidebar that's present in the dev build, it exists in the DOM but locator.click() throws "element is outside of the viewport". The JS evaluate approach bypasses the geometry check entirely. Happy to add a comment explaining this if that helps.


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 }
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does the below not work?

Suggested change
await page.waitForFunction(
() => {
const wrapper = document.querySelector('.CodeMirror') as any;
return (wrapper?.CodeMirror?.getValue?.() ?? '').length > 0;
},
{ timeout: 30_000 }
);
const editor = page.locator('.CodeMirror');
await expect(editor).toBeVisible({ timeout: 30_000 });

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, toBeVisible would work to confirm the element is in the DOM, but the reason I'm waiting for getValue().length > 0 is to confirm CodeMirror has actually loaded the default sketch content into its buffer. The element can be visible before the CM instance is fully initialized with content, and the Redux dispatch in the next step depends on files.find(f => f.isSelectedFile) being populated, but the suggested approach works just fine too!


// 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);

@clairep94 clairep94 Jun 22, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ideally we would instead have the bot type into the code editor, then we click play. (vs. injecting into the redux store state)

does that not work?

Suggested change
// 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);
// simulate typing code into editor, then clicking play
const editor = page.locator('.CodeMirror');
await editor.click();
await page.keyboard.press('Control+A');
await page.keyboard.type(
newCode,
{ delay: 5 }
);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried keyboard.type() with { delay: 5 } but CodeMirror's bracket auto-completion adds closing brackets/braces as you type, so the resulting code becomes malformed and the sketch throws a runtime error instead of running. For example typing function setup() { causes CM to auto-insert a }, resulting in function setup() {} with the rest of the body outside the braces.
I also tried keyboard.insertText() which pastes the whole string at once, but that triggered a NotFoundError in CodeMirror's internal DOM (removeChild on a non-child node) it seems CM's virtual rendering doesn't handle that input event correctly.
The Redux dispatch approach was the only reliable method I found. That said, if there's a way to disable CodeMirror's auto-close-brackets feature before typing in the test, that would solve it cleanly — something like cm.setOption('autoCloseBrackets', false) before calling keyboard.type(). Would that be an acceptable approach?

editor-rec.mp4


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 });
});
});
Loading