test: add Playwright e2e smoke suite for punimtag dev #60

Merged
ilia merged 1 commits from test/e2e-playwright-smoke into dev 2026-07-14 16:13:57 -05:00
15 changed files with 563 additions and 0 deletions

View File

@ -67,3 +67,58 @@ jobs:
fi
docker run --rm -v "$PWD:/repo" ghcr.io/gitleaks/gitleaks:latest \
detect --source /repo --no-banner --redact ${extra}
# Playwright smoke via @levkin/playkit — public host guards + API health.
# Sign-out auth spec runs only when E2E_ADMIN_* secrets are present.
e2e:
needs: skip-ci-check
if: needs.skip-ci-check.outputs.should-skip != '1'
runs-on: [homelab, self-hosted, linux]
defaults:
run:
working-directory: e2e
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Allow private playkit clone
if: ${{ secrets.GITEA_TOKEN != '' }}
run: |
git config --global url."https://oauth2:${{ secrets.GITEA_TOKEN }}@git.levkin.ca/".insteadOf "https://git.levkin.ca/"
- name: Install e2e deps
run: npm ci
- name: Install Chromium
run: npx playwright install --with-deps chromium
- name: Run Playwright smoke
env:
PLAYKIT_BASE_URL: ${{ secrets.PLAYKIT_BASE_URL }}
PLAYKIT_API_BASE_URL: ${{ secrets.PLAYKIT_API_BASE_URL }}
PLAYKIT_PROJECT: punimtag
PLAYKIT_ENV: dev
E2E_ADMIN_EMAIL: ${{ secrets.E2E_ADMIN_EMAIL }}
E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }}
MAILTRAP_API_TOKEN: ${{ secrets.MAILTRAP_API_TOKEN }}
MAILTRAP_ACCOUNT_ID: ${{ secrets.MAILTRAP_ACCOUNT_ID }}
MAILTRAP_INBOX_ID: ${{ secrets.MAILTRAP_INBOX_ID }}
CI: 'true'
run: |
export PLAYKIT_BASE_URL="${PLAYKIT_BASE_URL:-https://punimtagdev.levkin.ca}"
export PLAYKIT_API_BASE_URL="${PLAYKIT_API_BASE_URL:-http://10.0.10.121:8000}"
npx playwright test
- name: Upload report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: |
e2e/playwright-report/
e2e/test-results/
retention-days: 7

8
.gitignore vendored
View File

@ -85,3 +85,11 @@ data/thumbnails/
# PM2 ecosystem config (server-specific paths)
ecosystem.config.js
data/web_videos/
# Playwright e2e
e2e/node_modules/
e2e/playwright-report/
e2e/test-results/
e2e/blob-report/
e2e/.auth/
e2e/.env

17
e2e/.env.example Normal file
View File

@ -0,0 +1,17 @@
# Public UI (required for host-guard tests)
PLAYKIT_BASE_URL=https://punimtagdev.levkin.ca
PLAYKIT_PROJECT=punimtag
PLAYKIT_ENV=dev
# FastAPI on the DEV LXC (runners on LAN can reach this)
PLAYKIT_API_BASE_URL=http://10.0.10.121:8000
# Dedicated e2e user — never commit real passwords
E2E_ADMIN_EMAIL=
E2E_ADMIN_PASSWORD=
# Mailtrap Email Testing (optional — forgot-password specs skip if unset)
# MAILTRAP_API_TOKEN=
# MAILTRAP_ACCOUNT_ID=
# MAILTRAP_INBOX_ID=
# E2E_MAIL_TO= # defaults to E2E_ADMIN_EMAIL

40
e2e/README.md Normal file
View File

