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
184 lines
8.2 KiB
TypeScript
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');
|
|
});
|