[web] add browser tests for editing and search #194

Merged
eleith merged 1 commits from test/editor-search-e2e into main 2026-11-05 02:49:04 +00:00
15 changed files with 370 additions and 22 deletions

No files matched your search

+36 -20
View File
@@ -5,33 +5,36 @@ on:
branches:
- main
tags:
- '@luzzle/web@*'
- "@luzzle/web@*"
paths:
- 'apps/web/**'
- 'packages/web/config/**'
- 'packages/web/theme/**'
- 'packages/web/db/**'
- 'packages/web/jobs/**'
- 'packages/web/pieces/**'
- 'packages/web/templates/**'
- 'apps/core/**'
- "apps/web/**"
- "packages/web/config/**"
- "packages/web/theme/**"
- "packages/web/db/**"
- "packages/web/jobs/**"
- "packages/web/pieces/**"
- "packages/web/templates/**"
- "apps/core/**"
pull_request:
branches:
- main
paths:
- 'apps/web/**'
- 'packages/web/config/**'
- 'packages/web/theme/**'
- 'packages/web/db/**'
- 'packages/web/jobs/**'
- 'packages/web/pieces/**'
- 'packages/web/templates/**'
- 'apps/core/**'
- '.github/workflows/web.yml'
- "apps/web/**"
- "packages/web/config/**"
- "packages/web/theme/**"
- "packages/web/db/**"
- "packages/web/jobs/**"
- "packages/web/pieces/**"
- "packages/web/templates/**"
- "apps/core/**"
- ".github/workflows/web.yml"
jobs:
check:
runs-on: ubuntu-latest
# Keep the image's browser revision aligned with @playwright/test.
container:
image: ghcr.io/eleith/containers-playwright:1.61.1-node26@sha256:31ca7ae8b89997e60b60dec15487ea25ac9ea7b1eea45ea967f0f873c89af1ab
defaults:
run:
working-directory: apps/web
@@ -45,8 +48,8 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: '26'
cache: 'pnpm'
node-version: "26"
cache: "pnpm"
- name: Install dependencies
working-directory: .
@@ -75,6 +78,19 @@ jobs:
- name: Built-in template HTTP/SSR integration
run: pnpm run test:templates
- name: Browser tests
# The HTTP integration suite already built the real app from this checkout.
run: pnpm run test:e2e
- name: Browser failure artifacts
if: failure()
uses: actions/upload-artifact@v6
with:
name: web-browser-failures
path: apps/web/test-results
if-no-files-found: ignore
retention-days: 7
build-push:
needs: check
if: github.event_name != 'pull_request'
+3 -1
View File
@@ -60,7 +60,8 @@ steps:
- pnpm run check
test:
image: node:26
# Same browser version as @playwright/test, using the Gitea mirror.
image: git.eleith.com/eleith.private/playwright:1.61.1-node26
environment:
COREPACK_HOME: /workspace/src/.corepack
commands:
@@ -68,6 +69,7 @@ steps:
- corepack enable
- pnpm --filter @luzzle/web test
- pnpm --filter @luzzle/web test:templates
- pnpm --filter @luzzle/web test:e2e
check-version:
image: node:26
+1
View File
@@ -7,6 +7,7 @@ node_modules
.vercel
.svelte-kit/
/build
/test-results/
/package-lock.json
/src/lib/ui/styles/theme.build.css
/src/lib/pieces/components/custom
+1
View File
@@ -6,5 +6,6 @@ yarn.lock
**/*.json
sample
docker-compose.yml
test-results
data
archive
+22
View File
@@ -0,0 +1,22 @@
import { expect, test } from './support/test.js'
import { ADMIN, notebook } from './support/fixture.js'
test('sign in, save an edit and reload the saved source', async ({ page, baseURL }) => {
await page.goto(notebook.editorUrl)
await page.locator('#svelte-announcer').waitFor({ state: 'attached' })
await page.getByLabel('Username', { exact: true }).fill(ADMIN.username)
await page.getByLabel('Password', { exact: true }).fill(ADMIN.password)
await page.getByRole('button', { name: 'Sign in', exact: true }).click()
await expect(page).toHaveURL(`${baseURL}${notebook.editorUrl}`)
const editor = page.getByRole('textbox')
const save = page.getByRole('button', { name: 'Save', exact: true })
const observation = 'A saved observation from the browser.'
await expect(editor).toContainText('Original observation.')
await editor.press('ControlOrMeta+End')
await page.keyboard.insertText(`\n\n${observation}`)
await save.click()
await expect(save).toBeDisabled()
await page.reload()
await expect(editor).toContainText(observation)
})
+23
View File
@@ -0,0 +1,23 @@
import { expect, test } from './support/test.js'
import { notebook } from './support/fixture.js'
test('search for a piece and open its public page', async ({ page, baseURL }) => {
await page.goto('/')
const search = page
.getByRole('navigation', { name: 'Global' })
.getByLabel('search', { exact: true })
await expect(search).toHaveAttribute('aria-haspopup', 'dialog')
await search.click()
const dialog = page.getByRole('dialog', { name: 'Search', exact: true })
await dialog.getByRole('searchbox', { name: 'Search query' }).fill(notebook.title)
await dialog.getByRole('button', { name: 'Search', exact: true }).click()
await expect(page).toHaveURL(
`${baseURL}/search?${new URLSearchParams({ query: notebook.title })}`
)
const results = page.getByRole('region', { name: 'Search results', exact: true })
await expect(results.getByRole('link', { name: 'Forest Notebook' })).toHaveCount(0)
await results.getByRole('link', { name: notebook.title }).click()
await expect(page).toHaveURL(`${baseURL}${notebook.publicUrl}`)
await expect(page.getByRole('heading', { name: notebook.title, exact: true })).toBeVisible()
})
+28
View File
@@ -0,0 +1,28 @@
import { createServer } from 'node:http'
import { once } from 'node:events'
import type { AddressInfo } from 'node:net'
import { createFixture } from './support/fixture.ts'
const server = createServer()
function shutdown() {
server.close(() => process.exit(0))
server.closeIdleConnections()
setTimeout(() => process.exit(1), 5000).unref()
}
process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)
process.on('disconnect', shutdown)
// Bind before writing config so the browser, auth and Kit share the actual origin.
server.listen(0, '127.0.0.1')
await once(server, 'listening')
const origin = `http://localhost:${(server.address() as AddressInfo).port}`
await createFixture(process.cwd(), origin)
process.env.ORIGIN = origin
process.env.PROTOCOL_HEADER = 'x-forwarded-proto'
const { handler } = await import(new URL('../build/handler.js', import.meta.url).href)
server.on('request', (request, response) => {
request.headers['x-forwarded-proto'] = 'http'
handler(request, response)
})
process.send!({ origin })
+95
View File
@@ -0,0 +1,95 @@
import { mkdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { asCoreDatabase, migrate, Pieces, StorageFileSystem } from '@luzzle/core'
import { createAppDb, runWebMigrations } from '@luzzle/web.db'
export const ADMIN = { username: 'admin', password: 'e2e-only-admin-password' }
export const notebook = {
file: 'reading/meteor notes.notes.md',
editorUrl: '/admin/piece/reading/meteor%20notes.notes.md/source',
publicUrl: '/pieces/notes/meteor-notebook',
type: 'notes',
slug: 'meteor-notebook',
title: 'Meteor Notebook'
}
const pieces = [
notebook,
{
file: 'forest.notes.md',
type: 'notes',
slug: 'forest-notebook',
title: 'Forest Notebook'
}
]
export async function createFixture(directory: string, origin: string) {
const dateConsumed = '2024-01-01'
const consumedAt = Date.parse(`${dateConsumed}T00:00:00Z`)
const archive = path.join(directory, 'archive')
await mkdir(path.join(archive, '.luzzle/schemas'), { recursive: true })
await mkdir(path.join(archive, 'reading'), { recursive: true })
await mkdir(path.join(directory, 'data'), { recursive: true })
await writeFile(
path.join(archive, '.luzzle/schemas/notes.json'),
JSON.stringify({
title: 'notes',
type: 'object',
properties: {
title: { type: 'string' },
date: { type: 'string', format: 'date' }
},
required: ['title', 'date'],
additionalProperties: false
})
)
for (const piece of pieces) {
await writeFile(
path.join(archive, piece.file),
`---\ntitle: ${piece.title}\ndate: ${dateConsumed}\n---\n\nOriginal observation.\n`
)
}
const database = path.join(directory, 'data/luzzle.sqlite')
await writeFile(
path.join(directory, 'config.yaml'),
JSON.stringify({
url: { app: origin },
storage: { root: archive },
paths: { database },
content: { text: { title: 'E2E Garden' } },
auth: { secret: 'e2e-only-session-secret-at-least-32-characters', credentials: ADMIN },
pieces: [{ type: 'notes', fields: { title: 'title', date_consumed: 'date' } }]
})
)
const db = createAppDb(database)
try {
const core = await migrate(asCoreDatabase(db))
if (core.error) throw core.error
const web = await runWebMigrations(db)
if (web.error) throw web.error
const schemas = new Pieces(new StorageFileSystem(archive))
for await (const result of await schemas.sync(asCoreDatabase(db))) {
if (result.error) throw new Error(result.message)
}
// Seed the published read model directly; no Publish job is being simulated.
await db
.insertInto('web_pieces')
.values(
pieces.map((piece) => ({
id: piece.slug,
key: piece.slug,
slug: piece.slug,
type: piece.type,
file_path: piece.file,
title: piece.title,
note: 'Original observation.',
json_metadata: JSON.stringify({ title: piece.title, date: dateConsumed }),
date_added: consumedAt,
date_consumed: consumedAt
}))
)
.execute()
} finally {
await db.destroy()
}
}
+77
View File
@@ -0,0 +1,77 @@
import { fork } from 'node:child_process'
import { once } from 'node:events'
import { access, mkdir, mkdtemp, rm } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { test as base } from '@playwright/test'
export { expect } from '@playwright/test'
type AppServer = { origin: string; output: () => string }
export const test = base.extend<{ serverLogs: void }, { appServer: AppServer }>({
appServer: [
// Playwright requires destructuring even without fixture dependencies.
// eslint-disable-next-line no-empty-pattern
async ({}, use) => {
await access(new URL('../../build/handler.js', import.meta.url))
const cache = fileURLToPath(new URL('../../node_modules/.cache/', import.meta.url))
await mkdir(cache, { recursive: true })
const directory = await mkdtemp(`${cache}e2e-`)
const child = fork(new URL('../server.ts', import.meta.url), [], {
cwd: directory,
env: { PATH: process.env.PATH, NODE_ENV: 'production', TZ: 'UTC' },
execArgv: [],
stdio: ['ignore', 'pipe', 'pipe', 'ipc']
})
let output = ''
child.stdout!.on('data', (chunk) => (output = (output + chunk).slice(-50000)))
child.stderr!.on('data', (chunk) => (output = (output + chunk).slice(-50000)))
try {
const origin = await new Promise<string>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('E2E server startup timed out')), 30000)
child.once('message', (message: { origin: string }) => {
clearTimeout(timeout)
resolve(message.origin)
})
child.once('error', (error) => {
clearTimeout(timeout)
reject(error)
})
child.once('exit', (code, signal) => {
clearTimeout(timeout)
reject(new Error(`E2E server exited (${code ?? signal})`))
})
})
await use({ origin, output: () => output })
} catch (error) {
console.error(output)
throw error
} finally {
if (child.exitCode === null && child.signalCode === null) {
const exited = once(child, 'exit')
child.kill('SIGTERM')
const kill = setTimeout(() => child.kill('SIGKILL'), 6000)
try {
await exited
} finally {
clearTimeout(kill)
}
}
await rm(directory, { recursive: true, force: true })
}
},
{ scope: 'worker', timeout: 60000 }
],
baseURL: async ({ appServer }, use) => {
await use(appServer.origin)
},
serverLogs: [
async ({ appServer }, use, testInfo) => {
await use()
if (testInfo.status !== testInfo.expectedStatus) {
await testInfo.attach('server.log', { body: appServer.output(), contentType: 'text/plain' })
}
},
{ auto: true }
]
})
+1
View File
@@ -48,6 +48,7 @@ export default [
ignores: [
'build/',
'.svelte-kit/',
'test-results/',
'docker-compose.yml',
'src/lib/pieces/components/custom/',
'src/lib/content/components/custom/',
+3 -1
View File
@@ -17,7 +17,8 @@
"lint": "prettier --check . && prettier --config .prettierrc --check '../../packages/web/templates/src/**/*.svelte' && eslint .",
"format": "prettier --write .",
"test": "vitest run --silent",
"test:templates": "vitest run --config vitest.templates.config.ts --silent"
"test:templates": "vitest run --config vitest.templates.config.ts --silent",
"test:e2e": "playwright test"
},
"engines": {
"node": ">=24"
@@ -45,6 +46,7 @@
"@luzzle/core": "workspace:*",
"@luzzle/web.config": "workspace:*",
"@luzzle/web.jobs": "workspace:*",
"@playwright/test": "1.61.1",
"@sveltejs/adapter-node": "6.0.1",
"@sveltejs/enhanced-img": "1.0.0",
"@sveltejs/kit": "3.1.1",
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
testMatch: '**/*.e2e.ts',
workers: 1,
retries: 0,
forbidOnly: !!process.env.CI,
outputDir: './test-results',
use: {
timezoneId: 'UTC',
trace: 'retain-on-failure',
screenshot: 'only-on-failure'
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }]
})
+2
View File
@@ -9,6 +9,8 @@
"src",
"scripts/**/*.test.ts",
"test/templates/**/*.ts",
"e2e/**/*.ts",
"playwright.config.ts",
"vite.config.ts",
"vitest*.config.ts"
],
+24
View File
@@ -42,3 +42,27 @@ It is intentionally different from the [published-image example](docker-compose.
Follow the [development checks](../development.md). Keep parsing in `@luzzle/core`,
heavy work in the worker, UI styling on theme tokens, and web/worker/templates on
one exact Svelte version. Shared web packages stay internal with `workspace:*`.
## Browser tests
After building shared packages, install Chromium's headless shell and build the web app:
```sh
pnpm --filter @luzzle/web exec playwright install --with-deps --only-shell chromium
pnpm --filter @luzzle/web build
pnpm --filter @luzzle/web test:e2e
```
The Playwright suite covers two journeys: sign in to a protected editor,
save an edit and reload it; and search for a piece and open its public page. It
uses one Chromium worker against the production build, with a temporary
archive/config and a real SQLite index, not your demo or live data. It runs no
worker, LSP or external identity/storage services; it does not prove Preview/Publish
execution.
GitHub and Woodpecker reuse the app build from `test:templates` in their existing
test jobs. Both use the prebuilt Playwright image used by When/Directory: GHCR on
GitHub, the Gitea mirror on Woodpecker. Keep the image/browser version aligned with
`@playwright/test`. Failed journeys retain traces, screenshots and server logs
under `apps/web/test-results`; GitHub uploads those artifacts. There is no second
build, browser matrix or automatic retry.
+38
View File
@@ -354,6 +354,9 @@ importers:
'@luzzle/web.jobs':
specifier: workspace:*
version: link:../../packages/web/jobs
'@playwright/test':
specifier: 1.61.1
version: 1.61.1
'@sveltejs/adapter-node':
specifier: 6.0.1
version: 6.0.1(@sveltejs/kit@3.1.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.57.3(@typescript-eslint/types@8.60.0))(vite@8.0.14(@types/node@24.12.4)(yaml@2.7.0)))(svelte@5.57.3(@typescript-eslint/types@8.60.0))(typescript@6.0.3)(vite@8.0.14(@types/node@24.12.4)(yaml@2.7.0)))
@@ -1726,6 +1729,11 @@ packages:
resolution: {integrity: sha512-fdDH1LSGfZdTH2sxdpVMw31BanV28K/Gry0cVFxaNP77neJSkd82mM8ErPNYs9e+0O7SdHBLTDzDgwUuy18RnQ==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
'@playwright/test@1.61.1':
resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==}
engines: {node: '>=18'}
hasBin: true
'@pnpm/deps.graph-sequencer@1100.0.1':
resolution: {integrity: sha512-pOr5+q1fLYKwFN3LAJuGZEnfXDcQ73zqgDHMtGy+K+uIoUqyY+6MeDCWFwfu+4EFuq76I5EPFofoNAI+Bmmq4A==}
engines: {node: '>=22.13'}
@@ -2835,6 +2843,11 @@ packages:
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
engines: {node: '>=12.20.0'}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -3561,6 +3574,16 @@ packages:
pkg-types@2.3.1:
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
playwright-core@1.61.1:
resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==}
engines: {node: '>=18'}
hasBin: true
playwright@1.61.1:
resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==}
engines: {node: '>=18'}
hasBin: true
pluralize@8.0.0:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
@@ -5294,6 +5317,10 @@ snapshots:
'@pkgr/core@0.1.2': {}
'@playwright/test@1.61.1':
dependencies:
playwright: 1.61.1
'@pnpm/deps.graph-sequencer@1100.0.1': {}
'@polka/url@1.0.0-next.29': {}
@@ -6320,6 +6347,9 @@ snapshots:
dependencies:
fetch-blob: 3.2.0
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
@@ -7310,6 +7340,14 @@ snapshots:
exsolve: 1.0.8
pathe: 2.0.3
playwright-core@1.61.1: {}
playwright@1.61.1:
dependencies:
playwright-core: 1.61.1
optionalDependencies:
fsevents: 2.3.2
pluralize@8.0.0: {}
postcss-load-config@3.1.4(postcss@8.5.15):