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:
co-authored by
Claude Opus 5
parent
68d3a43191
commit
784b76baf7
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { globalSetup } from './residue';
|
||||
|
||||
export default globalSetup;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { globalTeardown } from './residue';
|
||||
|
||||
export default globalTeardown;
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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')}`);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user