@ -0,0 +1,40 @@
# PunimTag e2e (Playwright + @levkin/playkit)
Smoke tests against the **public** DEV URL so we catch LAN-redirect bugs
(e.g. `NEXTAUTH_URL=http://10.0.10.121:3001` → Kolby #57).
## Quick start
```bash
cd e2e
cp .env.example .env # edit credentials
npm ci
npx playwright install chromium
npm test
```
Private Gitea installs need auth once: set a local git `insteadOf` with `GITEA_TOKEN`
before `npm ci`, then unset it. CI does this in `.gitea/workflows/ci.yml`.
Never commit lockfile URLs that embed tokens.
## Environment
| Variable | Required | Purpose |
|----------|----------|---------|
| `PLAYKIT_BASE_URL` | yes (default in config) | `https://punimtagdev.levkin.ca` |
| `PLAYKIT_API_BASE_URL` | no | Default `http://10.0.10.121:8000` (LAN API) |
| `E2E_ADMIN_EMAIL` | for sign-out spec | Dedicated e2e user (not a human password) |
| `E2E_ADMIN_PASSWORD` | for sign-out spec | Dedicated e2e password |
Without credentials, the public-host login-page + API health specs still run;
sign-out is skipped.
**Secrets:** store in Infisical `LevkinOps` → sync to Gitea Actions. See ansible
`docs/hardening/SECRETS.md`.
## What we assert
1. Login page loads on the public hostname
2. Sign-out stays on that hostname (never `10.x`)
3. FastAPI `/health``{ "status": "ok" }`
4. Forgot-password email appears in Mailtrap; reset link uses public host

55
e2e/fixtures.ts Normal file
View File

@ -0,0 +1,55 @@
import { test as base, expect } from '@playwright/test';
import {
createPlaykitRuntime,
MailtrapClient,
type PlaykitFixtures,
} from '@levkin/playkit';
import { LoginPage } from './pages/LoginPage';
import { AccountMenu } from './pages/AccountMenu';
const runtime = createPlaykitRuntime({
...process.env,
PLAYKIT_PROJECT: process.env.PLAYKIT_PROJECT || 'punimtag',
PLAYKIT_ENV: process.env.PLAYKIT_ENV || 'dev',
PLAYKIT_API_BASE_URL:
process.env.PLAYKIT_API_BASE_URL ||
process.env.API_BASE_URL ||
'http://10.0.10.121:8000',
PLAYKIT_BASE_URL:
process.env.PLAYKIT_BASE_URL ||
process.env.BASE_URL ||
'https://punimtagdev.levkin.ca',
});
type PunimtagFixtures = PlaykitFixtures & {
loginPage: LoginPage;
accountMenu: AccountMenu;
e2eCredentials: { email: string; password: string } | null;
mailtrap: MailtrapClient | null;
};
export const test = base.extend<PunimtagFixtures>({
playkitConfig: async ({}, use) => use(runtime.playkitConfig),
playkitLog: async ({}, use) => use(runtime.playkitLog),
api: async ({}, use) => use(runtime.api),
timings: async ({}, use) => use(runtime.timings),
loginPage: async ({ page, playkitConfig }, use) =>
use(new LoginPage(page, playkitConfig.baseUrl)),
accountMenu: async ({ page, playkitConfig }, use) =>
use(new AccountMenu(page, playkitConfig.baseUrl)),
e2eCredentials: async ({}, use) => {
const email = process.env.E2E_ADMIN_EMAIL || process.env.E2E_EMAIL || '';
const password = process.env.E2E_ADMIN_PASSWORD || process.env.E2E_PASSWORD || '';
await use(email && password ? { email, password } : null);
},
mailtrap: async ({ playkitLog }, use) => {
await use(
MailtrapClient.fromEnv(process.env, playkitLog.child({ component: 'mailtrap' })),
);
},
});
export { expect };

117
e2e/package-lock.json generated Normal file
View File

@ -0,0 +1,117 @@
{
"name": "punimtag-e2e",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "punimtag-e2e",
"version": "1.0.0",
"devDependencies": {
"@levkin/playkit": "git+https://git.levkin.ca/ilia/playkit.git#v0.2.0",
"@playwright/test": "^1.52.0",
"@types/node": "^22.15.0",
"typescript": "^5.8.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@levkin/playkit": {
"version": "0.2.0",
"resolved": "git+https://git.levkin.ca/ilia/playkit.git#2f3991dadc172aae86b98538eb5654cdb3237e67",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@playwright/test": ">=1.40.0"
},
"peerDependenciesMeta": {
"@playwright/test": {
"optional": true
}
}
},
"node_modules/@playwright/test": {
"version": "1.61.1",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "22.20.1",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.61.1",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"dev": true,
"license": "MIT"
}
}
}

21
e2e/package.json Normal file
View File

@ -0,0 +1,21 @@
{
"name": "punimtag-e2e",
"version": "1.0.0",
"private": true,
"description": "PunimTag Playwright e2e — powered by @levkin/playkit",
"scripts": {
"test": "playwright test",
"test:ui": "playwright test --ui",
"test:headed": "playwright test --headed",
"report": "playwright show-report"
},
"engines": {
"node": ">=20"
},
"devDependencies": {
"@levkin/playkit": "git+https://git.levkin.ca/ilia/playkit.git#v0.2.0",
"@playwright/test": "^1.52.0",
"@types/node": "^22.15.0",
"typescript": "^5.8.0"
}
}

18
e2e/pages/AccountMenu.ts Normal file
View File

