Skip to content
Merged
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
47 changes: 47 additions & 0 deletions .github/workflows/playwright.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: Playwright E2E Tests

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

jobs:
test:
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install dependencies
run: |
cd web-app
npm ci

- name: Install Playwright Browsers
run: |
cd web-app
npx playwright install --with-deps

- name: Run Playwright tests
run: |
cd web-app
npx playwright test

- name: Upload Playwright Report
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: web-app/playwright-report/
retention-days: 30
Binary file modified .gitignore
Binary file not shown.
271 changes: 271 additions & 0 deletions web-app/index.html

Large diffs are not rendered by default.

15 changes: 11 additions & 4 deletions web-app/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
main.js — App wiring for Premium Python Projects Gallery
═══════════════════════════════════════════════════════════════ */

import { updateProjectVisibility } from "./modules/utils.js";

function prefersReducedMotion() {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
Expand Down Expand Up @@ -136,6 +138,7 @@ document.addEventListener("DOMContentLoaded", function () {
var footerNode = document.querySelector("footer.footer");
var backToTopNode = document.getElementById("backToTop");
var infoModalNode = document.getElementById("infoModalOverlay");
var projectModalNode = document.getElementById("projectModal");
var sidebarDockNode = document.getElementById("mainSidebar");
var mobileToggleNode = document.getElementById("mobileSidebarToggle");
var heroControlsNode = document.querySelector(".hero-controls");
Expand All @@ -147,6 +150,7 @@ document.addEventListener("DOMContentLoaded", function () {
if (footerNode) fragment.appendChild(footerNode);
if (backToTopNode) fragment.appendChild(backToTopNode);
if (infoModalNode) fragment.appendChild(infoModalNode);
if (projectModalNode) fragment.appendChild(projectModalNode);
if (heroControlsNode) heroControlsNode.remove();

document.body.appendChild(fragment);
Expand Down Expand Up @@ -1018,8 +1022,8 @@ document.addEventListener("DOMContentLoaded", function () {
});

renderRecentSearches();
});
/* ═══════════════════════════════════════════════════════════════

/* ═══════════════════════════════════════════════════════════════
MODAL
═══════════════════════════════════════════════════════════════ */
function getFocusableElements(root) {
Expand Down Expand Up @@ -1062,7 +1066,7 @@ function openProjectSafe(name, trigger) {
var scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
document.body.style.paddingRight = scrollbarWidth + "px";
document.body.style.overflow = "hidden";
window.setMainInert(true);
setMainInert(true);

safeRun(function () {
if (typeof getProjectHTML === "function") {
Expand Down Expand Up @@ -1145,7 +1149,7 @@ function closeProjectSafe() {
modal.setAttribute("aria-hidden", "true");
document.body.style.paddingRight = "";
document.body.style.overflow = "";
window.setMainInert(false);
setMainInert(false);
if (removeTrap) {
removeTrap();
removeTrap = null;
Expand All @@ -1172,6 +1176,7 @@ document.addEventListener("keydown", function (e) {
/* ── Expose for inline use ────────────────────────────────── */
window.openProjectSafe = openProjectSafe;
window.closeProjectSafe = closeProjectSafe;
window.setMainInert = setMainInert;

/* ═══════════════════════════════════════════════════════════════
WIRE PROJECT CARDS
Expand Down Expand Up @@ -1644,3 +1649,5 @@ if (progressBar) {

// Initial card filtering state update
updateProjectVisibility(currentCategory, currentSearchQuery);
});

5 changes: 3 additions & 2 deletions web-app/js/projects/tic-tac-toe.js
Original file line number Diff line number Diff line change
Expand Up @@ -865,6 +865,7 @@ function initTicTacToe() {
if (!gameOver) lockBoard(false);
}
}

initTicTacToe();
if (document.getElementById("start-btn")) {
initTicTacToe();
}
Comment on lines +868 to +870
//end of init function
64 changes: 64 additions & 0 deletions web-app/package-lock.json

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

4 changes: 3 additions & 1 deletion web-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"test": "echo \"Error: no test specified\" && exit 1",
"test:e2e": "playwright test"
},
"keywords": [],
"author": "",
Expand All @@ -18,6 +19,7 @@
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.43.0",
"@playwright/test": "^1.60.0",
"codemirror": "^6.0.2",
"esbuild": "^0.28.0"
}
Expand Down
40 changes: 40 additions & 0 deletions web-app/playwright.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
const { defineConfig, devices } = require('@playwright/test');

module.exports = defineConfig({
testDir: './tests-e2e',
timeout: 45000,
expect: {
timeout: 5000
},
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://127.0.0.1:8000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
webServer: {
command: 'python3 -m http.server 8000',
url: 'http://127.0.0.1:8000',
reuseExistingServer: !process.env.CI,
cwd: __dirname,
timeout: 10000,
},
});
75 changes: 75 additions & 0 deletions web-app/tests-e2e/modal.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
const { test, expect } = require('@playwright/test');

test.describe('Modal Lifecycle & Focus Trapping', () => {
test('should open modal, trap keyboard focus, close modal, and restore focus to trigger button', async ({ page }) => {
await page.goto('/');

// Scroll to projects to show cards
const exploreBtn = page.locator('#exploreBtn');
await exploreBtn.click();

// Wait for sidebar to be active
await expect(page.locator('body')).toHaveClass(/sidebar-active/);

// Get the first project card's "Try It" button
const firstCardPlayBtn = page.locator('.project-card .btn-play').first();
await expect(firstCardPlayBtn).toBeVisible();

// Focus and click the play button
await firstCardPlayBtn.focus();
await firstCardPlayBtn.click();

const modal = page.locator('#projectModal');
// Verify modal has active class
await expect(modal).toHaveClass(/active/);

// Selector for focusable elements
const focusableSelector = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';

// Get focusable element tags/ids/classes inside modal to compare
const focusablesInModal = await modal.evaluate((modalEl, sel) => {
return Array.from(modalEl.querySelectorAll(sel))
.filter(el => !el.closest('[aria-hidden="true"]') && !el.classList.contains("visually-hidden"))
.map(el => el.tagName + (el.id ? '#' + el.id : '') + (el.className ? '.' + el.className.split(' ').join('.') : ''));
Comment on lines +31 to +33
}, focusableSelector);

expect(focusablesInModal.length).toBeGreaterThan(0);

// 1. Manually focus the first focusable element inside the modal
await modal.evaluate((modalEl, sel) => {
const focusables = Array.from(modalEl.querySelectorAll(sel))
.filter(el => !el.closest('[aria-hidden="true"]') && !el.classList.contains("visually-hidden"));
focusables[0].focus();
}, focusableSelector);

// 2. Press Shift+Tab. Focus should wrap around to the last focusable element in the modal.
await page.keyboard.press('Shift+Tab');

let activeTag = await page.evaluate(() => {
const el = document.activeElement;
return el.tagName + (el.id ? '#' + el.id : '') + (el.className ? '.' + el.className.split(' ').join('.') : '');
});
const lastTagName = focusablesInModal[focusablesInModal.length - 1];
expect(activeTag).toBe(lastTagName);

// 3. Press Tab. Focus should wrap back to the first focusable element in the modal.
await page.keyboard.press('Tab');

activeTag = await page.evaluate(() => {
const el = document.activeElement;
return el.tagName + (el.id ? '#' + el.id : '') + (el.className ? '.' + el.className.split(' ').join('.') : '');
});
const firstTagName = focusablesInModal[0];
expect(activeTag).toBe(firstTagName);

// 4. Close the modal by pressing Escape
await page.keyboard.press('Escape');

// Verify modal is closed (no longer has active class)
await expect(modal).not.toHaveClass(/active/);

// Verify focus is restored to the original play button
const isPlayBtnFocused = await firstCardPlayBtn.evaluate(el => el === document.activeElement);
expect(isPlayBtnFocused).toBe(true);
});
});
32 changes: 32 additions & 0 deletions web-app/tests-e2e/playground.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
const { test, expect } = require('@playwright/test');

test.describe('Python Playground', () => {
test('should load Pyodide, execute default code, and show output in console', async ({ page }) => {
// 1. Navigate to homepage
await page.goto('/');

// 2. Click the Playground tab in the sticky filter bar
const playgroundTab = page.locator('button[data-sticky-category="playground"]');
await expect(playgroundTab).toBeVisible();
await playgroundTab.click();

// Verify playground section is displayed
const playgroundSection = page.locator('#playgroundSection');
await expect(playgroundSection).toBeVisible();

// 3. Wait for Pyodide load completion (wait up to 30 seconds for external download)
const statusText = page.locator('#statusText');
await expect(statusText).toHaveText(/Pyodide Ready/, { timeout: 30000 });

// Verify run code button is enabled
const runBtn = page.locator('#runCode');
await expect(runBtn).toBeEnabled();

// 4. Click the run code button
await runBtn.click();

// 5. Verify output console shows "Hello, World!"
const consoleOutput = page.locator('#consoleOutput');
await expect(consoleOutput).toContainText('Hello, World!');
});
});
Loading
Loading