Skip to content

Commit 371528a

Browse files
chore: add Playwright E2E tests, configure CI workflow, and resolve modal layout and focus-trap bugs
1 parent 7d3fad5 commit 371528a

12 files changed

Lines changed: 381 additions & 12 deletions

File tree

.github/workflows/playwright.yml

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name: Playwright E2E Tests
2+
3+
on:
4+
push:
5+
branches: [ main ]
6+
pull_request:
7+
branches: [ main ]
8+
9+
jobs:
10+
test:
11+
timeout-minutes: 15
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v4
15+
16+
- name: Set up Node.js
17+
uses: actions/setup-node@v4
18+
with:
19+
node-version: 20
20+
21+
- name: Set up Python
22+
uses: actions/setup-python@v5
23+
with:
24+
python-version: "3.12"
25+
26+
- name: Install dependencies
27+
run: |
28+
cd web-app
29+
npm ci
30+
31+
- name: Install Playwright Browsers
32+
run: |
33+
cd web-app
34+
npx playwright install --with-deps
35+
36+
- name: Run Playwright tests
37+
run: |
38+
cd web-app
39+
npx playwright test
40+
41+
- name: Upload Playwright Report
42+
uses: actions/upload-artifact@v4
43+
if: always()
44+
with:
45+
name: playwright-report
46+
path: web-app/playwright-report/
47+
retention-days: 30

.gitignore

46 Bytes
Binary file not shown.

web-app/index.html

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -402,7 +402,6 @@ <h3>Spot the Difference</h3>
402402
</div>
403403
</div>
404404
</template>
405-
</div>
406405

407406
<div class="sidebar-menu-wrapper">
408407
<div class="sidebar-menu-title">Categories</div>
@@ -835,8 +834,6 @@ <h3>Legal</h3>
835834
var guards = [
836835
'#projectsSection', /* entire projects grid area */
837836
'#projectsGrid',
838-
'.project-card',
839-
'.btn-play',
840837
'.modal', /* project modal */
841838
'#projectModal',
842839
'.modal-content',
@@ -851,7 +848,7 @@ <h3>Legal</h3>
851848
if (document.body.classList.contains('sidebar-active')) {
852849
e.stopPropagation();
853850
}
854-
}, true /* capture — runs before main.js bubble handler */);
851+
}, false /* bubble — runs after target target bubble handlers */);
855852
});
856853
});
857854

@@ -865,7 +862,7 @@ <h3>Legal</h3>
865862
if (document.body.classList.contains('sidebar-active')) {
866863
e.stopPropagation();
867864
}
868-
}, true);
865+
}, false);
869866
});
870867
});
871868
});

web-app/js/main.js

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
main.js — App wiring for Premium Python Projects Gallery
33
═══════════════════════════════════════════════════════════════ */
44