@ -0,0 +1,18 @@
import { type Page } from '@playwright/test';
import { BasePage, waitForVisible } from '@levkin/playkit';
export class AccountMenu extends BasePage {
constructor(page: Page, baseUrl: string) {
super(page, baseUrl);
}
async openMenu(): Promise<void> {
await this.click(this.page.getByLabel('Account menu'));
await waitForVisible(this.page.getByRole('button', { name: 'Sign out' }));
}
async signOut(): Promise<void> {
await this.openMenu();
await this.click(this.page.getByRole('button', { name: 'Sign out' }));
}
}

18
e2e/pages/LoginPage.ts Normal file
View File

@ -0,0 +1,18 @@
import { type Page } from '@playwright/test';
import { BasePage } from '@levkin/playkit';
export class LoginPage extends BasePage {
constructor(page: Page, baseUrl: string) {
super(page, baseUrl);
}
async openLogin(): Promise<void> {
await this.open('/login');
}
async signIn(email: string, password: string): Promise<void> {
await this.fill(this.page.locator('#email'), email);
await this.fill(this.page.locator('#password'), password);
await this.click(this.page.locator('button[type="submit"]'));
}
}

27
e2e/playwright.config.ts Normal file
View File

@ -0,0 +1,27 @@
import { defineConfig, devices } from '@playwright/test';
const baseURL =
process.env.PLAYKIT_BASE_URL ||
process.env.BASE_URL ||
'https://punimtagdev.levkin.ca';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: process.env.CI
? [['list'], ['html', { open: 'never' }], ['junit', { outputFile: 'test-results/junit.xml' }]]
: [['list'], ['html', { open: 'on-failure' }]],
timeout: 60_000,
expect: { timeout: 15_000 },
use: {
baseURL,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
...devices['Desktop Chrome'],
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});

View File

@ -0,0 +1,10 @@
import { test, expect } from '../fixtures';
test.describe('api @smoke', () => {
test('backend /health returns ok', async ({ api, timings }) => {
const res = await timings.measure('api_health', () =>
api.get<{ status: string }>('/health', { expectedStatus: 200 }),
);
expect(res.data).toMatchObject({ status: 'ok' });
});
});

View File

@ -0,0 +1,58 @@
import {
assertPublicHost,
waitForUrlHost,
} from '@levkin/playkit';
import { test, expect } from '../fixtures';
test.describe('auth @smoke', () => {
test('login page loads on public host', async ({
page,
playkitConfig,
timings,
loginPage,
}) => {
assertPublicHost(playkitConfig.baseUrl);
await timings.measure('login_page', () => loginPage.openLogin());
await expect(page.getByRole('heading', { name: /Sign in/i })).toBeVisible();
await expect(page.locator('#email')).toBeVisible();
await waitForUrlHost(page, playkitConfig.expectedHost);
expect(new URL(page.url()).hostname).toBe(playkitConfig.expectedHost);
});
test('sign-out stays on public host (Kolby #57)', async ({
page,
playkitConfig,
timings,
loginPage,
accountMenu,
e2eCredentials,
}) => {
test.skip(
!e2eCredentials,
'Set E2E_ADMIN_EMAIL and E2E_ADMIN_PASSWORD (Infisical / Gitea secrets)',
);
assertPublicHost(playkitConfig.baseUrl);
await timings.measure('login', async () => {
await loginPage.openLogin();
await loginPage.signIn(e2eCredentials!.email, e2eCredentials!.password);
await page.getByLabel('Account menu').waitFor({ state: 'visible' });
});
await waitForUrlHost(page, playkitConfig.expectedHost);
await timings.measure('sign_out', () => accountMenu.signOut());
// After sign-out, NextAuth must redirect to the public host — never 10.x.
await waitForUrlHost(page, playkitConfig.expectedHost);
expect(new URL(page.url()).hostname).toBe(playkitConfig.expectedHost);
expect(page.url()).not.toMatch(/10\.\d+\.\d+\.\d+/);
await expect(page.getByRole('button', { name: /Sign in/i })).toBeVisible({
timeout: 15_000,
});
});
});

View File

