diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 40c1060b..89d0bfdc 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -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' diff --git a/.woodpecker/.web.yml b/.woodpecker/.web.yml index 4e4bbcf0..4a2aae2a 100644 --- a/.woodpecker/.web.yml +++ b/.woodpecker/.web.yml @@ -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 diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 321cd827..e2bf12a3 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -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 diff --git a/apps/web/.prettierignore b/apps/web/.prettierignore index db495082..f2677cae 100644 --- a/apps/web/.prettierignore +++ b/apps/web/.prettierignore @@ -6,5 +6,6 @@ yarn.lock **/*.json sample docker-compose.yml +test-results data archive diff --git a/apps/web/e2e/editor.e2e.ts b/apps/web/e2e/editor.e2e.ts new file mode 100644 index 00000000..b66706ca --- /dev/null +++ b/apps/web/e2e/editor.e2e.ts @@ -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) +}) diff --git a/apps/web/e2e/search.e2e.ts b/apps/web/e2e/search.e2e.ts new file mode 100644 index 00000000..c5599823 --- /dev/null +++ b/apps/web/e2e/search.e2e.ts @@ -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() +}) diff --git a/apps/web/e2e/server.ts b/apps/web/e2e/server.ts new file mode 100644 index 00000000..183f0d4c --- /dev/null +++ b/apps/web/e2e/server.ts @@ -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 }) diff --git a/apps/web/e2e/support/fixture.ts b/apps/web/e2e/support/fixture.ts new file mode 100644 index 00000000..1da8c22e --- /dev/null +++ b/apps/web/e2e/support/fixture.ts @@ -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() + } +} diff --git a/apps/web/e2e/support/test.ts b/apps/web/e2e/support/test.ts new file mode 100644 index 00000000..0f1f9d29 --- /dev/null +++ b/apps/web/e2e/support/test.ts @@ -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((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 } + ] +}) diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js index e976525c..77cc7948 100644 --- a/apps/web/eslint.config.js +++ b/apps/web/eslint.config.js @@ -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/', diff --git a/apps/web/package.json b/apps/web/package.json index cb339015..eb6fe51c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts new file mode 100644 index 00000000..cfd94273 --- /dev/null +++ b/apps/web/playwright.config.ts @@ -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'] } }] +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index ef193d5c..b33b9285 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -9,6 +9,8 @@ "src", "scripts/**/*.test.ts", "test/templates/**/*.ts", + "e2e/**/*.ts", + "playwright.config.ts", "vite.config.ts", "vitest*.config.ts" ], diff --git a/docs/web/development.md b/docs/web/development.md index e334392a..3af2c381 100644 --- a/docs/web/development.md +++ b/docs/web/development.md @@ -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. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d050109..4b774dc3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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):