5+
import { updateProjectVisibility } from "./modules/utils.js";
6+
57
function prefersReducedMotion() {
68
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
79
}
@@ -136,6 +138,7 @@ document.addEventListener("DOMContentLoaded", function () {
136138
var footerNode = document.querySelector("footer.footer");
137139
var backToTopNode = document.getElementById("backToTop");
138140
var infoModalNode = document.getElementById("infoModalOverlay");
141+
var projectModalNode = document.getElementById("projectModal");
139142
var sidebarDockNode = document.getElementById("mainSidebar");
140143
var mobileToggleNode = document.getElementById("mobileSidebarToggle");
141144
var heroControlsNode = document.querySelector(".hero-controls");
@@ -147,6 +150,7 @@ document.addEventListener("DOMContentLoaded", function () {
147150
if (footerNode) fragment.appendChild(footerNode);
148151
if (backToTopNode) fragment.appendChild(backToTopNode);
149152
if (infoModalNode) fragment.appendChild(infoModalNode);
153+
if (projectModalNode) fragment.appendChild(projectModalNode);
150154
if (heroControlsNode) heroControlsNode.remove();
151155

152156
document.body.appendChild(fragment);
@@ -1018,8 +1022,8 @@ document.addEventListener("DOMContentLoaded", function () {
10181022
});
10191023

10201024
renderRecentSearches();
1021-
});
1022-
/* ═══════════════════════════════════════════════════════════════
1025+
1026+
/* ═══════════════════════════════════════════════════════════════
10231027
MODAL
10241028
═══════════════════════════════════════════════════════════════ */
10251029
function getFocusableElements(root) {
@@ -1062,7 +1066,7 @@ function openProjectSafe(name, trigger) {
10621066
var scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
10631067
document.body.style.paddingRight = scrollbarWidth + "px";
10641068
document.body.style.overflow = "hidden";
1065-
window.setMainInert(true);
1069+
setMainInert(true);
10661070

10671071
safeRun(function () {
10681072
if (typeof getProjectHTML === "function") {
@@ -1145,7 +1149,7 @@ function closeProjectSafe() {
11451149
modal.setAttribute("aria-hidden", "true");
11461150
document.body.style.paddingRight = "";
11471151
document.body.style.overflow = "";
1148-
window.setMainInert(false);
1152+
setMainInert(false);
11491153
if (removeTrap) {
11501154
removeTrap();
11511155
removeTrap = null;
@@ -1172,6 +1176,7 @@ document.addEventListener("keydown", function (e) {
11721176
/* ── Expose for inline use ────────────────────────────────── */
11731177
window.openProjectSafe = openProjectSafe;
11741178
window.closeProjectSafe = closeProjectSafe;
1179+
window.setMainInert = setMainInert;
11751180

11761181
/* ═══════════════════════════════════════════════════════════════
11771182
WIRE PROJECT CARDS
@@ -1645,3 +1650,4 @@ if (progressBar) {
16451650
// Initial card filtering state update
16461651
updateProjectVisibility(currentCategory, currentSearchQuery);
16471652
});
1653+

web-app/js/projects/tic-tac-toe.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -867,6 +867,7 @@ function initTicTacToe() {
867867
if (!gameOver) lockBoard(false);
868868
}
869869
}
870-
871-
initTicTacToe();
870+
if (document.getElementById("start-btn")) {
871+
initTicTacToe();
872+
}
872873
//end of init function

web-app/package-lock.json

Lines changed: 64 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

web-app/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
"version": "1.0.0",
44
"main": "index.js",
55
"scripts": {
6-
"test": "echo \"Error: no test specified\" && exit 1"
6+
"test": "echo \"Error: no test specified\" && exit 1",
7+
"test:e2e": "playwright test"
78
},
89
"keywords": [],
910
"author": "",
@@ -18,6 +19,7 @@
1819
"@codemirror/state": "^6.6.0",
1920
"@codemirror/theme-one-dark": "^6.1.3",
2021
"@codemirror/view": "^6.43.0",
22+
"@playwright/test": "^1.60.0",
2123
"codemirror": "^6.0.2",
2224
"esbuild": "^0.28.0"
2325
}

web-app/playwright.config.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
const { defineConfig, devices } = require('@playwright/test');
2+
3+
module.exports = defineConfig({
4+
testDir: './tests-e2e',
5+
timeout: 45000,
6+
expect: {
7+
timeout: 5000
8+
},
9+
fullyParallel: true,
10+
forbidOnly: !!process.env.CI,
11+
retries: process.env.CI ? 2 : 0,
12+
workers: process.env.CI ? 1 : undefined,
13+
reporter: 'html',
14+
use: {
15+
baseURL: 'http://127.0.0.1:8000',
16+
trace: 'on-first-retry',
17+
screenshot: 'only-on-failure',
18+
},
19+
projects: [
20+
{
21+
name: 'chromium',
22+
use: { ...devices['Desktop Chrome'] },
23+
},
24+
{
25+
name: 'firefox',
26+
use: { ...devices['Desktop Firefox'] },
27+
},
28+
{
29+
name: 'webkit',
30+
use: { ...devices['Desktop Safari'] },
31+
},
32+
],
33+
webServer: {
34+
command: 'python3 -m http.server 8000',
35+
url: 'http://127.0.0.1:8000',
36+
reuseExistingServer: !process.env.CI,
37+
cwd: __dirname,
38+
timeout: 10000,
39+
},
40+
});

web-app/tests-e2e/modal.spec.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
const { test, expect } = require('@playwright/test');
2+
3+
test.describe('Modal Lifecycle & Focus Trapping', () => {
4+
test('should open modal, trap keyboard focus, close modal, and restore focus to trigger button', async ({ page }) => {
5+
await page.goto('/');
6+
7+
// Scroll to projects to show cards
8+
const exploreBtn = page.locator('#exploreBtn');
9+
await exploreBtn.click();
10+
11+
// Wait for sidebar to be active
12+
await expect(page.locator('body')).toHaveClass(/sidebar-active/);
13+
14+
// Get the first project card's "Try It" button
15+
const firstCardPlayBtn = page.locator('.project-card .btn-play').first();
16+
await expect(firstCardPlayBtn).toBeVisible();
17+
18+
// Focus and click the play button
19+
await firstCardPlayBtn.focus();
20+
await firstCardPlayBtn.click();
21+
22+
const modal = page.locator('#projectModal');
23+
// Verify modal has active class
24+
await expect(modal).toHaveClass(/active/);
25+
26+
// Selector for focusable elements
27+
const focusableSelector = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
28+
29+
// Get focusable element tags/ids/classes inside modal to compare
30+
const focusablesInModal = await modal.evaluate((modalEl, sel) => {
31+
return Array.from(modalEl.querySelectorAll(sel))
32+
.filter(el => !el.closest('[aria-hidden="true"]') && !el.classList.contains("visually-hidden"))
33+
.map(el => el.tagName + (el.id ? '#' + el.id : '') + (el.className ? '.' + el.className.split(' ').join('.') : ''));
34+
}, focusableSelector);
35+
36+
expect(focusablesInModal.length).toBeGreaterThan(0);
37+
38+
// 1. Manually focus the first focusable element inside the modal
39+
await modal.evaluate((modalEl, sel) => {
40+
const focusables = Array.from(modalEl.querySelectorAll(sel))
41+
.filter(el => !el.closest('[aria-hidden="true"]') && !el.classList.contains("visually-hidden"));
42+
focusables[0].focus();
43+
}, focusableSelector);
44+
45+
// 2. Press Shift+Tab. Focus should wrap around to the last focusable element in the modal.
46+
await page.keyboard.press('Shift+Tab');
47+
48+
let activeTag = await page.evaluate(() => {
49+
const el = document.activeElement;
50+
return el.tagName + (el.id ? '#' + el.id : '') + (el.className ? '.' + el.className.split(' ').join('.') : '');
51+
});
52+
const lastTagName = focusablesInModal[focusablesInModal.length - 1];
53+
expect(activeTag).toBe(lastTagName);
54+
55+
// 3. Press Tab. Focus should wrap back to the first focusable element in the modal.
56+
await page.keyboard.press('Tab');
57+
58+
activeTag = await page.evaluate(() => {
59+
const el = document.activeElement;
60+
return el.tagName + (el.id ? '#' + el.id : '') + (el.className ? '.' + el.className.split(' ').join('.') : '');
61+
});
62+
const firstTagName = focusablesInModal[0];
63+
expect(activeTag).toBe(firstTagName);
64+
65+
// 4. Close the modal by pressing Escape
66+
await page.keyboard.press('Escape');
67+
68+
// Verify modal is closed (no longer has active class)
69+
await expect(modal).not.toHaveClass(/active/);
70+
71+
// Verify focus is restored to the original play button
72+
const isPlayBtnFocused = await firstCardPlayBtn.evaluate(el => el === document.activeElement);
73+
expect(isPlayBtnFocused).toBe(true);
74+
});
75+
});

0 commit comments

Comments
 (0)