@ -0,0 +1,105 @@
import {
assertPublicHost,
firstLinkMatching,
waitForUrlHost,
} from '@levkin/playkit';
import { test, expect } from '../fixtures';
/**
* Kolby #56 class: forgot-password must actually produce a message we can read,
* and the reset link must use the public host (not 10.x).
*
* Requires:
* - MAILTRAP_API_TOKEN + MAILTRAP_INBOX_ID (+ optional MAILTRAP_ACCOUNT_ID)
* - punimtag DEV SMTP sandbox.smtp.mailtrap.io (see SECRETS.md)
* - E2E_ADMIN_EMAIL (or E2E_MAIL_TO) for an existing verified user
*/
test.describe('mail @smoke', () => {
test('forgot-password email lands in Mailtrap with public reset link', async ({
request,
playkitConfig,
timings,
mailtrap,
e2eCredentials,
}) => {
test.skip(
!mailtrap,
'Set MAILTRAP_API_TOKEN and MAILTRAP_INBOX_ID (Infisical /playkit/punimtag)',
);
const to =
process.env.E2E_MAIL_TO ||
e2eCredentials?.email ||
process.env.E2E_ADMIN_EMAIL ||
'';
test.skip(!to, 'Set E2E_MAIL_TO or E2E_ADMIN_EMAIL');
assertPublicHost(playkitConfig.baseUrl);
const after = new Date(Date.now() - 5_000);
const res = await timings.measure('forgot_password_api', () =>
request.post(`${playkitConfig.baseUrl}/api/auth/forgot-password`, {
data: { email: to },
headers: { 'Content-Type': 'application/json' },
}),
);
expect(res.ok()).toBeTruthy();
const msg = await timings.measure('mailtrap_wait', () =>
mailtrap!.waitForEmail({
to,
subject: /reset|password/i,
after,
timeoutMs: 90_000,
pollMs: 2_500,
}),
);
const html = await mailtrap!.getHtml(msg.id, msg.html_path);
const link = firstLinkMatching(html, /reset-password/);
expect(link, 'reset link missing from email HTML').toBeTruthy();
const url = new URL(link!);
expect(url.hostname).toBe(playkitConfig.expectedHost);
expect(url.hostname).not.toMatch(/^10\./);
expect(url.pathname).toMatch(/reset-password/);
});
test('forgot-password UI shows success and Mailtrap gets mail', async ({
page,
playkitConfig,
timings,
mailtrap,
e2eCredentials,
}) => {
test.skip(!mailtrap, 'Set MAILTRAP_API_TOKEN and MAILTRAP_INBOX_ID');
const to =
process.env.E2E_MAIL_TO ||
e2eCredentials?.email ||
process.env.E2E_ADMIN_EMAIL ||
'';
test.skip(!to, 'Set E2E_MAIL_TO or E2E_ADMIN_EMAIL');
assertPublicHost(playkitConfig.baseUrl);
const after = new Date(Date.now() - 5_000);
await timings.measure('open_home', () => page.goto(playkitConfig.baseUrl));
await page.getByRole('button', { name: /Sign in/i }).click();
await page.getByRole('button', { name: /Forgot password/i }).click();
await page.locator('#forgot-email').fill(to);
await page.getByRole('button', { name: /Send reset link/i }).click();
await expect(page.getByRole('status')).toContainText(/sent|check your inbox/i, {
timeout: 20_000,
});
await waitForUrlHost(page, playkitConfig.expectedHost);
const msg = await mailtrap!.waitForEmail({
to,
subject: /reset|password/i,
after,
timeoutMs: 90_000,
});
expect(msg.id).toBeTruthy();
});
});

12
e2e/tsconfig.json Normal file
View File

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["./**/*.ts"]
}

View File

@ -19,7 +19,9 @@
"lint:python": "flake8 backend --max-line-length=100 --ignore=E501,W503,W293,E305,F401,F811,W291,W391,E712,W504,F841,E402,F824,E128,E226,F402,F541,E302,E117,E722 || true",
"lint:python:syntax": "find backend -name '*.py' -exec python -m py_compile {} \\;",
"test:backend": "export PYTHONPATH=$(pwd) && export SKIP_DEEPFACE_IN_TESTS=1 && ./venv/bin/python3 -m pytest tests/ -v",
"test:e2e": "npm test --prefix e2e",
"test:all": "npm run test:backend",
"install:e2e": "npm install --prefix e2e",
"ci:local": "npm run lint:all && npm run type-check:viewer && npm run lint:python && npm run test:backend && npm run build:all",
"deploy:dev": "npm run build:all && echo '✅ Build complete. Ready for deployment to dev server (10.0.10.121)'",
"deploy:dev:prepare": "npm run build:all && mkdir -p deploy/package && cp -r backend deploy/package/ && cp -r admin-frontend/dist deploy/package/admin-frontend-dist && cp -r viewer-frontend/.next deploy/package/viewer-frontend-next && cp requirements.txt deploy/package/ && cp .env.example deploy/package/ && echo '✅ Deployment package prepared in deploy/package/'"