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
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,11 @@ jobs:
- name: Build
working-directory: frontend
run: npm run build

- name: Install Playwright browser
working-directory: frontend
run: npx playwright install --with-deps chromium

- name: Browser tests
working-directory: frontend
run: npm run test:e2e
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ work/
node_modules/
frontend/dist/
frontend/coverage/
frontend/playwright-report/
frontend/test-results/
*.tsbuildinfo
Thumbs.db
.env
Expand Down
86 changes: 86 additions & 0 deletions frontend/e2e/navigation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { expect, test } from "@playwright/test";

test.describe("course navigation", () => {
test("keeps the full desktop sidebar reachable at laptop height", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 720 });
await page.goto("/#/map");

const sidebar = page.locator("aside.sidebar");
const lastNavigationItem = sidebar.getByRole("button", { name: /Problem-solving Lab/ });

await expect(sidebar).toBeVisible();
await expect(sidebar).not.toHaveAttribute("aria-hidden");
await expect(sidebar).not.toHaveAttribute("inert");
const sidebarMetrics = await sidebar.evaluate((element) => ({
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight,
}));
expect(sidebarMetrics.scrollHeight).toBeGreaterThan(sidebarMetrics.clientHeight);

await sidebar.evaluate((element) => {
element.scrollTop = element.scrollHeight;
});
await expect(lastNavigationItem).toBeInViewport();

await lastNavigationItem.click();
await expect(page).toHaveURL(/#\/problem-solving-lab$/);
});
});

test.describe("mobile course drawer", () => {
test.use({ viewport: { width: 390, height: 844 } });

test.beforeEach(async ({ page }) => {
await page.goto("/#/map");
});

test("keeps the closed drawer out of the focus order and restores focus after Escape", async ({ page }) => {
const menuButton = page.locator(`button[aria-controls="mobile-course-navigation"]`);
const sidebar = page.locator("#mobile-course-navigation");
const firstSidebarButton = sidebar.locator("button").first();

await expect(menuButton).toHaveAttribute("aria-expanded", "false");
await expect(sidebar).toHaveAttribute("aria-hidden", "true");
await expect(sidebar).toHaveAttribute("inert", "");

await menuButton.focus();
await page.keyboard.press("Tab");
await expect(firstSidebarButton).not.toBeFocused();

await menuButton.click();
await expect(menuButton).toHaveAttribute("aria-label", "關閉課程選單");
await expect(menuButton).toHaveAttribute("aria-expanded", "true");
await expect(sidebar).toHaveAttribute("aria-hidden", "false");
await expect(sidebar).not.toHaveAttribute("inert");

const lastSidebarButton = sidebar.locator("button").last();
await expect(firstSidebarButton).toBeFocused();

await page.keyboard.press("Shift+Tab");
await expect(lastSidebarButton).toBeFocused();
await page.keyboard.press("Tab");
await expect(firstSidebarButton).toBeFocused();

await page.keyboard.press("Escape");
await expect(menuButton).toHaveAttribute("aria-expanded", "false");
await expect(sidebar).toHaveAttribute("aria-hidden", "true");
await expect(sidebar).toHaveAttribute("inert", "");
await expect(menuButton).toBeFocused();
});

test("closes and returns focus when selecting a route", async ({ page }) => {
const menuButton = page.locator(`button[aria-controls="mobile-course-navigation"]`);
const sidebar = page.locator("#mobile-course-navigation");
const scrim = page.getByRole("button", { name: "關閉選單", exact: true });

await menuButton.click();
await scrim.click();
await expect(menuButton).toBeFocused();

await menuButton.click();
await sidebar.getByRole("button", { name: /Git Lab/ }).click();

await expect(page).toHaveURL(/#\/lab$/);
await expect(menuButton).toBeFocused();
});
});
64 changes: 64 additions & 0 deletions frontend/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 frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@
"lint": "tsc --noEmit",
"review": "node ../.github/scripts/ollama-review.mjs",
"preview": "vite preview",
"test": "vitest run"
"test": "vitest run",
"test:e2e": "playwright test"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@playwright/test": "^1.62.1",
"@types/node": "^24.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
Expand Down
20 changes: 20 additions & 0 deletions frontend/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { defineConfig } from "@playwright/test";

export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0,
reporter: [["list"], ["html", { outputFolder: "playwright-report", open: "never" }]],
use: {
baseURL: "http://127.0.0.1:4173",
screenshot: "only-on-failure",
trace: "retain-on-failure",
},
webServer: {
command: "npm run dev -- --host 127.0.0.1 --port 4173",
url: "http://127.0.0.1:4173",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});
Loading
Loading