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:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user