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
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,25 @@ jobs:
- run: npm ci
- run: npm run build

test:
name: E2E Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm test
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: test-results/
retention-days: 7

validate-manifest:
name: Validate Question Bank
runs-on: ubuntu-latest
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,9 @@ dist/
.cache/
.env.local

# Playwright
test-results/
playwright-report/

# Raw exports from browser/API
test-*.json
22 changes: 19 additions & 3 deletions package-lock.json

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

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"test": "playwright test",
"test:ui": "playwright test --ui"
},
"dependencies": {
"@stripe/react-stripe-js": "^6.0.0",
Expand All @@ -18,6 +20,7 @@
"react-dom": "18.3.1"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"eslint": "^8.57.1",
"eslint-config-next": "^14.2.35",
"playwright": "^1.58.2"
Expand Down
22 changes: 22 additions & 0 deletions playwright.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { defineConfig } from "@playwright/test";

export default defineConfig({
testDir: "./tests",
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: process.env.CI ? "github" : "list",
timeout: 30000,
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
screenshot: "only-on-failure",
},
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
timeout: 30000,
},
});
30 changes: 30 additions & 0 deletions tests/01-home.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { test, expect } from "@playwright/test";

test.describe("Home page", () => {
test("renders hero section with title and tagline", async ({ page }) => {
await page.goto("/");
await expect(page.locator("h1")).toContainText("EASA 2020 ECQB Bank Practice");
await expect(page.locator(".hero-tagline")).toContainText("For Pilots, By Pilots");
await expect(page.locator(".eyebrow")).toContainText("Open Source ATPL Bank");
});

test("header has logo and navigation", async ({ page }) => {
await page.goto("/");
await expect(page.locator(".topbar-logo-icon")).toBeVisible();
await expect(page.locator(".topbar-logo")).toContainText("OpenATPL");
await expect(page.locator(".topbar-link")).toContainText("Tests");
});

test("Create Test and GitHub buttons are visible", async ({ page }) => {
await page.goto("/");
await expect(page.locator(".hero-actions a", { hasText: "Create Test" })).toBeVisible();
await expect(page.locator(".hero-actions a", { hasText: "Get on GitHub" })).toBeVisible();
});

test("shows empty state when no saved tests", async ({ page }) => {
await page.goto("/");
await page.evaluate(() => localStorage.clear());
await page.reload();
await expect(page.locator(".hero-actions a", { hasText: "Create Test" })).toBeVisible();
});
});
94 changes: 94 additions & 0 deletions tests/02-create-test.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { test, expect } from "@playwright/test";

test.describe("Create Test page", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
await page.evaluate(() => localStorage.clear());
await page.goto("/create-test");
await expect(page.locator("h1")).toContainText("Create Test");
});

test("has subject dropdown with all 13 subjects", async ({ page }) => {
const select = page.locator("select").first();
await expect(select).toBeVisible();
const options = select.locator("option");
await expect(options).toHaveCount(13);
});

test("has slider and number input for question count", async ({ page }) => {
await expect(page.locator('input[type="range"]')).toBeVisible();
await expect(page.locator(".count-input")).toBeVisible();
});

test("number input syncs with slider", async ({ page }) => {
const numberInput = page.locator(".count-input");
await numberInput.fill("10");
await numberInput.press("Tab");
const slider = page.locator('input[type="range"]');
await expect(slider).toHaveValue("10");
});

test("changing subject updates slider max", async ({ page }) => {
const select = page.locator("select").first();
const slider = page.locator('input[type="range"]');

await select.selectOption({ index: 0 });
const max1 = await slider.getAttribute("max");

await select.selectOption({ index: 5 });
const max2 = await slider.getAttribute("max");

expect(Number(max1)).toBeGreaterThan(0);
expect(Number(max2)).toBeGreaterThan(0);
});

test("filters section is visible with checkboxes", async ({ page }) => {
await expect(page.locator(".field-heading", { hasText: "Filters" })).toBeVisible();
await expect(page.locator(".filter-group-label", { hasText: "Attachments" })).toBeVisible();
await expect(page.locator(".filter-group-label", { hasText: "History" })).toBeVisible();
});

test("attachment filter updates question count", async ({ page }) => {
const slider = page.locator('input[type="range"]');
const maxBefore = Number(await slider.getAttribute("max"));

// Check "With attachments"
await page.locator(".filter-option", { hasText: "With attachments" }).locator("input").check();

const maxAfter = Number(await slider.getAttribute("max"));
expect(maxAfter).toBeLessThan(maxBefore);
});

test("history filters are disabled with no history", async ({ page }) => {
// Wait for subject entries and history to load
await page.waitForTimeout(1000);

const notSeenLabel = page.locator(".filter-option", { hasText: "Not seen before" });
await expect(notSeenLabel).toBeVisible();
const notSeenCheckbox = notSeenLabel.locator('input[type="checkbox"]');
await expect(notSeenCheckbox).toBeDisabled({ timeout: 5000 });
});

test("AND/OR toggle appears with multiple filters", async ({ page }) => {
// Check "With attachments"
await page.locator(".filter-option", { hasText: "With attachments" }).locator("input").check();
// AND/OR should NOT appear yet (only one filter)
await expect(page.locator(".filter-mode-toggle")).not.toBeVisible();

// We can't easily test AND/OR with history disabled, but verify the toggle logic exists
});

test("Create Test button creates test and navigates", async ({ page }) => {
const numberInput = page.locator(".count-input");
await numberInput.fill("1");
await numberInput.press("Tab");

await page.locator('button[type="submit"]').click();
await page.waitForURL(/\/tests\/\d+\/run/, { timeout: 10000 });
});

test("Back to My Tests navigates home", async ({ page }) => {
await page.locator("a", { hasText: "Back to My Tests" }).click();
await page.waitForURL("/");
});
});
Loading
Loading