From 0bccdf251bece3cb2d4cc043acae384099fad73c Mon Sep 17 00:00:00 2001 From: Dennis Oleksyuk Date: Sat, 23 May 2026 09:15:47 -0500 Subject: [PATCH 1/2] Add Playwright e2e test suite with page object model. Introduce TypeScript browser tests, POM page classes, test data, and PLAYWRIGHT.md authoring guide for the calendar app. Co-authored-by: Cursor --- .gitignore | 5 + e2e/PLAYWRIGHT.md | 481 +++++++++++++++++++++++++++ e2e/package-lock.json | 76 +++++ e2e/package.json | 12 + e2e/playwright.config.ts | 31 ++ e2e/tests/data/test-users.ts | 5 + e2e/tests/home.spec.ts | 10 + e2e/tests/login.spec.ts | 17 + e2e/tests/pages/events-index.page.ts | 21 ++ e2e/tests/pages/login.page.ts | 38 +++ e2e/tsconfig.json | 11 + 11 files changed, 707 insertions(+) create mode 100644 e2e/PLAYWRIGHT.md create mode 100644 e2e/package-lock.json create mode 100644 e2e/package.json create mode 100644 e2e/playwright.config.ts create mode 100644 e2e/tests/data/test-users.ts create mode 100644 e2e/tests/home.spec.ts create mode 100644 e2e/tests/login.spec.ts create mode 100644 e2e/tests/pages/events-index.page.ts create mode 100644 e2e/tests/pages/login.page.ts create mode 100644 e2e/tsconfig.json diff --git a/.gitignore b/.gitignore index a46f753..799f3c8 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,8 @@ config/Migrations/schema-dump-default.lock /html .vscode/settings.json +/e2e/node_modules +/e2e/playwright-report +/e2e/test-results +/e2e/blob-report +/e2e/playwright/.cache diff --git a/e2e/PLAYWRIGHT.md b/e2e/PLAYWRIGHT.md new file mode 100644 index 0000000..eacea44 --- /dev/null +++ b/e2e/PLAYWRIGHT.md @@ -0,0 +1,481 @@ +# Writing Playwright tests for DMS Calendar + +Guide for authoring end-to-end tests in this repo. Based on [Playwright documentation](https://playwright.dev/docs/intro), the [Page Object Model pattern](https://playwright.dev/docs/pom), and common community guidance on POM + fixtures. + +--- + +## Non‑negotiable rule: Page Object Model only + +**Every UI interaction and locator lives in a page or component object.** Spec files never call `page.getByRole`, `page.getByLabel`, `page.locator`, or `page.goto` directly. + +| Layer | Location | Allowed | +| --- | --- | --- | +| **Locators & actions** | `tests/pages/*.page.ts`, `tests/components/*.component.ts` | Define locators, navigation, user actions | +| **Test data** | `tests/data/*.ts` | Usernames, passwords, seed IDs — not selectors | +| **Specs** | `tests/*.spec.ts` | Instantiate page objects, call methods, `expect` on page object locators | +| **Fixtures** | `fixtures/*.ts` | Auth lifecycle, wiring page objects into tests | + +```typescript +// ❌ Never in a spec file +await page.goto('/users/login'); +await page.getByLabel('Username').fill('user1'); + +// ✅ Spec file +const loginPage = new LoginPage(page); +await loginPage.goto(); +await loginPage.loginAsMember(testUsers.member.username, testUsers.member.password); +await expect(eventsIndex.heading).toBeVisible(); +``` + +Codegen and prototyping are fine — but **move every locator into a page object before merging**. There are no one-off or “just this once” exceptions. + +--- + +## Quick start + +### Prerequisites + +1. Start the full app stack (from repo root): + + ```bash + ./setup.sh # first time on Linux/macOS + docker compose up + ``` + +2. App should be available at **http://localhost:8000**. + +3. Install and run tests: + + ```bash + cd e2e + npm ci + npx playwright install chromium # first time only, when not using Docker + npm test + ``` + +### Useful commands + +| Command | Purpose | +| --- | --- | +| `npm test` | Run all tests headless | +| `npm run test:headed` | Run with visible browser | +| `npm run test:ui` | Interactive UI mode | +| `npx playwright test login.spec.ts` | Run one file | +| `npx playwright test -g "log in"` | Run tests matching title | +| `npx playwright test --debug` | Step through with Inspector | +| `npx playwright codegen http://localhost:8000` | Discover locators → copy into page objects | +| `npx playwright show-report` | Open last HTML report | + +### Environment + +| Variable | Default | Used for | +| --- | --- | --- | +| `BASE_URL` | `http://localhost:8000` | App root (`playwright.config.ts`) | +| `CI` | unset locally | Retries, GitHub reporter, stricter behavior | + +### Test accounts + +Defined in `tests/data/test-users.ts` (sourced from `dms-ad-openldap/03-users.ldif`): + +| Key | Username | Password | Notes | +| --- | --- | --- | --- | +| `testUsers.member` | `user1` | `password` | Regular member | +| `testUsers.admin` | `user2` | `password` | Admin-capable | + +--- + +## Project layout + +``` +e2e/ +├── PLAYWRIGHT.md +├── playwright.config.ts +├── package.json +├── tests/ +│ ├── *.spec.ts # orchestration + assertions only +│ ├── data/ +│ │ └── test-users.ts # credentials, IDs — no locators +│ ├── pages/ +│ │ ├── login.page.ts +│ │ └── events-index.page.ts +│ └── components/ # shared UI (navbar, modals) +│ └── header.component.ts +└── fixtures/ # auth lifecycle + page object wiring + └── test.ts +``` + +| Pattern | Location | Purpose | +| --- | --- | --- | +| **Page object** | `tests/pages/` | One class per logical page (`LoginPage`, `EventsIndexPage`) | +| **Component object** | `tests/components/` | UI shared across pages (navbar, dialogs) | +| **Test data** | `tests/data/` | Static values reused across specs | +| **Fixture** | `fixtures/` | Setup/teardown (auth state, shared sessions) | +| **Spec** | `tests/*.spec.ts` | User journey; **no locators** | + +--- + +## Page Object Model rules + +| Concept | Rule | +| --- | --- | +| **Locators** | Private getter properties; public getters only when specs need them for `expect` | +| **Actions** | Public methods for user intent: `loginAsMember()`, `openCalendarView()` | +| **Navigation** | `goto()` on the page object; never `page.goto()` in specs | +| **Cross-page flows** | Methods return the **destination page object** (`loginAsMember()` → `EventsIndexPage`) | +| **Assertions** | In **specs**, against locators exposed by page objects (`expect(loginPage.heading)`) | +| **State** | Page objects are stateless — no cached DOM text or step tracking | +| **Constructor** | `Page` for pages, `Locator` for components — no credentials in constructor | +| **Naming** | `LoginPage` / `login.page.ts`; `HeaderComponent` / `header.component.ts` | + +### Locator priority (inside page objects only) + +This app uses CakePHP + Bootstrap without `data-testid` today. Define locators in page objects using: + +1. `getByRole` — buttons, headings, links, navigation +2. `getByLabel` — form fields (`Username`, `Password`) +3. `getByText` / `getByPlaceholder` — when role/label are insufficient +4. `getByTestId` — after adding `data-testid` in PHP templates +5. CSS / XPath — last resort + +Use chaining and filter inside page objects: + +```typescript +// tests/pages/events-index.page.ts +private eventRow(title: string): Locator { + return this.page.getByRole('row').filter({ hasText: title }); +} +``` + +### Locator properties + +Define locators as **getter properties**, not public `readonly` fields. Use `private get` for locators consumed only by actions; use `get` (public) when specs assert against them. + +```typescript +export class LoginPage { + constructor(private readonly page: Page) {} + + /** Public — specs call expect(loginPage.heading).toBeVisible() */ + get heading(): Locator { + return this.page.getByRole('heading', { name: 'DMS Member Log In' }); + } + + /** Private — only used inside submitCredentials() */ + private get usernameInput(): Locator { + return this.page.getByLabel('Username'); + } + + async submitCredentials(username: string, password: string) { + await this.usernameInput.fill(username); + // ... + } +} +``` + +Add a new page object file **before** writing a spec that touches that page. + +--- + +## Component objects + +Use when the same UI region appears on multiple pages (e.g. `src/Template/Element/Header/default.ctp`). + +```typescript +// tests/components/header.component.ts +import type { Locator } from '@playwright/test'; + +export class HeaderComponent { + constructor(private readonly root: Locator) {} + + get logInLink(): Locator { + return this.root.getByRole('link', { name: 'Log In' }); + } + + private get submitEventLink(): Locator { + return this.root.getByRole('link', { name: 'Submit Event' }); + } + + async goToSubmitEvent() { + await this.submitEventLink.click(); + } +} +``` + +Compose into page objects: + +```typescript +// inside EventsIndexPage constructor +this.header = new HeaderComponent(page.getByRole('navigation')); +``` + +Specs call `eventsIndex.header.goToSubmitEvent()` — never reach into the DOM themselves. + +--- + +## Spec examples (required style) + +### Public page + +```typescript +// tests/home.spec.ts +import { test, expect } from '@playwright/test'; +import { EventsIndexPage } from './pages/events-index.page'; + +test('homepage displays upcoming events', async ({ page }) => { + const eventsIndex = new EventsIndexPage(page); + + await eventsIndex.goto(); + await expect(eventsIndex.heading).toBeVisible(); +}); +``` + +### Authenticated flow + +```typescript +// tests/login.spec.ts +import { test, expect } from '@playwright/test'; +import { testUsers } from './data/test-users'; +import { EventsIndexPage } from './pages/events-index.page'; +import { LoginPage } from './pages/login.page'; + +test('member can log in with LDAP credentials', async ({ page }) => { + const loginPage = new LoginPage(page); + const eventsIndex = new EventsIndexPage(page); + + await loginPage.goto(); + await expect(loginPage.heading).toBeVisible(); + + await loginPage.loginAsMember( + testUsers.member.username, + testUsers.member.password, + ); + + await expect(eventsIndex.heading).toBeVisible(); +}); +``` + +--- + +## Adding a new test (checklist) + +1. **Define the user goal** — e.g. “Member opens calendar view from home.” +2. **Identify pages touched** — list each screen in the flow. +3. **Create or extend page objects first** — add locators and actions for every new interaction. +4. **Add test data** if needed (`tests/data/`) — never hardcode credentials in specs. +5. **Write the spec** — page object methods + `expect` on page object locators only. +6. **Run locally** — `npm test`, then `--headed` or `--debug` on failure. +7. **Push** — CI runs via `.github/workflows/playwright.yml`. + +### Spec template + +```typescript +import { test, expect } from '@playwright/test'; +import { SomePage } from './pages/some.page'; + +test.describe('Feature area', () => { + test('role can do thing', async ({ page }) => { + const somePage = new SomePage(page); + + await somePage.goto(); + await somePage.doSomething(); + + await expect(somePage.resultLocator).toBeVisible(); + }); +}); +``` + +### Naming + +- **Page files:** `events-calendar.page.ts` → `EventsCalendarPage` +- **Spec files:** `events-calendar.spec.ts` +- **Tests:** `'guest can browse upcoming events'`, `'admin can open honoraria pending list'` + +--- + +## Authentication + +Always go through `LoginPage` (or a fixture that uses it). Never duplicate login locators in specs or fixtures. + +### Per-test login + +```typescript +test.beforeEach(async ({ page }) => { + const loginPage = new LoginPage(page); + const eventsIndex = new EventsIndexPage(page); + + await loginPage.goto(); + await loginPage.loginAsMember( + testUsers.member.username, + testUsers.member.password, + ); + await expect(eventsIndex.heading).toBeVisible(); +}); +``` + +### Setup project + `storageState` (many tests) + +[Playwright auth docs](https://playwright.dev/docs/auth) — still uses page objects in the setup file: + +```typescript +// tests/auth.setup.ts +import { test as setup, expect } from '@playwright/test'; +import path from 'path'; +import { testUsers } from './data/test-users'; +import { EventsIndexPage } from './pages/events-index.page'; +import { LoginPage } from './pages/login.page'; + +const memberAuth = path.join(__dirname, '../playwright/.auth/member.json'); + +setup('authenticate as member', async ({ page }) => { + const loginPage = new LoginPage(page); + const eventsIndex = new EventsIndexPage(page); + + await loginPage.goto(); + await loginPage.loginAsMember( + testUsers.member.username, + testUsers.member.password, + ); + await expect(eventsIndex.heading).toBeVisible(); + await page.context().storageState({ path: memberAuth }); +}); +``` + +**Do not commit** `playwright/.auth/*.json`. + +### Custom fixture + +Fixtures wire page objects; they still must not contain locators: + +```typescript +// fixtures/test.ts +import { test as base, expect } from '@playwright/test'; +import { testUsers } from '../tests/data/test-users'; +import { EventsIndexPage } from '../tests/pages/events-index.page'; +import { LoginPage } from '../tests/pages/login.page'; + +export const test = base.extend<{ memberSession: void }>({ + memberSession: async ({ page }, use) => { + const loginPage = new LoginPage(page); + const eventsIndex = new EventsIndexPage(page); + + await loginPage.goto(); + await loginPage.loginAsMember( + testUsers.member.username, + testUsers.member.password, + ); + await expect(eventsIndex.heading).toBeVisible(); + await use(); + }, +}); + +export { expect } from '@playwright/test'; +``` + +--- + +## Core principles + +### Test user-visible behavior + +Page objects should target headings, labels, and link text — not `.btn-info` or `#username`. + +### Web-first assertions (in specs) + +```typescript +await expect(eventsIndex.heading).toBeVisible(); // ✅ +expect(await eventsIndex.heading.isVisible()).toBe(true); // ❌ +``` + +Never use `page.waitForTimeout()`. Playwright auto-waits on actions and `expect`. + +### Test isolation + +Each test gets a fresh browser context. Do not depend on another test’s server-side mutations unless you control seed data. + +### No third-party assertions + +Do not test `talk.dallasmakerspace.org` or other external URLs. Stub with [`page.route()`](https://playwright.dev/docs/network) inside a page object if needed. + +--- + +## Public vs authenticated routes + +| Area | Path (examples) | Auth | +| --- | --- | --- | +| Events list | `/` | Public | +| Calendar / RSS / embed | `/events/calendar`, `/events/feed/rss` | Public | +| Event view | `/events/view/:id` | Public | +| Login | `/users/login` | Public | +| Submit event | `/events/add` | Member (`testUsers.member`) | +| Admin / honoraria | `/events/honoraria/*` | Admin (`testUsers.admin`) | + +--- + +## Debugging + +| Tool | When | +| --- | --- | +| `npx playwright test --debug` | Step through locally | +| `npx playwright show-report` | HTML report after local run | +| Trace viewer | `trace: 'on-first-retry'` in config | +| **Playwright Tests** check (CI) | JUnit XML → GitHub Checks + job Summary via `dorny/test-reporter` | +| **playwright-junit** artifact | Raw `junit.xml` from each CI run | +| **docker-compose-logs** artifact | CI failure only | + +On CI, open the workflow run → **Summary** tab, or the **Playwright Tests** check on the commit/PR, for pass/fail details per test. Locally, CI uses JUnit XML instead of the HTML reporter. + +Use codegen to **find** locators, then paste them into the appropriate page object file. + +--- + +## CI + +- Workflow: `.github/workflows/playwright.yml` +- Stack: `docker compose up`; tests in `mcr.microsoft.com/playwright:v1.60.0-jammy` +- Keep `PLAYWRIGHT_VERSION` in sync with `@playwright/test` in `package.json` +- Requires `dms-ad-openldap` submodule for LDAP login +- CI publishes JUnit XML (`test-results/junit.xml`) as the **Playwright Tests** GitHub check + +--- + +## Anti-patterns + +| Do not | Do instead | +| --- | --- | +| `page.getByRole(...)` in a spec | Add locator to page object | +| `readonly foo: Locator` public fields | Private/public getter properties | +| `page.goto(...)` in a spec | `somePage.goto()` | +| Inline / one-off tests without POM | Create page object first | +| CSS / `#id` in page objects | `getByRole`, `getByLabel` | +| Credentials in spec files | `tests/data/test-users.ts` | +| `waitForTimeout()` | `expect(locator).toBeVisible()` | +| Giant page object for whole app | Split by page + components | +| Committing `playwright/.auth/*.json` | `.gitignore` | +| `test.only` in commits | Remove before push | + +--- + +## Adding `data-testid` (when locators are ambiguous) + +Add in CakePHP templates, define **once** in the page object: + +```php +Form->button(__('Login'), ['data-testid' => 'login-submit']); ?> +``` + +```typescript +// login.page.ts constructor +private get loginButton(): Locator { + return this.page.getByTestId('login-submit'); +} +``` + +--- + +## Further reading + +- [Page object models](https://playwright.dev/docs/pom) +- [Locators](https://playwright.dev/docs/locators) +- [Best practices](https://playwright.dev/docs/best-practices) +- [Fixtures](https://playwright.dev/docs/test-fixtures) +- [Authentication](https://playwright.dev/docs/auth) +- [Codegen](https://playwright.dev/docs/codegen) diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..db2462a --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,76 @@ +{ + "name": "dms-calendar-e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dms-calendar-e2e", + "devDependencies": { + "@playwright/test": "1.60.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..289cd53 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,12 @@ +{ + "name": "dms-calendar-e2e", + "private": true, + "scripts": { + "test": "playwright test", + "test:ui": "playwright test --ui", + "test:headed": "playwright test --headed" + }, + "devDependencies": { + "@playwright/test": "1.60.0" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..3d0f9cd --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,31 @@ +import { defineConfig, devices } from '@playwright/test'; + +const baseURL = process.env.BASE_URL ?? 'http://localhost:8000'; + +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI + ? [ + ['junit', { outputFile: 'test-results/junit.xml' }], + ['list'], + ] + : [ + ['list'], + ['html', { open: 'never' }], + ], + use: { + baseURL, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/e2e/tests/data/test-users.ts b/e2e/tests/data/test-users.ts new file mode 100644 index 0000000..4f05645 --- /dev/null +++ b/e2e/tests/data/test-users.ts @@ -0,0 +1,5 @@ +/** OpenLDAP seed users from dms-ad-openldap/03-users.ldif */ +export const testUsers = { + member: { username: 'user1', password: 'password' }, + admin: { username: 'user2', password: 'password' }, +} as const; diff --git a/e2e/tests/home.spec.ts b/e2e/tests/home.spec.ts new file mode 100644 index 0000000..2cc7a8c --- /dev/null +++ b/e2e/tests/home.spec.ts @@ -0,0 +1,10 @@ +import { test, expect } from '@playwright/test'; + +import { EventsIndexPage } from './pages/events-index.page'; + +test('homepage displays upcoming events', async ({ page }) => { + const eventsIndex = new EventsIndexPage(page); + + await eventsIndex.goto(); + await expect(eventsIndex.heading).toBeVisible(); +}); diff --git a/e2e/tests/login.spec.ts b/e2e/tests/login.spec.ts new file mode 100644 index 0000000..34b466d --- /dev/null +++ b/e2e/tests/login.spec.ts @@ -0,0 +1,17 @@ +import { test, expect } from '@playwright/test'; + +import { testUsers } from './data/test-users'; +import { EventsIndexPage } from './pages/events-index.page'; +import { LoginPage } from './pages/login.page'; + +test('member can log in with LDAP credentials', async ({ page }) => { + const loginPage = new LoginPage(page); + const eventsIndex = new EventsIndexPage(page); + + await loginPage.goto(); + await expect(loginPage.heading).toBeVisible(); + + await loginPage.loginAsMember(testUsers.member.username, testUsers.member.password); + + await expect(eventsIndex.heading).toBeVisible(); +}); diff --git a/e2e/tests/pages/events-index.page.ts b/e2e/tests/pages/events-index.page.ts new file mode 100644 index 0000000..7fdcc57 --- /dev/null +++ b/e2e/tests/pages/events-index.page.ts @@ -0,0 +1,21 @@ +import type { Locator, Page } from '@playwright/test'; + +export class EventsIndexPage { + constructor(private readonly page: Page) {} + + get heading(): Locator { + return this.page.getByRole('heading', { name: 'Upcoming Classes and Events' }); + } + + private get calendarViewLink(): Locator { + return this.page.getByRole('link', { name: 'Calendar View' }); + } + + async goto() { + await this.page.goto('/'); + } + + async openCalendarView() { + await this.calendarViewLink.click(); + } +} diff --git a/e2e/tests/pages/login.page.ts b/e2e/tests/pages/login.page.ts new file mode 100644 index 0000000..c43fd89 --- /dev/null +++ b/e2e/tests/pages/login.page.ts @@ -0,0 +1,38 @@ +import type { Locator, Page } from '@playwright/test'; + +import { EventsIndexPage } from './events-index.page'; + +export class LoginPage { + constructor(private readonly page: Page) {} + + get heading(): Locator { + return this.page.getByRole('heading', { name: 'DMS Member Log In' }); + } + + private get usernameInput(): Locator { + return this.page.getByLabel('Username'); + } + + private get passwordInput(): Locator { + return this.page.getByLabel('Password'); + } + + private get loginButton(): Locator { + return this.page.getByRole('button', { name: 'Login' }); + } + + async goto() { + await this.page.goto('/users/login'); + } + + async submitCredentials(username: string, password: string) { + await this.usernameInput.fill(username); + await this.passwordInput.fill(password); + await this.loginButton.click(); + } + + async loginAsMember(username: string, password: string): Promise { + await this.submitCredentials(username, password); + return new EventsIndexPage(this.page); + } +} diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000..0d49d73 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "commonjs", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["**/*.ts"] +} From 300a4f75adde775c1a5d56f803e8a9c837f18da9 Mon Sep 17 00:00:00 2001 From: Dennis Oleksyuk Date: Sat, 23 May 2026 09:15:47 -0500 Subject: [PATCH 2/2] Add GitHub Actions workflow for Playwright e2e tests. Boot the Docker stack in CI, run tests in the Playwright container, and publish JUnit results to GitHub Checks. Co-authored-by: Cursor --- .github/workflows/playwright.yml | 91 ++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 .github/workflows/playwright.yml diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000..bdf13fa --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,91 @@ +name: Playwright + +on: + push: + pull_request: + +permissions: + contents: read + checks: write + pull-requests: write + +env: + PLAYWRIGHT_VERSION: '1.60.0' + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + submodules: recursive + + - name: Prepare local directories + run: | + chmod +x .docker/*.sh setup.sh + ./setup.sh + + - name: Start application stack + run: docker compose up -d --build + + - name: Wait for application + run: | + echo "Waiting for http://localhost:8000 ..." + for i in $(seq 1 60); do + if curl -sf http://localhost:8000/ > /dev/null; then + echo "Application is ready." + exit 0 + fi + echo "Attempt ${i}/60: application not ready yet." + sleep 10 + done + echo "Application failed to become ready in time." + docker compose ps + docker compose logs app --tail 200 + exit 1 + + - name: Run Playwright tests + run: | + docker run --rm \ + --network host \ + --ipc=host \ + -v "${{ github.workspace }}/e2e:/e2e" \ + -w /e2e \ + -e BASE_URL=http://localhost:8000 \ + -e CI=true \ + mcr.microsoft.com/playwright:v${{ env.PLAYWRIGHT_VERSION }}-jammy \ + sh -c "npm ci && npm test" + + - name: Publish Playwright test results + uses: dorny/test-reporter@v3 + if: always() && !cancelled() + with: + name: Playwright Tests + path: e2e/test-results/junit.xml + reporter: java-junit + fail-on-error: true + use-actions-summary: true + + - name: Upload JUnit results + if: always() && !cancelled() + uses: actions/upload-artifact@v6 + with: + name: playwright-junit + path: e2e/test-results/junit.xml + retention-days: 14 + if-no-files-found: ignore + + - name: Upload application logs + if: failure() + run: docker compose logs --no-color > docker-compose.log + + - name: Store application logs + if: failure() + uses: actions/upload-artifact@v6 + with: + name: docker-compose-logs + path: docker-compose.log + retention-days: 7