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
This commit is contained in:
ProfessorNova
2026-09-04 08:36:17 +02:00
co-authored by Claude Opus 5
commit 97dbff309c
346 changed files with 43430 additions and 0 deletions
+129
View File
@@ -0,0 +1,129 @@
import { expect, test } from '@playwright/test';
import { login, DEV_PASSWORD } from './helpers';
// Uses max@pablan.dev so a failure here cannot lock the other specs out of
// pablo@pablan.dev. The password is changed back at the end (zero residue).
const NEW_PASSWORD = 'ben-neues-geheimnis';
test('a user changes their own password and stays signed in', async ({ page }) => {
await login(page, 'max@pablan.dev');
await page.getByTestId('user-menu').click();
await page.getByTestId('change-password').click();
await page.getByLabel('Current password').fill(DEV_PASSWORD);
await page.getByLabel('New password', { exact: true }).fill(NEW_PASSWORD);
await page.getByLabel('Repeat new password').fill(NEW_PASSWORD);
await page.getByTestId('submit-password').click();
await expect(page.getByTestId('password-changed')).toBeVisible({ timeout: 15_000 });
// This session survived the change — no redirect to the login page.
await page.reload();
await page.locator('body[data-hydrated]').waitFor();
await expect(page).toHaveURL('/');
// The new password is the one that works now.
await page.request.post('/api/auth/logout');
const stale = await page.request.post('/api/auth/login', {
data: { email: 'max@pablan.dev', password: DEV_PASSWORD }
});
expect(stale.status()).toBe(401);
// Change it back, so the suite can run again.
await login(page, 'max@pablan.dev', NEW_PASSWORD);
await page.getByTestId('user-menu').click();
await page.getByTestId('change-password').click();
await page.getByLabel('Current password').fill(NEW_PASSWORD);
await page.getByLabel('New password', { exact: true }).fill(DEV_PASSWORD);
await page.getByLabel('Repeat new password').fill(DEV_PASSWORD);
await page.getByTestId('submit-password').click();
await expect(page.getByTestId('password-changed')).toBeVisible({ timeout: 15_000 });
await page.request.post('/api/auth/logout');
});
test('the wrong current password is rejected', async ({ page }) => {
await login(page, 'max@pablan.dev');
await page.getByTestId('user-menu').click();
await page.getByTestId('change-password').click();
await page.getByLabel('Current password').fill('definitely-wrong');
await page.getByLabel('New password', { exact: true }).fill(NEW_PASSWORD);
await page.getByLabel('Repeat new password').fill(NEW_PASSWORD);
await page.getByTestId('submit-password').click();
await expect(page.getByRole('alert')).toContainText('current password');
// The old password still works.
await page.request.post('/api/auth/logout');
const still = await page.request.post('/api/auth/login', {
data: { email: 'max@pablan.dev', password: DEV_PASSWORD }
});
expect(still.status()).toBe(200);
await page.request.post('/api/auth/logout');
});
test('the theme survives a reload without flashing the other one', async ({ page }) => {
await login(page, 'max@pablan.dev');
await page.getByTestId('user-menu').click();
await page.getByTestId('settings-dialog').waitFor();
await page.getByTestId('theme-switch').getByRole('button', { name: 'Light' }).click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
// The boot script applies it before hydration, so it is already correct
// on the very first frame after a reload.
await page.reload();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
await page.locator('body[data-hydrated]').waitFor();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
// Back to following the OS, so the next spec starts clean.
await page.getByTestId('user-menu').click();
await page.getByTestId('theme-switch').getByRole('button', { name: 'System' }).click();
await expect(page.locator('html')).not.toHaveAttribute('data-theme', /.*/);
await page.request.post('/api/auth/logout');
});
test('the language switch flips the interface in place, with no reload', async ({ page }) => {
await login(page, 'max@pablan.dev');
await page.getByTestId('user-menu').click();
const dialog = page.getByTestId('settings-dialog');
const locales = page.getByTestId('locale-switch');
// Start from English, whatever a previous run left behind.
await locales.getByRole('button', { name: 'English' }).click();
await expect(dialog).toContainText('Settings');
await expect(page.locator('html')).toHaveAttribute('lang', 'en');
// A marker on window survives a re-render but not a reload, and a typed
// value survives neither if the DOM is rebuilt: together they are the
// assertion that "no reload artifacts" actually holds.
await page.evaluate(() => ((window as unknown as Record<string, string>).__i18nMarker = 'alive'));
await page.getByTestId('change-password').click();
await page.getByLabel('Current password').fill('typed-before-switch');
await locales.getByRole('button', { name: 'Deutsch' }).click();
await expect(dialog).toContainText('Einstellungen');
await expect(dialog).toContainText('Passwort ändern');
await expect(page.locator('html')).toHaveAttribute('lang', 'de');
expect(
await page.evaluate(() => (window as unknown as Record<string, string>).__i18nMarker)
).toBe('alive');
await expect(page.getByLabel('Aktuelles Passwort')).toHaveValue('typed-before-switch');
// The choice is on the account, not just in the tab.
await expect
.poll(async () => (await (await page.request.get('/api/auth/me')).json()).locale)
.toBe('de');
// Zero residue: back to following the browser.
await locales.getByRole('button', { name: 'Automatisch' }).click();
await expect
.poll(async () => (await (await page.request.get('/api/auth/me')).json()).locale)
.toBe(null);
await page.request.post('/api/auth/logout');
});
+217
View File
@@ -0,0 +1,217 @@
import { expect, test } from '@playwright/test';
import { login, logout, openAdmin, openAdminTab, openDocuments } from './helpers';
// Admin creates a department and a user; the new user logs in and only
// sees what their (new, grantless) department allows. Plus the LLM panel
// against the real endpoints.
test.setTimeout(120_000);
const RUN = Date.now();
const DEPARTMENT = `QS-${RUN}`;
const EMAIL = `quinn-${RUN}@pablan.dev`;
test('admin creates department + user; the new user is correctly scoped', async ({ page }) => {
await login(page, 'florian@pablan.dev', 'pablan-dev');
await openAdmin(page);
await page.locator('body[data-hydrated]').waitFor();
// Department first, so the user form can pick it. Creating is a dialog
// for both, so each starts with its trigger.
await openAdminTab(page, 'people');
await page.getByTestId('new-department').click();
await page.locator('#new-department-name').fill(DEPARTMENT);
await page.getByTestId('create-department').click();
await expect(page.getByTestId('department-list')).toContainText(DEPARTMENT);
await page.getByTestId('new-user').click();
await page.locator('#new-email').fill(EMAIL);
await page.locator('#new-name').fill('Quinn Neu');
await page.locator('#new-department').selectOption({ label: DEPARTMENT });
await page.locator('#new-password').fill('quinn-secret-1');
await page.getByTestId('create-user').click();
await expect(page.getByTestId('user-table')).toContainText(EMAIL);
// LLM endpoint panel against the real endpoints — its own tab now.
await openAdminTab(page, 'llm');
await page.getByTestId('llm-test').click();
await expect(page.getByTestId('llm-results')).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId('llm-results').getByText('ok')).toHaveCount(3);
await logout(page);
// The new user sees public documents, but no other department's ones.
await login(page, EMAIL, 'quinn-secret-1');
await openDocuments(page);
await page.locator('body[data-hydrated]').waitFor();
await expect(page.getByTestId('document-list')).toContainText('Reklamationsprozess', {
timeout: 10_000
});
await expect(page.getByTestId('document-list')).not.toContainText('Wartungsplan CNC-Fräse');
await expect(page.getByTestId('document-list')).not.toContainText('CRM-Pflege');
// No admin nav for members.
await expect(page.getByRole('link', { name: 'Admin', exact: true })).toHaveCount(0);
// Cleanup so the spec is re-runnable.
await logout(page);
await login(page, 'florian@pablan.dev', 'pablan-dev');
const users = (await (await page.request.get('/api/admin/users')).json()).items;
const created = users.find((u: { email: string }) => u.email === EMAIL);
if (created) await page.request.delete(`/api/admin/users/${created.id}`);
const departments = await (await page.request.get('/api/departments')).json();
const dept = departments.find((d: { name: string }) => d.name === DEPARTMENT);
if (dept) await page.request.delete(`/api/admin/departments/${dept.id}`);
await page.request.post('/api/auth/logout');
});
test('an admin changes an LLM endpoint and resets it to the .env value', async ({ page }) => {
await login(page, 'florian@pablan.dev');
await openAdmin(page);
await page.locator('body[data-hydrated]').waitFor();
await openAdminTab(page, 'llm');
const settings = page.getByTestId('llm-settings');
// Bootstrapped from .env, so every field starts out attributed to it.
await expect(settings).toContainText('from .env');
const envUrl = await page.locator('#chat-url').inputValue();
expect(envUrl).not.toEqual('');
// A bogus endpoint must fail the test, and Save stays locked behind it.
await page.locator('#chat-url').fill('http://definitely.invalid/v1');
await page.getByTestId('test-chat').click();
await expect(settings).toContainText('endpoint failed', { timeout: 30_000 });
await expect(page.getByTestId('save-chat')).toBeDisabled();
// The real endpoint passes, so it can be stored, and applies at once.
await page.locator('#chat-url').fill(envUrl);
await page.getByTestId('test-chat').click();
await expect(settings).toContainText('ok ·', { timeout: 30_000 });
await page.locator('#chat-url').fill(`${envUrl}/`);
await page.getByTestId('save-chat').click();
await expect(settings).toContainText('changed here', { timeout: 15_000 });
// The endpoint reports what it serves, so the model becomes a dropdown.
await page.getByTestId('discover-chat').click();
await expect(
page.getByTestId('model-select-chat').or(page.getByTestId('no-model-list-chat'))
).toBeVisible({ timeout: 30_000 });
// Reset to .env, so the run leaves no configuration behind (zero residue).
await settings.getByLabel('Reset to .env').first().click();
await expect(settings.getByText('changed here')).toHaveCount(0, { timeout: 15_000 });
await expect(page.locator('#chat-url')).toHaveValue(envUrl);
await logout(page);
});
test('an admin forks a template, edits the fork and deletes it', async ({ page }) => {
await login(page, 'florian@pablan.dev');
await openAdmin(page);
await page.locator('body[data-hydrated]').waitFor();
await openAdminTab(page, 'templates');
const templates = page.getByTestId('template-list');
const firstRow = templates.locator('li').first();
await expect(firstRow).toBeVisible({ timeout: 15_000 });
await firstRow.getByLabel('Duplicate').click();
// The fork opens in the form builder; the raw YAML is behind its toggle,
// which is where a broken template can be typed at all.
await page.getByTestId('builder-show-yaml').click();
const editor = page.getByTestId('template-editor');
await expect(editor).toBeVisible({ timeout: 15_000 });
const original = await editor.inputValue();
await editor.fill('id: broken\nname: nope');
await page.getByTestId('save-template').click();
await expect(page.getByTestId('template-error')).toBeVisible();
// Valid YAML saves, and the fork appears in the list.
await editor.fill(original);
await page.getByTestId('save-template').click();
await expect(templates).toContainText('(2)', { timeout: 15_000 });
// Remove it again (zero residue). Destructive actions ask in the app's own
// modal, not the browser's.
const fork = templates.locator('li').filter({ hasText: '(2)' }).first();
await fork.getByLabel('Delete').click();
await page.getByTestId('confirm-accept').click();
await expect(templates.getByText('(2)')).toHaveCount(0, { timeout: 15_000 });
await logout(page);
});
test('an admin adds a template from the catalog and removes it again', async ({ page }) => {
await login(page, 'florian@pablan.dev');
await openAdmin(page);
await page.locator('body[data-hydrated]').waitFor();
await openAdminTab(page, 'templates');
await page.getByTestId('toggle-catalog').click();
// A blueprint can be read before it is added — that is what "View" is for.
// Deliberately one the starter set does NOT install: adding a blueprint
// that is already there would make the delete below ambiguous.
const catalog = page.getByTestId('template-catalog');
const blueprint = catalog.locator('li').filter({ hasText: 'Anlage' }).first();
await expect(blueprint).toBeVisible({ timeout: 15_000 });
await blueprint.getByLabel('View').click();
const editor = page.getByTestId('template-editor');
await expect(editor).toBeVisible({ timeout: 15_000 });
// Nothing on disk is editable — it has no row to save to yet.
await expect(editor).toHaveAttribute('readonly', '');
// Adding drops it into the instance and opens it for adapting straight
// away, which is what an admin does next — so the row is NOT on screen
// afterwards, and the instance's own list is what proves the add. (The
// panel around the list holds the open editor too, so asserting text on it
// would match the blueprint YAML and prove nothing.)
await page.getByTestId('add-template').click();
const listed = async () =>
(await (await page.request.get('/api/templates')).json()).find((entry: { name: string }) =>
entry.name.includes('Anlage')
);
await expect.poll(listed, { timeout: 15_000 }).toBeTruthy();
// Zero residue. Through the API: the UI is sitting in the editor it just
// opened, and this spec is about the catalog, not about leaving a form.
const added = await listed();
expect((await page.request.delete(`/api/templates/${added.id}`)).status()).toBe(204);
await logout(page);
});
test('an admin edits a user in the modal and pages the list', async ({ page }) => {
await login(page, 'florian@pablan.dev');
await openAdmin(page);
await page.locator('body[data-hydrated]').waitFor();
// Address the row by who it is, never by position: this test changes
// data, and after the search below `.first()` is a different person.
await openAdminTab(page, 'people');
const row = page.getByTestId('user-table').locator('tr').filter({ hasText: 'max@pablan.dev' });
const dialog = page.getByTestId('user-dialog');
// Editing is a dialog, not an expanded row.
await row.getByTestId('edit-user').click();
await expect(dialog).toBeVisible({ timeout: 15_000 });
const original = await page.locator('#edit-name').inputValue();
await page.locator('#edit-name').fill(`${original} (e2e)`);
await page.getByTestId('save-user').click();
await expect(dialog).toHaveCount(0, { timeout: 15_000 });
await expect(row).toContainText(`${original} (e2e)`);
// Search narrows the list server-side.
await page.getByTestId('user-search').fill('pablo');
await expect
.poll(async () => page.getByTestId('user-table').locator('tr').count(), { timeout: 15_000 })
.toBe(1);
await page.getByTestId('user-search').fill('');
await expect(row).toBeVisible({ timeout: 15_000 });
// Zero residue: put the name back on the same row it came from.
await row.getByTestId('edit-user').click();
await expect(dialog).toBeVisible({ timeout: 15_000 });
await page.locator('#edit-name').fill(original);
await page.getByTestId('save-user').click();
await expect(dialog).toHaveCount(0, { timeout: 15_000 });
await expect(row).toContainText(original);
await logout(page);
});
+107
View File
@@ -0,0 +1,107 @@
import { expect, test, type Page } from '@playwright/test';
import { login, logout } from './helpers';
// Uses the seeded dev users (make seed): pablo@pablan.dev / pablan-dev.
async function gotoHydrated(page: Page, path: string) {
await page.goto(path);
await page.locator('body[data-hydrated]').waitFor();
}
async function signInOnCurrentPage(page: Page, email: string) {
// Fills the login form already on screen — NO page.goto. A full
// navigation would reset the module singletons and hide the very leak
// this exercises. Wait for hydration first, so the client submit handler
// (which does the full-reload navigation) is wired up. Selectors are by
// attribute, not label: the logged-out login page is rendered in
// whatever language the previous user left, which is part of the point.
await page.locator('body[data-hydrated]').waitFor();
await page.locator('input[name="email"]').fill(email);
await page.locator('input[name="password"]').fill('pablan-dev');
await page.locator('input[name="password"]').press('Enter');
await expect(page).toHaveURL('/');
await page.locator('body[data-hydrated]').waitFor();
}
test('redirects anonymous visitors to the login page', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveURL(/\/login$/);
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
});
test('shows an error for wrong credentials', async ({ page }) => {
await gotoHydrated(page, '/login');
await page.getByLabel('Email').fill('pablo@pablan.dev');
await page.getByLabel('Password').fill('definitely-wrong');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('alert')).toHaveText('Email or password is incorrect.');
await expect(page).toHaveURL(/\/login$/);
});
test('login and logout round-trip', async ({ page }) => {
await gotoHydrated(page, '/login');
await page.getByLabel('Email').fill('pablo@pablan.dev');
await page.getByLabel('Password').fill('pablan-dev');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL('/');
await expect(page.getByTestId('sidebar').getByText('Pablo')).toBeVisible();
// The landing greets by first name and invites capture.
await expect(page.getByRole('heading', { name: /Pablo/ })).toBeVisible();
await logout(page);
await page.goto('/');
await expect(page).toHaveURL(/\/login$/);
});
// Regression for the P0 session-state leak: switching users in one browser
// context must not carry the previous user's conversation titles (an
// information disclosure) or their interface language across the boundary.
// Deliberately drives the real in-app flow — logout button, then the login
// form on the page it lands on — with no page.goto between users, which is
// what masked this in the other specs.
const MARKER = 'Zebrafrage-Session-P0-Test';
let leakConversationId: string | null = null;
test.afterEach(async ({ page }) => {
// End whatever session is current (max's UI login is never logged out by
// the test itself), then re-auth as pablo to undo his residue.
await page.request.post('/api/auth/logout');
await page.request.post('/api/auth/login', {
data: { email: 'pablo@pablan.dev', password: 'pablan-dev' }
});
await page.request.put('/api/account/locale', { data: { locale: null } });
if (leakConversationId) {
await page.request.delete(`/api/conversations/${leakConversationId}`);
leakConversationId = null;
}
await page.request.post('/api/auth/logout');
});
test('a different user does not inherit the previous session state', async ({ page }) => {
// Pablo: German interface and one conversation with a recognizable title.
await login(page, 'pablo@pablan.dev');
await page.request.put('/api/account/locale', { data: { locale: 'de' } });
const created = await page.request.post('/api/conversations', { data: { mode: 'query' } });
leakConversationId = (await created.json()).id;
await page.request.post(`/api/conversations/${leakConversationId}/messages`, {
data: { content: MARKER }
});
// Reload (pablo → pablo) so the German setting and the new conversation
// are on screen before the switch.
await page.goto('/');
await page.locator('body[data-hydrated]').waitFor();
await expect(page.getByTestId('sidebar')).toContainText(MARKER);
await expect(page.locator('html')).toHaveAttribute('lang', 'de');
// Switch to Max through the UI, no page.goto.
await logout(page);
await page.locator('body[data-hydrated]').waitFor();
await signInOnCurrentPage(page, 'max@pablan.dev');
// Max sees only his own world.
await expect(page.getByTestId('sidebar')).not.toContainText(MARKER);
await expect(page.locator('html')).toHaveAttribute('lang', 'en');
});
+91
View File
@@ -0,0 +1,91 @@
import { expect, test, type Page } from '@playwright/test';
import { login as loginAs } from './helpers';
// Writing-first capture as a USER sees it: pick a documentation type, write in
// the editor, get a refined version of the section at the cursor, accept it,
// read the diff before saving, and publish. The refine step drives the real
// LLM, so it can take a few seconds.
test.describe.configure({ mode: 'serial' });
test.setTimeout(180_000);
// Template names are matched in German on purpose: a template is product
// CONTENT in the instance's own language (PABLAN_DEFAULT_LOCALE), not UI copy
// that follows the reader's language setting.
/** Draft documents this spec created, so cleanup removes only its own. */
let created: string[] = [];
async function newDraft(page: Page, templateText: string): Promise<string> {
await page.goto('/documents/new');
await page.locator('body[data-hydrated]').waitFor();
await page.getByTestId('capture-template').filter({ hasText: templateText }).first().click();
await expect(page).toHaveURL(/\/documents\/[0-9a-f-]{36}\/edit$/);
const id = page.url().split('/')[4];
created.push(id);
await page.locator('.cm-content').waitFor();
return id;
}
test.afterEach(async ({ page }) => {
for (const id of created) {
await page.request.delete(`/api/documents/${id}`);
}
created = [];
await page.request.post('/api/auth/logout');
});
test('picking a type opens the editor on the template skeleton', async ({ page }) => {
await loginAs(page, 'pablo@pablan.dev');
await newDraft(page, 'Über dich');
// The editor opens on the skeleton headings, not an empty box, and nothing
// else: the suggestion is a block inside the text that appears when one
// streams, so before any typing there is no widget and nothing to accept.
await expect(page.getByTestId('editor-source')).toContainText('##');
await expect(page.getByTestId('editor-suggestion')).toHaveCount(0);
await expect(page.getByTestId('editor-accept')).toHaveCount(0);
});
test('a typing pause yields a section suggestion that overwrites on accept', async ({ page }) => {
await loginAs(page, 'pablo@pablan.dev');
await newDraft(page, 'Über dich');
// Write rough notes under the first heading.
await page.locator('.cm-content').click();
await page.keyboard.press('Control+Home');
await page.keyboard.press('ArrowDown');
await page.keyboard.type(
'also der neue kollege wartet die cnc maschinen und ist ansprechpartner für den einkauf.'
);
// After the pause the model streams a refined version of the section.
await expect(page.getByTestId('editor-accept')).toBeVisible({ timeout: 40_000 });
await page.getByTestId('editor-accept').click();
// Accepting consumes the suggestion (the pane returns to its empty state)
// and replaces the rough notes — the lowercase draft phrasing is gone.
await expect(page.getByTestId('editor-accept')).toHaveCount(0);
await expect(page.getByTestId('editor-source')).not.toContainText('also der neue kollege');
// Saving shows what changed first, VSCode-style, and asks again.
await page.getByTestId('editor-open-save').click();
await expect(page.getByTestId('editor-diff')).toBeVisible();
});
test('a draft is published from the editor in one step', async ({ page }) => {
await loginAs(page, 'pablo@pablan.dev');
const id = await newDraft(page, 'Notiz');
await page.locator('.cm-content').click();
await page.keyboard.type('\nEin kurzer, brauchbarer Inhalt zum Veröffentlichen.');
await page.getByTestId('editor-open-save').click();
await page.getByTestId('editor-publish').click();
// Published, and the author is offered a colleague to check it.
await expect(page.getByTestId('capture-success')).toBeVisible();
await page.getByTestId('success-view').click();
await expect(page).toHaveURL(new RegExp(`/documents/${id}$`));
await expect(page.getByTestId('document-status')).toHaveAttribute('data-status', 'published');
});
+246
View File
@@ -0,0 +1,246 @@
import { expect, test, type Page } from '@playwright/test';
import { login as loginAs, openChat, waitForTurnEnd } from './helpers';
// Runs against the dev stack with the REAL LLM endpoints and the seeded,
// indexed corpus (make seed + backend running). Streaming answers from the
// local model take seconds — generous timeouts on stream assertions.
test.describe.configure({ mode: 'serial' });
test.setTimeout(120_000);
async function login(page: Page) {
await loginAs(page, 'pablo@pablan.dev');
}
async function deleteNewestConversation(page: Page) {
const list = await (await page.request.get('/api/conversations')).json();
if (list.length > 0) {
await page.request.delete(`/api/conversations/${list[0].id}`);
}
}
// Zero-residue: every test cleans up its conversation; the session goes here.
test.afterEach(async ({ page }) => {
await page.request.post('/api/auth/logout');
});
test('streams an answer with citations from the corpus', async ({ page }) => {
await login(page);
await openChat(page);
await page.getByTestId('chat-input').fill('Wie beantrage ich Urlaub?');
await page.getByTestId('send-button').click();
// Retrieval progress is reported before the answer arrives.
await expect(page.getByTestId('retrieval-status')).toBeVisible({ timeout: 30_000 });
const assistant = page.getByTestId('assistant-message').last();
await expect(assistant).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId('sources').last()).toContainText(
'Urlaubsanträge und Abwesenheiten',
{ timeout: 30_000 }
);
// Wait for the stream to finish, then check we got a real answer.
await waitForTurnEnd(page);
const answer = (await assistant.innerText()).trim();
expect(answer.length).toBeGreaterThan(40);
// Transient: the status line disappears once the turn ends.
await expect(page.getByTestId('retrieval-status')).toHaveCount(0);
// One badge per cited document, however many of its sections matched.
// Retrieval works on chunks, so this is exactly where duplicates appear.
const titles = await page
.getByTestId('sources')
.last()
.getByTestId('source-badge')
.allInnerTexts();
const documentNames = titles.map((text) => text.replace(/\d+ sections$/, '').trim());
expect(new Set(documentNames).size).toBe(documentNames.length);
await deleteNewestConversation(page);
});
test('a citation shows its excerpt on hover and opens the document beside the chat', async ({
page
}) => {
await login(page);
await openChat(page);
await page.getByTestId('chat-input').fill('Wie beantrage ich Urlaub?');
await page.getByTestId('send-button').click();
await expect(page.getByTestId('sources').last()).toBeVisible({ timeout: 45_000 });
await waitForTurnEnd(page);
const badge = page.getByTestId('source-badge').first();
await badge.hover();
// The popover carries the cited passage as plain text.
const tooltip = page.getByTestId('tooltip-content');
await expect(tooltip).toBeVisible({ timeout: 10_000 });
expect((await tooltip.innerText()).trim().length).toBeGreaterThan(20);
await badge.click();
const panel = page.getByTestId('document-panel');
await expect(panel).toBeVisible();
await expect(panel).toContainText('Urlaub', { timeout: 15_000 });
await panel.getByLabel('Close document panel').click();
await expect(panel).toHaveCount(0);
await deleteNewestConversation(page);
});
test('an undocumented question offers to capture the knowledge', async ({ page }) => {
await login(page);
await openChat(page);
// One of the eval-calibrated no-answer queries (tests/fixtures/
// golden_queries.yaml), so this reliably takes the low-confidence path.
await page.getByTestId('chat-input').fill('Welche Gerichte gibt es in der Kantine für Veganer?');
await page.getByTestId('send-button').click();
await waitForTurnEnd(page);
// The answer stands on general knowledge, but is labelled source-free
// and offers to close the gap by writing it down.
await expect(page.getByTestId('no-sources-note')).toBeVisible();
// The link carries the conversation, so the draft starts with the question
// that exposed the gap as background.
await expect(page.getByTestId('capture-gap')).toHaveAttribute(
'href',
/\/documents\/new\?conversation=/
);
await page.getByTestId('capture-gap').click();
await expect(page).toHaveURL(/\/documents\/new\?conversation=/);
// Only the query conversation was created; clean it up.
await deleteNewestConversation(page);
});
test('stop button aborts the stream and the partial answer survives reload', async ({ page }) => {
await login(page);
await openChat(page);
await page
.getByTestId('chat-input')
.fill('Erkläre ausführlich und Schritt für Schritt den kompletten Reklamationsprozess.');
await page.getByTestId('send-button').click();
// Wait until answer TOKENS have arrived (the .markdown body, not the
// sources badges which render first), then stop mid-stream.
const assistant = page.getByTestId('assistant-message').last();
await expect(assistant).toBeVisible({ timeout: 30_000 });
const answerBody = assistant.locator('.markdown');
await expect
.poll(async () => (await answerBody.innerText()).trim().length, { timeout: 40_000 })
.toBeGreaterThan(15);
const stop = page.getByTestId('stop-button');
if (await stop.isVisible()) {
await stop.click();
}
await expect(page.getByTestId('stop-button')).toHaveCount(0, { timeout: 90_000 });
// The (partial) assistant message is persisted server-side.
await page.reload();
await page.locator('body[data-hydrated]').waitFor();
// Sidebar rows are links; the only button in a row is "delete".
await page.getByTestId('conversation-list').getByRole('link').first().click();
const restored = page.getByTestId('assistant-message').last();
await expect(restored).toBeVisible({ timeout: 15_000 });
expect((await restored.innerText()).trim().length).toBeGreaterThan(10);
await deleteNewestConversation(page);
});
test('deleting a conversation removes it from the list', async ({ page }) => {
await login(page);
// Create a dedicated conversation via the API so the test never deletes
// pre-existing user data (and doesn't depend on earlier tests' residue).
await page.request.post('/api/conversations', { data: { mode: 'query' } });
await openChat(page);
const list = page.getByTestId('conversation-list');
await expect.poll(async () => list.locator('li').count(), { timeout: 15_000 }).toBeGreaterThan(0);
const before = await list.locator('li').count();
// Newest first: the untitled conversation we just created. Deleting asks
// first — it is destructive and the row carries no undo.
const ownRow = list.locator('li').filter({ hasText: 'New conversation' }).first();
await ownRow.hover();
await ownRow.getByLabel('Delete conversation').click();
await page.getByTestId('confirm-accept').click();
await expect.poll(async () => list.locator('li').count()).toBe(before - 1);
});
test('Pablan answers questions about itself, in the language they were asked', async ({ page }) => {
await login(page);
await openChat(page);
// Deictic phrasing ("this app") and English, against German help pages:
// the built-in documentation has to carry both the self-reference and
// survive the language switch.
await page.getByTestId('chat-input').fill('How does this app work?');
await page.getByTestId('send-button').click();
await waitForTurnEnd(page);
const assistant = page.getByTestId('assistant-message').last();
await expect(assistant).toContainText('Pablan');
await expect(page.getByTestId('sources').last()).toContainText('Pablan:', {
timeout: 15_000
});
// It was answered FROM the knowledge base, not from general knowledge.
await expect(page.getByTestId('no-sources-note')).toHaveCount(0);
await deleteNewestConversation(page);
});
test('the first message moves the URL to the conversation, and Back leaves it', async ({
page
}) => {
await login(page);
await openChat(page);
await expect(page).toHaveURL('/chat');
await page.getByTestId('chat-input').fill('Wie beantrage ich Urlaub?');
await page.getByTestId('send-button').click();
// The conversation gets its own address as soon as it exists, while the
// answer is still streaming.
await expect(page).toHaveURL(/\/chat\/[0-9a-f-]{36}$/, { timeout: 30_000 });
const conversationUrl = page.url();
await waitForTurnEnd(page);
// The stream survived the navigation: the state outlives the route.
expect(
(await page.getByTestId('assistant-message').last().innerText()).trim().length
).toBeGreaterThan(20);
// replaceState, so Back never lands on an orphaned empty composer.
await page.goBack();
await expect(page).not.toHaveURL(conversationUrl);
await deleteNewestConversation(page);
});
test('switching conversations abandons the previous view, and a foreign id is 404', async ({
page
}) => {
await login(page);
// Two conversations, created via the API so nothing depends on ordering.
const first = await (
await page.request.post('/api/conversations', { data: { mode: 'query' } })
).json();
const second = await (
await page.request.post('/api/conversations', { data: { mode: 'query' } })
).json();
await page.goto(`/chat/${first.id}`);
await page.locator('body[data-hydrated]').waitFor();
await page.goto(`/chat/${second.id}`);
await page.locator('body[data-hydrated]').waitFor();
// Nothing bled across: the second conversation is empty.
await expect(page.getByTestId('assistant-message')).toHaveCount(0);
// An id that is not yours is indistinguishable from one that does not
// exist — existence must not leak.
await page.goto('/chat/00000000-0000-4000-8000-000000000000');
await expect(page.locator('body')).toContainText('404');
await page.request.delete(`/api/conversations/${first.id}`);
await page.request.delete(`/api/conversations/${second.id}`);
});
+183
View File
@@ -0,0 +1,183 @@
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');
});
+3
View File
@@ -0,0 +1,3 @@
import { globalSetup } from './residue';
export default globalSetup;
+3
View File
@@ -0,0 +1,3 @@
import { globalTeardown } from './residue';
export default globalTeardown;
+71
View File
@@ -0,0 +1,71 @@
// Shared navigation helpers. Every spec used to inline these; they live
// here so a change to the app shell is a one-file fix.
import { expect, type Page } from '@playwright/test';
export const DEV_PASSWORD = 'pablan-dev';
export async function login(page: Page, email: string, password = DEV_PASSWORD) {
await page.goto('/login');
await page.locator('body[data-hydrated]').waitFor();
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL('/');
// The specs assert English copy and pin it with Accept-Language, which is
// the strategy for a visitor with no ACCOUNT preference. A language picked
// in the settings dialog outranks it (that is the product rule), so a dev
// stack where somebody switched to German would fail every spec that reads
// a label. Hand the account back to "follow the browser" on the way in.
await page.request.put('/api/account/locale', { data: { locale: null } });
// Wait for the landing page to hydrate: filling an input before Svelte
// binds it leaves the state empty and submit buttons disabled.
await page.locator('body[data-hydrated]').waitFor();
}
/** Log out through the sidebar user menu. */
export async function logout(page: Page) {
// Login/logout are full document reloads (session state must not survive
// the boundary), so the page may be freshly server-rendered and not yet
// interactive when a test reaches here; a click before hydration does
// nothing. A real user is far slower than hydration.
await page.locator('body[data-hydrated]').waitFor();
await page.getByTestId('user-menu').click();
await page.getByTestId('logout').click();
await expect(page).toHaveURL(/\/login$/);
}
export async function openChat(page: Page) {
// By testid: an untitled conversation row carries the same label.
await page.getByTestId('sidebar-new-conversation').click();
await expect(page).toHaveURL(/\/chat/);
}
export async function openDocuments(page: Page) {
await page.getByTestId('sidebar').getByRole('link', { name: 'Documents' }).click();
await expect(page).toHaveURL(/\/documents$/);
}
export async function openAdmin(page: Page) {
await page.getByTestId('sidebar').getByRole('link', { name: 'Admin' }).click();
await expect(page).toHaveURL(/\/admin$/);
}
/** The admin page is four unrelated jobs behind four tabs; every spec has to
* say which one it is doing.
*
* By testid, not by label: the account's own language setting outranks the
* Accept-Language this suite pins (that is the product rule — a language a
* person chose follows them), and the admin used here has picked one. */
export async function openAdminTab(page: Page, tab: 'people' | 'templates' | 'llm' | 'prompts') {
await page.locator('body[data-hydrated]').waitFor();
await page.getByTestId(`tab-${tab}`).click();
}
/** Call right after triggering a turn: waits for it to actually start (the
* stop button appears) and then to finish. Waiting on the send button alone
* races, because it is still visible for a moment after the click. */
export async function waitForTurnEnd(page: Page) {
await expect(page.getByTestId('stop-button')).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId('stop-button')).toHaveCount(0, { timeout: 120_000 });
}
+58
View File
@@ -0,0 +1,58 @@
import { expect, test } from '@playwright/test';
import { login, logout } from './helpers';
// The landing is the front door: one input that starts a real conversation,
// and three chips.
test.setTimeout(120_000);
test.afterEach(async ({ page }) => {
await page.request.post('/api/auth/logout');
});
test('the landing input starts a conversation and streams an answer', async ({ page }) => {
await login(page, 'pablo@pablan.dev');
await page.getByTestId('landing-input').fill('Wie beantrage ich Urlaub?');
await page.getByTestId('landing-send').click();
// Hand-off to the chat page, which sends the question straight away.
await expect(page).toHaveURL(/\/chat/);
const assistant = page.getByTestId('assistant-message').last();
await expect(assistant).toBeVisible({ timeout: 45_000 });
// The turn is over once the stop button is gone.
await expect(page.getByTestId('stop-button')).toHaveCount(0, { timeout: 90_000 });
expect((await assistant.innerText()).trim().length).toBeGreaterThan(40);
// The conversation shows up in the sidebar.
await expect(page.getByTestId('conversation-list')).toContainText('Urlaub');
// Reloading reopens the same conversation instead of re-asking: the
// hand-off parameter is replaced by the conversation's own route.
const list = await (await page.request.get('/api/conversations')).json();
const created = list.find((c: { title: string }) => c.title?.includes('Urlaub'));
expect(created).toBeTruthy();
await expect(page).toHaveURL(`/chat/${created.id}`);
await page.reload();
await page.locator('body[data-hydrated]').waitFor();
await expect(page.getByTestId('assistant-message')).toHaveCount(1);
const after = await (await page.request.get(`/api/conversations/${created.id}`)).json();
expect(after.messages).toHaveLength(2); // still one question, one answer
await page.request.delete(`/api/conversations/${created.id}`);
});
test('the chips lead to capture and documents', async ({ page }) => {
await login(page, 'pablo@pablan.dev');
await page.getByTestId('chip-find').click();
await expect(page).toHaveURL(/\/documents$/);
await page.goBack();
await page.locator('body[data-hydrated]').waitFor();
await page.getByTestId('chip-capture').click();
await expect(page).toHaveURL(/\/documents\/new$/);
await expect(page.getByTestId('capture-template').first()).toBeVisible({ timeout: 15_000 });
await logout(page);
});
+67
View File
@@ -0,0 +1,67 @@
import { expect, test, type Page } from '@playwright/test';
import { login as loginAs, logout } from './helpers';
// Permission boundary in the browser, against the restricted corpus
// document "Wissenssicherung: Werner Krause" (visibility: restricted,
// granted to Engineering only). Pablo (Engineering) sees it, Max (Sales)
// must not — in the list, in the detail view, and in chat sources.
test.setTimeout(120_000);
async function login(page: Page, email: string) {
await loginAs(page, email);
}
test('restricted corpus document stays invisible across list, detail and chat', async ({
page
}) => {
// Pablo (Engineering, has the grant) can see it — and we grab the id.
await login(page, 'pablo@pablan.dev');
const fromApi = (await (await page.request.get('/api/documents?search=Wissenssicherung')).json())
.items;
expect(fromApi.length).toBeGreaterThan(0);
const restrictedId = fromApi[0].id;
await page.goto('/documents');
await page.locator('body[data-hydrated]').waitFor();
await page.getByTestId('document-search').fill('Wissenssicherung');
await expect(page.getByTestId('document-list')).toContainText('Wissenssicherung: Werner Krause', {
timeout: 10_000
});
await logout(page);
// Max (Sales): list is empty, detail 404s, chat cites nothing restricted.
await login(page, 'max@pablan.dev');
await page.goto('/documents');
await page.locator('body[data-hydrated]').waitFor();
await page.getByTestId('document-search').fill('Wissenssicherung Werner Krause');
// Search ranks by meaning, so it returns related documents rather than
// nothing — what matters is that the restricted one is never among them.
await expect(
page.getByTestId('document-list').or(page.getByText('No documents match'))
).toBeVisible({
timeout: 15_000
});
await expect(page.locator('body')).not.toContainText('Wissenssicherung: Werner Krause');
await page.goto(`/documents/${restrictedId}`);
await expect(page.getByText('Document not found')).toBeVisible();
await page.getByTestId('sidebar-new-conversation').click();
await page
.getByTestId('chat-input')
.fill('Welcher Servotec-Techniker kennt die F-350 am besten?');
await page.getByTestId('send-button').click();
await expect(page.getByTestId('send-button')).toBeVisible({ timeout: 60_000 });
const sources = page.getByTestId('sources');
if ((await sources.count()) > 0) {
await expect(sources.last()).not.toContainText('Wissenssicherung');
}
// Cleanup: ben's chat question created a conversation; drop it + session.
const conversations = await (await page.request.get('/api/conversations')).json();
if (conversations.length > 0) {
await page.request.delete(`/api/conversations/${conversations[0].id}`);
}
await page.request.post('/api/auth/logout');
});
+58
View File
@@ -0,0 +1,58 @@
// Zero-residue guard: the e2e suite must leave the dev database exactly as
// it found it. globalSetup snapshots row counts, globalTeardown re-counts
// and fails the run on any difference.
//
// Documented exception: done/failed rows in `jobs` are execution history
// (the queue's audit trail); only unprocessed jobs count as residue.
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const TABLES = [
'users',
'departments',
'templates',
'conversations',
'messages',
'documents',
'chunks',
'doc_permissions',
'auth_sessions',
'llm_settings'
];
const SNAPSHOT = path.join(import.meta.dirname, '..', 'test-results', 'row-counts.json');
function query(sql: string): string {
return execSync(`docker exec pablan-dev-postgres-1 psql -tA -U pablan -d pablan -c "${sql}"`)
.toString()
.trim();
}
export function snapshotCounts(): Record<string, number> {
const counts: Record<string, number> = {};
for (const table of TABLES) {
counts[table] = Number(query(`SELECT count(*) FROM ${table}`));
}
counts['jobs (unprocessed)'] = Number(
query(`SELECT count(*) FROM jobs WHERE status IN ('pending','running')`)
);
return counts;
}
export async function globalSetup(): Promise<void> {
fs.mkdirSync(path.dirname(SNAPSHOT), { recursive: true });
fs.writeFileSync(SNAPSHOT, JSON.stringify(snapshotCounts(), null, 2));
}
export async function globalTeardown(): Promise<void> {
const before = JSON.parse(fs.readFileSync(SNAPSHOT, 'utf-8')) as Record<string, number>;
const after = snapshotCounts();
const diffs = Object.keys(after)
.filter((key) => before[key] !== after[key])
.map((key) => ` ${key}: ${before[key]} -> ${after[key]}`);
if (diffs.length > 0) {
throw new Error(`e2e suite left residue in the database:\n${diffs.join('\n')}`);
}
}
+105
View File
@@ -0,0 +1,105 @@
import { expect, test } from '@playwright/test';
import { login as loginAs, logout } from './helpers';
// Asking a colleague to check something, end to end and without the model:
// the author asks, the document is marked as unsettled for everyone, the
// colleague answers, and the mark goes away with the answer.
test.setTimeout(120_000);
test('a question travels with the document until the colleague answers it', async ({ page }) => {
await loginAs(page, 'pablo@pablan.dev');
// A published document everyone may read, so Max is a candidate reviewer.
const created = await (
await page.request.post('/api/documents', {
data: { title: 'Notfallnummern Halle 1', visibility: 'public' }
})
).json();
const documentId = created.id;
await page.request.patch(`/api/documents/${documentId}`, {
data: { content_md: '## Notfall\n\nDie Nummer der Instandhaltung ist die 4455.' }
});
await page.request.post(`/api/documents/${documentId}/publish`);
await page.goto(`/documents/${documentId}`);
await page.locator('body[data-hydrated]').waitFor();
await page.getByTestId('document-menu').click();
await page.getByTestId('document-ask-review').click();
await page.getByTestId('reviewer-select').selectOption({ label: 'Max' });
await page.getByTestId('review-question').fill('Stimmt die 4455 noch?');
await page.getByTestId('review-ask-send').click();
// The question is on the document, and it says who is waiting on whom.
await expect(page.getByTestId('open-reviews')).toContainText('Stimmt die 4455 noch?');
await expect(page.getByTestId('open-reviews')).toContainText('Max');
// The author cannot answer their own question, only drop it.
await expect(page.getByTestId('review-confirm')).toHaveCount(0);
await expect(page.getByTestId('review-close')).toBeVisible();
await logout(page);
// Max was asked: it is in his queue, he may edit it, and he answers.
await loginAs(page, 'max@pablan.dev');
await page.goto('/documents?review=1');
await page.locator('body[data-hydrated]').waitFor();
await expect(page.getByTestId('document-list')).toContainText('Notfallnummern Halle 1');
await expect(page.getByTestId('open-review-badge').first()).toBeVisible();
await page.goto(`/documents/${documentId}`);
await page.locator('body[data-hydrated]').waitFor();
await expect(page.getByTestId('document-edit')).toBeVisible();
await page.getByTestId('review-confirm').click();
// Answered: no open question left, and the record says who checked it.
await expect(page.getByTestId('open-reviews')).toHaveCount(0);
await expect(page.getByTestId('answered-reviews')).toContainText('Max');
// The grant went with the answer — Max may read it, not change it.
await expect(page.getByTestId('document-edit')).toHaveCount(0);
await logout(page);
await loginAs(page, 'pablo@pablan.dev');
await page.request.delete(`/api/documents/${documentId}`);
await page.request.post('/api/auth/logout');
});
test('answering on a draft hands it back instead of dropping you on a 404', async ({ page }) => {
await loginAs(page, 'pablo@pablan.dev');
const created = await (
await page.request.post('/api/documents', {
data: { title: 'Entwurf zum Gegenlesen', visibility: 'public' }
})
).json();
const documentId = created.id;
await page.request.patch(`/api/documents/${documentId}`, {
data: { content_md: '## Stand\n\nNoch nicht fertig.' }
});
const max = (
await (await page.request.get(`/api/documents/${documentId}/reviewers`)).json()
).find((candidate: { name: string }) => candidate.name === 'Max');
await page.request.post(`/api/documents/${documentId}/reviews`, {
data: { reviewer_id: max.id, question: 'Passt der Stand so?' }
});
await logout(page);
// Max only sees the draft because he was asked, and the page says so.
await loginAs(page, 'max@pablan.dev');
await page.goto(`/documents/${documentId}`);
await page.locator('body[data-hydrated]').waitFor();
await expect(page.getByTestId('open-reviews')).toContainText('Passt der Stand so?');
// He can see who may read it, and change neither that nor the publish state.
await page.getByTestId('access-chip').click();
await expect(page.getByTestId('access-controls')).toBeVisible();
await expect(page.getByTestId('visibility-select')).toHaveCount(0);
await expect(page.getByTestId('share-save')).toHaveCount(0);
await page.keyboard.press('Escape');
await expect(page.getByTestId('publish-button')).toHaveCount(0);
// Answering ends that access, so the page thanks him instead of 404ing.
await page.getByTestId('review-confirm').click();
await expect(page.getByTestId('review-done')).toBeVisible();
await logout(page);
await loginAs(page, 'pablo@pablan.dev');
await page.request.delete(`/api/documents/${documentId}`);
await page.request.post('/api/auth/logout');
});