Files
pablan/frontend/e2e/documents.spec.ts
T
ProfessorNovaandClaude Opus 5 97dbff309c Pablan, as it stands
Self-hosted knowledge management for SMEs: a split-screen Markdown editor
whose sections an LLM refines while you write, and RAG question answering
over the documents that result. FastAPI + Postgres/pgvector on the back,
SvelteKit on the front, everything OpenAI-compatible and self-hostable.

Squashed into a single commit; the development history stays local.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CA43ZJda8Rbp2hKXNy8f6b
2026-09-04 08:36:17 +02:00

184 lines
8.2 KiB
TypeScript

import { expect, test, type Page } from '@playwright/test';
import { login as loginAs } from './helpers';
// The full knowledge loop against the REAL model: author a document in the
// writing editor (content set via the API for speed, the way the editor saves
// it), publish it in the UI, then retrieve the new knowledge via chat.
// Uses the seeded dev stack (make seed).
test.setTimeout(240_000);
async function login(page: Page) {
await loginAs(page, 'pablo@pablan.dev');
}
// Zero-residue: each test's login session goes here.
test.afterEach(async ({ page }) => {
await page.request.post('/api/auth/logout');
});
test('author → publish → ask-about-it, entirely in the product', async ({ page }) => {
await login(page);
// --- create a draft from a template (API, browser cookies shared) -----
const templates = await (await page.request.get('/api/templates')).json();
// German: a template is product content in the instance's language.
const blueprint = templates.find((t: { name: string }) => t.name.startsWith('Ablauf'));
expect(blueprint).toBeTruthy();
const draft = await (
await page.request.post('/api/documents', { data: { template_id: blueprint.id } })
).json();
const documentId = draft.id;
expect(draft.status).toBe('draft');
// Write the document. Done via the API (the editor autosaves the same way)
// so this loop does not depend on the LLM suggestion — capture.spec covers
// that path.
await page.request.patch(`/api/documents/${documentId}`, {
data: {
content_md:
'## Rolle und Aufgaben\n\nInstandhaltung der CNC-Maschinen in Halle 1.\n\n' +
'## Ansprechpartner\n\nDie interne Notfallnummer der Instandhaltung ist die 4455.'
}
});
// --- publish it in the UI --------------------------------------------
await page.goto(`/documents/${documentId}`);
await page.locator('body[data-hydrated]').waitFor();
await page.getByTestId('publish-button').click();
await expect(page.getByTestId('document-status')).toHaveAttribute('data-status', 'published', {
timeout: 15_000
});
// --- the new knowledge is retrievable via chat ------------------------
await page.waitForTimeout(5_000); // let the index job embed the chunks
await page.getByTestId('sidebar-new-conversation').click();
await page
.getByTestId('chat-input')
.fill('Wie lautet die interne Notfallnummer der Instandhaltung?');
await page.getByTestId('send-button').click();
await expect(page.getByTestId('sources').last()).toContainText('Ablauf', {
timeout: 45_000
});
await expect(page.getByTestId('send-button')).toBeVisible({ timeout: 60_000 });
expect(await page.getByTestId('assistant-message').last().innerText()).toContain('4455');
// --- cleanup so the spec is re-runnable -------------------------------
await page.request.delete(`/api/documents/${documentId}`);
// The chat question above auto-created a query conversation (newest first).
const conversations = await (await page.request.get('/api/conversations')).json();
if (conversations.length > 0) {
await page.request.delete(`/api/conversations/${conversations[0].id}`);
}
});
// Edit rights are deliberately NOT shown here — editing starts by opening
// the document, so the list only answers "why can I see this?".
test('the list marks what needs attention and filters by access', async ({ page }) => {
await login(page);
await page.goto('/documents');
await page.locator('body[data-hydrated]').waitFor();
const list = page.getByTestId('document-list');
await expect(list).toContainText('Wartungsplan CNC-Fräse', { timeout: 15_000 });
// Rows are quiet unless something is off: the document somebody asked this
// user to check is flagged, and the normal case (published, yours, public)
// carries no badge.
await expect(page.getByTestId('open-review-badge').first()).toBeVisible();
// Built-in help pages read as product content, not as someone's document.
await expect(list).toContainText('Built-in');
// Filtering by access is client-side over the same rows: a
// department-only document disappears under "Public" and comes back.
const before = await list.locator('li').count();
// The narrowing controls live behind the filter toggle; only the filters
// that are ON stay visible, as removable chips.
await page.getByTestId('filters-toggle').click();
await page.getByTestId('access-filter').getByRole('button', { name: 'Public' }).click();
await expect(list).not.toContainText('Wartungsplan CNC-Fräse');
expect(await list.locator('li').count()).toBeLessThan(before);
await page.getByTestId('access-filter').getByRole('button', { name: 'All' }).click();
await expect(list).toContainText('Wartungsplan CNC-Fräse');
// A filter that is on stays visible after the panel is closed, and can be
// dropped from there — a quietly filtered list would lie about what exists.
await page.getByTestId('access-filter').getByRole('button', { name: 'Public' }).click();
await page.getByTestId('filters-toggle').click();
await expect(page.getByTestId('active-filters')).toContainText('Public');
await page.getByTestId('active-filters').getByRole('button').first().click();
await expect(list).toContainText('Wartungsplan CNC-Fräse');
});
test('a built-in help page cannot be edited or deleted', async ({ page }) => {
await login(page);
const found = (await (await page.request.get('/api/documents?search=Überblick')).json()).items;
const builtin = found.find((d: { is_builtin: boolean }) => d.is_builtin);
expect(builtin).toBeTruthy();
await page.goto(`/documents/${builtin.id}`);
await page.locator('body[data-hydrated]').waitFor();
const actions = page.getByRole('main');
await expect(actions.getByLabel('Edit')).toHaveCount(0);
await expect(actions.getByLabel('Delete')).toHaveCount(0);
// The API refuses the edit as well, not just the UI.
const patched = await page.request.patch(`/api/documents/${builtin.id}`, {
data: { title: 'Hijacked' }
});
expect(patched.status()).toBe(409);
});
test('search finds a document by meaning, not just by title', async ({ page }) => {
await login(page);
await page.goto('/documents');
await page.locator('body[data-hydrated]').waitFor();
// Wording that appears nowhere in the title — only hybrid retrieval finds it.
await page.getByTestId('document-search').fill('Wie oft wird die Fräse gewartet');
const list = page.getByTestId('document-list');
await expect(list).toContainText('Wartungsplan CNC-Fräse', { timeout: 20_000 });
// Hits name the section they matched.
await expect(list.locator('li').first()).toContainText('Wartungsplan');
});
test('the list can be sorted and paged, and the sort sticks', async ({ page }) => {
await login(page);
await page.goto('/documents');
await page.locator('body[data-hydrated]').waitFor();
const list = page.getByTestId('document-list');
await expect(list).toContainText('Wartungsplan CNC-Fräse', { timeout: 15_000 });
// Grid is the only layout for now.
await expect(list).toHaveAttribute('data-layout', 'grid');
// Sorting is server-side, so the set stays the same size.
await page.getByTestId('filters-toggle').click();
await page.getByTestId('sort-select').selectOption('created');
await expect(list).toContainText('Wartungsplan CNC-Fräse', { timeout: 15_000 });
// The sort choice is per device and survives a reload (the filter panel
// itself does not — it opens closed, which is the point of it).
await page.reload();
await page.locator('body[data-hydrated]').waitFor();
await page.getByTestId('filters-toggle').click();
await expect(page.getByTestId('sort-select')).toHaveValue('created', { timeout: 15_000 });
// Paging: force a small page so the pager appears regardless of corpus size.
const small = await (await page.request.get('/api/documents?per_page=2&page=1')).json();
expect(small.items.length).toBeLessThanOrEqual(2);
expect(small.total).toBeGreaterThan(2);
const second = await (await page.request.get('/api/documents?per_page=2&page=2')).json();
const firstIds = small.items.map((d: { id: string }) => d.id);
const secondIds = second.items.map((d: { id: string }) => d.id);
expect(firstIds.some((id: string) => secondIds.includes(id))).toBe(false);
// Zero residue: back to the default sort (the panel is still open).
await page.getByTestId('sort-select').selectOption('updated');
await expect(page.getByTestId('sort-select')).toHaveValue('updated');
});