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