GSOC-E2E-1: Add Playwright CI workflow and sketch execution test#4172
GSOC-E2E-1: Add Playwright CI workflow and sketch execution test#4172Geethegreat wants to merge 9 commits into
Conversation
| - name: Create .env file | ||
| run: | | ||
| cat > .env << 'EOF' | ||
| 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 | ||
| EOF |
There was a problem hiding this comment.
Please make this into a file next to .env.local called .env.e2e instead and then do the following:
| - name: Create .env file | |
| run: | | |
| cat > .env << 'EOF' | |
| 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 | |
| EOF | |
| - name: Create .env file | |
| run: cp .env.e2e .env |
This makes the e2e .env file way easier to maintain
| with: | ||
| name: playwright-report | ||
| path: playwright-report/ | ||
| retention-days: 7 No newline at end of file |
There was a problem hiding this comment.
need to add a new-line at the end of this file
|
|
||
| use: { | ||
| baseURL: 'http://localhost:8000', | ||
| headless: true, |
There was a problem hiding this comment.
remove this and see update for package.json commands
| "test:ci": "npm run lint && npm run test", | ||
| "fetch-examples": "cross-env NODE_ENV=development node ./server/scripts/fetch-examples.js", |
There was a problem hiding this comment.
Add the below new scripts:
"e2e": "playwright test --headed",
"e2e:ci": "playwright test",
Then update how the workflow calls the e2e test with the e2e:ci command instead
Then add docs for devs on how to run these tests
There was a problem hiding this comment.
Additionally can you update .gitignore to ignore the artifacts playwright generates?
Add the below:
# Playwright
test-results/
playwright-report/
.playwright/
| async function dismissCookies(page: Page) { | ||
| try { | ||
| await page.waitForSelector('button', { timeout: 3_000 }); | ||
| await page.evaluate(() => { | ||
| const btn = Array.from(document.querySelectorAll('button')).find((b) => | ||
| /allow all|allow essential/i.test(b.textContent ?? '') | ||
| ) as HTMLElement | undefined; | ||
| btn?.click(); | ||
| }); | ||
| await page.waitForTimeout(400); | ||
| } catch { | ||
| /* no banner */ | ||
| } | ||
| } | ||
|
|
||
| test.describe('p5.js Editor – Playwright E2E', () => { | ||
| test('editor loads and has a sketch iframe', async ({ page }) => { | ||
| await page.goto('http://localhost:8000', { | ||
| waitUntil: 'domcontentloaded', | ||
| timeout: 30_000 | ||
| }); | ||
| await dismissCookies(page); | ||
| await page.evaluate(() => { | ||
| (document.querySelector( | ||
| '[aria-label="Play sketch"]' | ||
| ) as HTMLElement)?.click(); | ||
| }); | ||
| const iframeHandle = await page.waitForSelector('iframe', { | ||
| timeout: 15_000 | ||
| }); | ||
| expect(iframeHandle).toBeTruthy(); | ||
| }); | ||
|
|
||
| test('can access iframe content frame', async ({ page }) => { | ||
| await page.goto('http://localhost:8000'); | ||
| await dismissCookies(page); | ||
| await page.waitForSelector('iframe'); | ||
| const body = page.frameLocator('iframe').first().locator('body'); | ||
| await expect(body).toBeAttached({ timeout: 10_000 }); | ||
| }); | ||
|
|
||
| test('run button triggers sketch in iframe', async ({ page }) => { | ||
| await page.goto('http://localhost:8000'); | ||
| await dismissCookies(page); | ||
| await page.evaluate(() => { | ||
| (document.querySelector( | ||
| '[aria-label="Play sketch"]' | ||
| ) as HTMLElement)?.click(); | ||
| }); | ||
| await page.waitForSelector('iframe', { timeout: 10_000 }); | ||
| const iframeSrc = await page.locator('iframe').getAttribute('src'); | ||
| console.log('iframe src:', iframeSrc); | ||
| expect(iframeSrc).toBeTruthy(); | ||
| await expect(page.locator('iframe')).toBeVisible({ timeout: 10_000 }); | ||
| }); | ||
|
|
||
| test.skip('sketch execution via postMessage', async () => { | ||
| // FINDING: postMessage interception via page.evaluate() returns empty. | ||
| // The sketch iframe (localhost:8002) sends messages via window.parent.parent | ||
| // but these do not surface in Playwright's main page context. | ||
| // Testing sketch output would require CDP or a dedicated message relay | ||
| // — a candidate for GSoC implementation. | ||
| }); | ||
|
|
||
| test('sketch console.log appears in editor console', async ({ page }) => { | ||
| await page.goto('http://localhost:8000'); | ||
|
|
||
| await dismissCookies(page); | ||
|
|
||
| await page.waitForFunction(() => { | ||
| const wrapper = document.querySelector('.CodeMirror') as any; | ||
| return !!wrapper?.CodeMirror; | ||
| }); | ||
|
|
||
| await page.evaluate( | ||
| (newCode) => { | ||
| const cm = (document.querySelector('.CodeMirror') as any)?.CodeMirror; | ||
|
|
||
| if (!cm) { | ||
| throw new Error('CodeMirror not found'); | ||
| } | ||
|
|
||
| cm.setValue(newCode); | ||
| 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('Selected file not found'); | ||
| } | ||
|
|
||
| store.dispatch({ | ||
| type: 'UPDATE_FILE_CONTENT', | ||
| id: selectedFile.id, | ||
| content: newCode | ||
| }); | ||
| }, | ||
| ` | ||
| function setup() { | ||
| createCanvas(400, 400); | ||
| } | ||
|
|
||
| function draw() { | ||
| background(220); | ||
| console.log('hi from sketch'); | ||
| noLoop(); | ||
| } | ||
| ` | ||
| ); | ||
|
|
||
| await page.waitForTimeout(1000); | ||
|
|
||
| await page.locator('#play-sketch').click(); | ||
|
|
||
| const openConsoleButton = page.locator('[aria-label="Open console"]'); | ||
|
|
||
| if (await openConsoleButton.isVisible()) { | ||
| await openConsoleButton.click(); | ||
| } | ||
|
|
||
| await expect | ||
| .poll(() => page.locator('.preview-console__messages').textContent(), { | ||
| timeout: 15000 | ||
| }) | ||
| .toContain('hi from sketch'); | ||
| }); |
There was a problem hiding this comment.
Hi Geeth I think you have too many test blocks here.
This first initial test should just be something like "Can run sketch code from the editor"
I haven't run the below locally, so you may need to edit this, but it would also be helpful to add some comments on what the bot is doing
Remember that this is an e2e test, so you want to describe things that a human user would do, rather than describe the technical internals of the test code
| async function dismissCookies(page: Page) { | |
| try { | |
| await page.waitForSelector('button', { timeout: 3_000 }); | |
| await page.evaluate(() => { | |
| const btn = Array.from(document.querySelectorAll('button')).find((b) => | |
| /allow all|allow essential/i.test(b.textContent ?? '') | |
| ) as HTMLElement | undefined; | |
| btn?.click(); | |
| }); | |
| await page.waitForTimeout(400); | |
| } catch { | |
| /* no banner */ | |
| } | |
| } | |
| test.describe('p5.js Editor – Playwright E2E', () => { | |
| test('editor loads and has a sketch iframe', async ({ page }) => { | |
| await page.goto('http://localhost:8000', { | |
| waitUntil: 'domcontentloaded', | |
| timeout: 30_000 | |
| }); | |
| await dismissCookies(page); | |
| await page.evaluate(() => { | |
| (document.querySelector( | |
| '[aria-label="Play sketch"]' | |
| ) as HTMLElement)?.click(); | |
| }); | |
| const iframeHandle = await page.waitForSelector('iframe', { | |
| timeout: 15_000 | |
| }); | |
| expect(iframeHandle).toBeTruthy(); | |
| }); | |
| test('can access iframe content frame', async ({ page }) => { | |
| await page.goto('http://localhost:8000'); | |
| await dismissCookies(page); | |
| await page.waitForSelector('iframe'); | |
| const body = page.frameLocator('iframe').first().locator('body'); | |
| await expect(body).toBeAttached({ timeout: 10_000 }); | |
| }); | |
| test('run button triggers sketch in iframe', async ({ page }) => { | |
| await page.goto('http://localhost:8000'); | |
| await dismissCookies(page); | |
| await page.evaluate(() => { | |
| (document.querySelector( | |
| '[aria-label="Play sketch"]' | |
| ) as HTMLElement)?.click(); | |
| }); | |
| await page.waitForSelector('iframe', { timeout: 10_000 }); | |
| const iframeSrc = await page.locator('iframe').getAttribute('src'); | |
| console.log('iframe src:', iframeSrc); | |
| expect(iframeSrc).toBeTruthy(); | |
| await expect(page.locator('iframe')).toBeVisible({ timeout: 10_000 }); | |
| }); | |
| test.skip('sketch execution via postMessage', async () => { | |
| // FINDING: postMessage interception via page.evaluate() returns empty. | |
| // The sketch iframe (localhost:8002) sends messages via window.parent.parent | |
| // but these do not surface in Playwright's main page context. | |
| // Testing sketch output would require CDP or a dedicated message relay | |
| // — a candidate for GSoC implementation. | |
| }); | |
| test('sketch console.log appears in editor console', async ({ page }) => { | |
| await page.goto('http://localhost:8000'); | |
| await dismissCookies(page); | |
| await page.waitForFunction(() => { | |
| const wrapper = document.querySelector('.CodeMirror') as any; | |
| return !!wrapper?.CodeMirror; | |
| }); | |
| await page.evaluate( | |
| (newCode) => { | |
| const cm = (document.querySelector('.CodeMirror') as any)?.CodeMirror; | |
| if (!cm) { | |
| throw new Error('CodeMirror not found'); | |
| } | |
| cm.setValue(newCode); | |
| 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('Selected file not found'); | |
| } | |
| store.dispatch({ | |
| type: 'UPDATE_FILE_CONTENT', | |
| id: selectedFile.id, | |
| content: newCode | |
| }); | |
| }, | |
| ` | |
| function setup() { | |
| createCanvas(400, 400); | |
| } | |
| function draw() { | |
| background(220); | |
| console.log('hi from sketch'); | |
| noLoop(); | |
| } | |
| ` | |
| ); | |
| await page.waitForTimeout(1000); | |
| await page.locator('#play-sketch').click(); | |
| const openConsoleButton = page.locator('[aria-label="Open console"]'); | |
| if (await openConsoleButton.isVisible()) { | |
| await openConsoleButton.click(); | |
| } | |
| await expect | |
| .poll(() => page.locator('.preview-console__messages').textContent(), { | |
| timeout: 15000 | |
| }) | |
| .toContain('hi from sketch'); | |
| }); | |
| test.beforeEach(async ({ page }) => { | |
| // go to homepage | |
| await page.goto('/'); | |
| // close cookie dialog if it appears | |
| 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' }); | |
| } | |
| }); | |
| test.describe('p5.js Editor – Playwright E2E', () => { | |
| test('can execute code from the editor by clicking the Play button', async ({ page }) => { | |
| const editor = page.locator('.CodeMirror'); | |
| await editor.click(); | |
| // type in editor | |
| // add a console log that we should see in the terminal | |
| await page.keyboard.press('Control+A'); | |
| await page.keyboard.type( | |
| [ | |
| 'function setup() {', | |
| ' createCanvas(400, 400);', | |
| '}', | |
| '', | |
| 'function draw() {', | |
| ' background(220);', | |
| " console.log('hi from sketch');", | |
| ' noLoop();', | |
| '}' | |
| ].join('\n'), | |
| { delay: 5 } | |
| ); | |
| // click Play button | |
| await page.getByLabel('Play sketch').click(); | |
| // check that console log from editor code is shown in editor console | |
| const openConsole = page.getByLabel('Open console'); | |
| if (await openConsole.isVisible().catch(() => false)) { | |
| await openConsole.click(); | |
| } | |
| await expect(page.locator('.preview-console__messages')).toContainText( | |
| 'hi from sketch', | |
| { timeout: 15_000 } | |
| ); | |
| // not sure if there's a way to find the iframe, check the src and verify that its' sourcecode comes from the editor? In order to verify that the iframe also displays the code from the editor. | |
| }); |
clairep94
left a comment
There was a problem hiding this comment.
Hi Geeth great start!
Some small bits of clean up for your setup code + some suggestions for the first E2e test
Add new environmnet for e2e rather than using staging. Co-authored-by: Claire Peng <128436909+clairep94@users.noreply.github.com>
replace npm install with npm ci Co-authored-by: Claire Peng <128436909+clairep94@users.noreply.github.com>
add white space Co-authored-by: Claire Peng <128436909+clairep94@users.noreply.github.com>
|
Could we also pre-fix all the GSOC E2e PRs with "GSOC-E2E-"? |
Issue:
Fixes #
Demo:
video.-.Trim.mp4
Changes:
This PR introduces an initial Playwright E2E testing setup for the p5.js Web Editor.
I have verified that this pull request:
npm run lint)npm run test)npm run typecheck)developbranch.Fixes #123