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'); });