chore: kanban template

This commit is contained in:
e2e
2026-06-21 21:15:34 +02:00
commit d0b6864c4c
209 changed files with 215578 additions and 0 deletions

7
e2e/tests/cucumber.mjs Normal file
View 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,
}

View File

View 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

View 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 "/"

View 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))
})

View 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
View 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()
})

View 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

View 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)