chore: kanban template
This commit is contained in:
122
e2e/README.md
Normal file
122
e2e/README.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# E2E Test Harness
|
||||
|
||||
Cucumber + Playwright end-to-end tests for the project. The intent is that
|
||||
**every locked workflow has a `.feature` file before it gets a line of
|
||||
implementation code** (see "Phase 7.5 — Acceptance scenarios" in the
|
||||
discovery playbook).
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
e2e/
|
||||
package.json — cucumber, @playwright/test, tsx
|
||||
cucumber.mjs — runner config
|
||||
tests/
|
||||
features/*.feature — Gherkin scenarios (one feature per locked workflow)
|
||||
steps/*.steps.ts — step definitions; common.steps.ts is generic
|
||||
support/
|
||||
world.ts — AppWorld (browser, page, login, gotoApp, expectText)
|
||||
hooks.ts — spawns servers (optional), DB reset hook, browser lifecycle
|
||||
types.ts — config (URLs, timeouts) read from env
|
||||
reports/ — generated HTML reports
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
The harness assumes the app is reachable. Either:
|
||||
|
||||
**A. Start servers yourself** (recommended during dev):
|
||||
|
||||
```sh
|
||||
yarn dev # in repo root — starts frontend + backend
|
||||
yarn workspace @project/e2e test
|
||||
```
|
||||
|
||||
**B. Let the harness manage the servers**:
|
||||
|
||||
```sh
|
||||
E2E_MANAGE_SERVERS=1 \
|
||||
E2E_BACKEND_CMD="yarn dev:backend" \
|
||||
E2E_FRONTEND_CMD="yarn dev" \
|
||||
yarn workspace @project/e2e test
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
All env vars optional; defaults match the kanban-board-template's ports.
|
||||
|
||||
| Var | Default | Purpose |
|
||||
|----------------------|----------------------------|-----------------------------------------------|
|
||||
| `APP_URL` | `http://localhost:7104` | Vite frontend URL |
|
||||
| `API_URL` | `http://localhost:4003` | Pikku backend URL |
|
||||
| `E2E_TIMEOUT` | `30000` | Per-step Playwright timeout (ms) |
|
||||
| `E2E_MANAGE_SERVERS` | unset | Set to `1` to have hooks spawn dev servers |
|
||||
| `E2E_BACKEND_CMD` | `yarn dev:backend` | Used when `E2E_MANAGE_SERVERS=1` |
|
||||
| `E2E_FRONTEND_CMD` | `yarn dev` | Used when `E2E_MANAGE_SERVERS=1` |
|
||||
| `E2E_RESET_URL` | unset | POST URL that resets DB to seed between tests |
|
||||
| `HEADED` | unset | Set to `1` to run with a visible browser |
|
||||
|
||||
## DB reset between scenarios
|
||||
|
||||
Scenarios pollute each other. To get a deterministic DB state per scenario,
|
||||
implement a `__test_reset` Pikku function gated behind a test-only flag, and
|
||||
point `E2E_RESET_URL` at it. The harness will POST to it in the `Before`
|
||||
hook. If the URL is unset or unreachable the harness logs a warning and
|
||||
continues.
|
||||
|
||||
Example shape:
|
||||
|
||||
```ts
|
||||
export const testReset = pikkuSessionlessFunc({
|
||||
expose: true,
|
||||
description: 'Reset DB to seed state. Test-only — gated by NODE_ENV.',
|
||||
func: async ({ kysely, config }) => {
|
||||
if (config.env !== 'test') throw new Error('forbidden')
|
||||
await kysely.deleteFrom('booking').execute()
|
||||
// ... drop + reseed all mutable tables
|
||||
return { ok: true as const }
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Writing a feature
|
||||
|
||||
```gherkin
|
||||
Feature: <one locked workflow>
|
||||
|
||||
Scenario: <one happy path>
|
||||
Given I am logged in as "demo@yoga-retreat.example" with password "demo1234"
|
||||
When I visit "/bookings"
|
||||
Then I see "Summer Yoga Retreat"
|
||||
```
|
||||
|
||||
Generic steps live in `tests/steps/common.steps.ts`:
|
||||
|
||||
- `Given I visit "<path>"`
|
||||
- `Given I am logged in as "<email>" with password "<password>"`
|
||||
- `When I log in as "..." with password "..."`
|
||||
- `When I log out`
|
||||
- `When I fill "<selector>" with "<value>"`
|
||||
- `When I click "<button label>"`
|
||||
- `Then I see "<text>"`
|
||||
- `Then I do not see "<text>"`
|
||||
- `Then the URL contains "<fragment>"`
|
||||
|
||||
Project-specific steps go in their own `*.steps.ts` files (e.g.
|
||||
`bookings.steps.ts`). Keep `common.steps.ts` framework-agnostic so the next
|
||||
project that copies this harness inherits a clean baseline.
|
||||
|
||||
## Tags
|
||||
|
||||
Run a subset by tag:
|
||||
|
||||
```sh
|
||||
yarn workspace @project/e2e test:tag '@auth'
|
||||
yarn workspace @project/e2e test:tag 'not @slow'
|
||||
```
|
||||
|
||||
Mark scenarios as `@skip` to exclude from the default run.
|
||||
|
||||
## Reports
|
||||
|
||||
After a run, open `tests/reports/cucumber-report.html`.
|
||||
20
e2e/package.json
Normal file
20
e2e/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@project/e2e",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "End-to-end test harness — Cucumber + Playwright.",
|
||||
"scripts": {
|
||||
"test": "cucumber-js --config tests/cucumber.mjs --tags 'not @skip'",
|
||||
"test:headed": "HEADED=1 cucumber-js --config tests/cucumber.mjs --tags 'not @skip'",
|
||||
"test:tag": "cucumber-js --config tests/cucumber.mjs --tags",
|
||||
"tsc": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cucumber/cucumber": "^11.0.0",
|
||||
"@playwright/test": "^1.50.0",
|
||||
"@types/node": "^22",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.8.0"
|
||||
}
|
||||
}
|
||||
7
e2e/tests/cucumber.mjs
Normal file
7
e2e/tests/cucumber.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
requireModule: ['tsx'],
|
||||
require: ['tests/support/**/*.ts', 'tests/steps/**/*.ts'],
|
||||
paths: ['tests/features/**/*.feature'],
|
||||
format: ['progress', 'html:tests/reports/cucumber-report.html'],
|
||||
forceExit: true,
|
||||
}
|
||||
0
e2e/tests/features/.gitkeep
Normal file
0
e2e/tests/features/.gitkeep
Normal file
23
e2e/tests/features/content.feature
Normal file
23
e2e/tests/features/content.feature
Normal file
@@ -0,0 +1,23 @@
|
||||
@content
|
||||
Feature: Card file attachments
|
||||
Files attached to cards are uploaded via a presigned PUT URL and accessed via
|
||||
a time-limited signed download URL. Access without a valid signature must be
|
||||
rejected by the storage backend.
|
||||
|
||||
Scenario: Upload and download a card attachment via signed URLs
|
||||
Given a card exists
|
||||
When I request an upload URL for filename "hello.txt" and content type "text/plain"
|
||||
And I PUT "Hello, Pikku!" to the upload URL
|
||||
Then the upload response status is 200 or 201
|
||||
When I request a signed download URL for filename "hello.txt"
|
||||
Then the signed URL response contains a signedUrl
|
||||
And downloading the signed URL returns "Hello, Pikku!"
|
||||
|
||||
Scenario: Unsigned access to an uploaded file is rejected
|
||||
Given a card exists
|
||||
When I request an upload URL for filename "secret.txt" and content type "text/plain"
|
||||
And I PUT "Top secret" to the upload URL
|
||||
Then the upload response status is 200 or 201
|
||||
When I request a signed download URL for filename "secret.txt"
|
||||
And I strip the signature from the signed URL
|
||||
Then fetching the unsigned URL returns a 4xx status
|
||||
7
e2e/tests/features/smoke.feature
Normal file
7
e2e/tests/features/smoke.feature
Normal file
@@ -0,0 +1,7 @@
|
||||
@smoke
|
||||
Feature: App smoke
|
||||
The harness can reach the running app.
|
||||
|
||||
Scenario: home page loads
|
||||
When I visit "/"
|
||||
Then the URL contains "/"
|
||||
58
e2e/tests/steps/common.steps.ts
Normal file
58
e2e/tests/steps/common.steps.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Given, When, Then } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
import type { AppWorld } from '../support/world.js'
|
||||
|
||||
/**
|
||||
* Generic step library — shared across every project that copies this harness.
|
||||
*
|
||||
* Project-specific business steps (e.g. "I create a booking for <date>")
|
||||
* belong in their own *.steps.ts files. Keep this file framework-agnostic.
|
||||
*/
|
||||
|
||||
Given('I visit {string}', async function (this: AppWorld, path: string) {
|
||||
await this.gotoApp(path)
|
||||
})
|
||||
|
||||
Given(
|
||||
'I am logged in as {string} with password {string}',
|
||||
async function (this: AppWorld, email: string, password: string) {
|
||||
await this.login(email, password)
|
||||
},
|
||||
)
|
||||
|
||||
When('I log in as {string} with password {string}', async function (
|
||||
this: AppWorld,
|
||||
email: string,
|
||||
password: string,
|
||||
) {
|
||||
await this.login(email, password)
|
||||
})
|
||||
|
||||
When('I log out', async function (this: AppWorld) {
|
||||
await this.logout()
|
||||
})
|
||||
|
||||
When(
|
||||
'I fill {string} with {string}',
|
||||
async function (this: AppWorld, selector: string, value: string) {
|
||||
await this.page.locator(selector).fill(value)
|
||||
},
|
||||
)
|
||||
|
||||
When('I click {string}', async function (this: AppWorld, label: string) {
|
||||
await this.page.getByRole('button', { name: label }).first().click()
|
||||
})
|
||||
|
||||
Then('I see {string}', async function (this: AppWorld, text: string) {
|
||||
await this.expectText(text)
|
||||
})
|
||||
|
||||
Then('I do not see {string}', async function (this: AppWorld, text: string) {
|
||||
await expect(
|
||||
this.page.getByText(text, { exact: false }).first(),
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
Then('the URL contains {string}', async function (this: AppWorld, fragment: string) {
|
||||
await this.page.waitForURL((url) => url.toString().includes(fragment))
|
||||
})
|
||||
106
e2e/tests/steps/content.steps.ts
Normal file
106
e2e/tests/steps/content.steps.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Given, When, Then } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
import type { AppWorld } from '../support/world.js'
|
||||
import { config } from '../support/types.js'
|
||||
|
||||
interface ContentWorld extends AppWorld {
|
||||
cardId: string
|
||||
uploadUrl: string
|
||||
signedUrl: string
|
||||
uploadStatus: number
|
||||
}
|
||||
|
||||
Given('a card exists', async function (this: ContentWorld) {
|
||||
const res = await fetch(`${config.apiUrl}/cards`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ title: 'Attachment test card' }),
|
||||
})
|
||||
expect(res.ok, `POST /cards failed: ${res.status}`).toBe(true)
|
||||
const body = (await res.json()) as { cardId: string }
|
||||
this.cardId = body.cardId
|
||||
})
|
||||
|
||||
When(
|
||||
'I request an upload URL for filename {string} and content type {string}',
|
||||
async function (this: ContentWorld, fileName: string, contentType: string) {
|
||||
const res = await fetch(
|
||||
`${config.apiUrl}/cards/${this.cardId}/attachments/upload-url`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ cardId: this.cardId, fileName, contentType }),
|
||||
},
|
||||
)
|
||||
expect(res.ok, `upload-url request failed: ${res.status} ${await res.text()}`).toBe(true)
|
||||
const body = (await res.json()) as { uploadUrl: string }
|
||||
this.uploadUrl = body.uploadUrl
|
||||
},
|
||||
)
|
||||
|
||||
When(
|
||||
'I PUT {string} to the upload URL',
|
||||
async function (this: ContentWorld, content: string) {
|
||||
const res = await fetch(this.uploadUrl, {
|
||||
method: 'PUT',
|
||||
body: content,
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
})
|
||||
this.uploadStatus = res.status
|
||||
},
|
||||
)
|
||||
|
||||
Then(
|
||||
'the upload response status is 200 or 201',
|
||||
function (this: ContentWorld) {
|
||||
expect(
|
||||
this.uploadStatus === 200 || this.uploadStatus === 201,
|
||||
`expected 200 or 201 from upload PUT, got ${this.uploadStatus}`,
|
||||
).toBe(true)
|
||||
},
|
||||
)
|
||||
|
||||
When(
|
||||
'I request a signed download URL for filename {string}',
|
||||
async function (this: ContentWorld, fileName: string) {
|
||||
const res = await fetch(
|
||||
`${config.apiUrl}/cards/${this.cardId}/attachments/${encodeURIComponent(fileName)}/signed-url`,
|
||||
)
|
||||
expect(res.ok, `signed-url request failed: ${res.status} ${await res.text()}`).toBe(true)
|
||||
const body = (await res.json()) as { signedUrl: string }
|
||||
this.signedUrl = body.signedUrl
|
||||
},
|
||||
)
|
||||
|
||||
Then(
|
||||
'the signed URL response contains a signedUrl',
|
||||
function (this: ContentWorld) {
|
||||
expect(typeof this.signedUrl).toBe('string')
|
||||
expect(this.signedUrl.length).toBeGreaterThan(0)
|
||||
},
|
||||
)
|
||||
|
||||
Then(
|
||||
'downloading the signed URL returns {string}',
|
||||
async function (this: ContentWorld, expectedContent: string) {
|
||||
const res = await fetch(this.signedUrl)
|
||||
expect(res.ok, `signed download failed: ${res.status}`).toBe(true)
|
||||
const text = await res.text()
|
||||
expect(text).toBe(expectedContent)
|
||||
},
|
||||
)
|
||||
|
||||
When('I strip the signature from the signed URL', function (this: ContentWorld) {
|
||||
const url = new URL(this.signedUrl)
|
||||
url.searchParams.delete('sig')
|
||||
this.signedUrl = url.toString()
|
||||
})
|
||||
|
||||
Then(
|
||||
'fetching the unsigned URL returns a 4xx status',
|
||||
async function (this: ContentWorld) {
|
||||
const res = await fetch(this.signedUrl)
|
||||
expect(res.status, `expected 4xx for unsigned access, got ${res.status}`).toBeGreaterThanOrEqual(400)
|
||||
expect(res.status).toBeLessThan(500)
|
||||
},
|
||||
)
|
||||
126
e2e/tests/support/hooks.ts
Normal file
126
e2e/tests/support/hooks.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
Before,
|
||||
After,
|
||||
BeforeAll,
|
||||
AfterAll,
|
||||
setDefaultTimeout,
|
||||
} from '@cucumber/cucumber'
|
||||
import { spawn, type ChildProcess } from 'child_process'
|
||||
import { resolve, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import type { AppWorld } from './world.js'
|
||||
import { config } from './types.js'
|
||||
|
||||
setDefaultTimeout(config.responseTimeout)
|
||||
|
||||
let backendProcess: ChildProcess | undefined
|
||||
let frontendProcess: ChildProcess | undefined
|
||||
|
||||
async function waitForUrl(url: string, label: string, timeoutMs = 60_000) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
if (res.status < 500) return
|
||||
} catch {
|
||||
// not ready yet
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
}
|
||||
throw new Error(`[e2e] ${label} did not become ready at ${url} within ${timeoutMs}ms`)
|
||||
}
|
||||
|
||||
BeforeAll(async function () {
|
||||
if (!config.manageServers) {
|
||||
// Caller is expected to start backend + frontend (e.g. via pm2, docker,
|
||||
// or a separate `yarn dev` terminal) before running tests.
|
||||
return
|
||||
}
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const repoRoot = resolve(__dirname, '../../..')
|
||||
|
||||
// Project-specific: override these commands by setting E2E_BACKEND_CMD /
|
||||
// E2E_FRONTEND_CMD env vars. The defaults assume `yarn dev` at the repo
|
||||
// root spawns the frontend, and the project provides a `dev:backend`
|
||||
// script for the API.
|
||||
const backendCmd = process.env.E2E_BACKEND_CMD ?? 'yarn dev:backend'
|
||||
const frontendCmd = process.env.E2E_FRONTEND_CMD ?? 'yarn dev'
|
||||
|
||||
backendProcess = spawn(backendCmd, {
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env, NODE_ENV: 'test' },
|
||||
stdio: 'pipe',
|
||||
shell: true,
|
||||
detached: true,
|
||||
})
|
||||
backendProcess.stderr?.on('data', (d: Buffer) =>
|
||||
process.stderr.write(`[backend] ${d}`),
|
||||
)
|
||||
backendProcess.stdout?.on('data', (d: Buffer) =>
|
||||
process.stdout.write(`[backend] ${d}`),
|
||||
)
|
||||
|
||||
frontendProcess = spawn(frontendCmd, {
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env, NODE_ENV: 'test' },
|
||||
stdio: 'pipe',
|
||||
shell: true,
|
||||
detached: true,
|
||||
})
|
||||
frontendProcess.stderr?.on('data', (d: Buffer) =>
|
||||
process.stderr.write(`[frontend] ${d}`),
|
||||
)
|
||||
frontendProcess.stdout?.on('data', (d: Buffer) =>
|
||||
process.stdout.write(`[frontend] ${d}`),
|
||||
)
|
||||
|
||||
await Promise.all([
|
||||
waitForUrl(config.apiUrl, 'backend'),
|
||||
waitForUrl(config.appUrl, 'frontend'),
|
||||
])
|
||||
})
|
||||
|
||||
AfterAll(async function () {
|
||||
for (const p of [backendProcess, frontendProcess]) {
|
||||
if (!p?.pid) continue
|
||||
try {
|
||||
// Detached + pgid kill takes the whole tree (vite/wrangler spawn children).
|
||||
process.kill(-p.pid, 'SIGTERM')
|
||||
} catch {
|
||||
try {
|
||||
p.kill('SIGTERM')
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Before(async function (this: AppWorld) {
|
||||
await this.openBrowser()
|
||||
if (config.resetUrl) {
|
||||
const body = config.resetRpcName
|
||||
? JSON.stringify({ rpcName: config.resetRpcName, data: {} })
|
||||
: undefined
|
||||
await fetch(config.resetUrl, {
|
||||
method: 'POST',
|
||||
headers: body ? { 'content-type': 'application/json' } : undefined,
|
||||
body,
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
process.stderr.write(
|
||||
`[e2e] WARN: reset hook ${config.resetUrl} returned ${res.status}\n`,
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
process.stderr.write(`[e2e] WARN: reset hook ${config.resetUrl} unreachable\n`)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
After(async function (this: AppWorld) {
|
||||
await this.closeBrowser()
|
||||
})
|
||||
28
e2e/tests/support/types.ts
Normal file
28
e2e/tests/support/types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// Project-level e2e configuration. Override via env vars per environment.
|
||||
//
|
||||
// Defaults match the kanban-board-template's default ports:
|
||||
// apps/kanban → tanstack-start (ssr) on 7104
|
||||
// backend → wrangler/cfw worker on 4003 (or whatever the project wires)
|
||||
|
||||
export const config = {
|
||||
/** URL of the running frontend (vite dev server or built preview). */
|
||||
appUrl: process.env.APP_URL ?? 'http://localhost:7104',
|
||||
/** URL of the running backend (Pikku API / CFW worker). */
|
||||
apiUrl: process.env.API_URL ?? 'http://localhost:4003',
|
||||
/** Per-step Playwright timeout (ms). */
|
||||
responseTimeout: Number(process.env.E2E_TIMEOUT ?? 30_000),
|
||||
/** Whether the harness should spawn the dev servers itself. */
|
||||
manageServers: process.env.E2E_MANAGE_SERVERS === '1',
|
||||
/**
|
||||
* Reset hook URL — POST here to reset the DB to seed state between scenarios.
|
||||
* Project must implement this as a sessionless RPC gated behind a test-only
|
||||
* flag (e.g. NODE_ENV !== 'production'). Leave undefined to skip reset.
|
||||
*/
|
||||
resetUrl: process.env.E2E_RESET_URL,
|
||||
/**
|
||||
* RPC name to invoke at resetUrl. When set, the reset hook posts
|
||||
* `{rpcName, data: {}}` as the body — matching pikku's public /rpc/:name
|
||||
* envelope. Leave unset for non-pikku reset endpoints.
|
||||
*/
|
||||
resetRpcName: process.env.E2E_RESET_RPC_NAME,
|
||||
} as const
|
||||
84
e2e/tests/support/world.ts
Normal file
84
e2e/tests/support/world.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { World, setWorldConstructor } from '@cucumber/cucumber'
|
||||
import {
|
||||
chromium,
|
||||
type Browser,
|
||||
type BrowserContext,
|
||||
type Page,
|
||||
} from '@playwright/test'
|
||||
import { config } from './types.js'
|
||||
|
||||
/**
|
||||
* AppWorld — generic Playwright world for portal-style apps.
|
||||
*
|
||||
* Exposes the primitives every feature needs:
|
||||
* - openBrowser / closeBrowser — lifecycle
|
||||
* - gotoApp(path) — navigate within the frontend
|
||||
* - login(email, password) — fill + submit /login, wait for redirect
|
||||
* - logout() — visit /logout
|
||||
* - expectText(text) — assert visible text
|
||||
*
|
||||
* Project-specific helpers (e.g. createBooking, assignRoom) belong in
|
||||
* step files, not here. Keep this generic.
|
||||
*/
|
||||
export class AppWorld extends World {
|
||||
browser!: Browser
|
||||
context!: BrowserContext
|
||||
page!: Page
|
||||
|
||||
async openBrowser() {
|
||||
const headed = process.env.HEADED === '1' || process.env.HEADED === 'true'
|
||||
this.browser = await chromium.launch({
|
||||
headless: !headed,
|
||||
slowMo: headed ? 150 : 0,
|
||||
})
|
||||
this.context = await this.browser.newContext(
|
||||
process.env.E2E_LOCALE ? { locale: process.env.E2E_LOCALE } : undefined,
|
||||
)
|
||||
this.page = await this.context.newPage()
|
||||
this.page.setDefaultTimeout(config.responseTimeout)
|
||||
}
|
||||
|
||||
async closeBrowser() {
|
||||
await this.context?.close()
|
||||
await this.browser?.close()
|
||||
}
|
||||
|
||||
async gotoApp(path: string) {
|
||||
const url = path.startsWith('http')
|
||||
? path
|
||||
: `${config.appUrl}${path.startsWith('/') ? path : `/${path}`}`
|
||||
await this.page.goto(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills the login form on /login and waits for navigation away from it.
|
||||
* Assumes the login form has inputs named/typed `email` and a password
|
||||
* input. Override per-project if the form differs.
|
||||
*/
|
||||
async login(email: string, password: string) {
|
||||
await this.gotoApp('/login')
|
||||
await this.page.locator('input[type="email"]').fill(email)
|
||||
await this.page.locator('input[type="password"]').fill(password)
|
||||
await this.page.getByRole('button', { name: /(sign in|login|anmelden)/i }).click()
|
||||
await this.page.waitForURL((url) => !url.pathname.startsWith('/login'), {
|
||||
timeout: config.responseTimeout,
|
||||
})
|
||||
}
|
||||
|
||||
async logout() {
|
||||
await this.gotoApp('/logout')
|
||||
}
|
||||
|
||||
async expectText(text: string) {
|
||||
await this.page.getByText(text, { exact: false }).first().waitFor({
|
||||
state: 'visible',
|
||||
timeout: config.responseTimeout,
|
||||
})
|
||||
}
|
||||
|
||||
async getPageText(): Promise<string> {
|
||||
return this.page.innerText('body')
|
||||
}
|
||||
}
|
||||
|
||||
setWorldConstructor(AppWorld)
|
||||
14
e2e/tsconfig.json
Normal file
14
e2e/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"types": ["node"],
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["tests/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user