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 09:21:37 +02:00
co-authored by Claude Opus 5
parent 68d3a43191
commit 784b76baf7
346 changed files with 43430 additions and 0 deletions
+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);
});