diff --git a/core/http/react-ui/bun.lock b/core/http/react-ui/bun.lock index 4d9ce9572..00fe52074 100644 --- a/core/http/react-ui/bun.lock +++ b/core/http/react-ui/bun.lock @@ -15,7 +15,6 @@ "@codemirror/view": "^6.36.8", "@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/geist-mono": "^5.2.7", - "@fortawesome/fontawesome-free": "^6.7.2", "@lezer/highlight": "^1.2.1", "@modelcontextprotocol/ext-apps": "^1.2.2", "@modelcontextprotocol/sdk": "^1.30.0", @@ -188,7 +187,6 @@ "@fontsource-variable/geist-mono": ["@fontsource-variable/geist-mono@5.2.7", "", {}, "sha512-ZKlZ5sjtalb2TwXKs400mAGDlt/+2ENLNySPx0wTz3bP3mWARCsUW+rpxzZc7e05d2qGch70pItt3K4qttbIYA=="], - "@fortawesome/fontawesome-free": ["@fortawesome/fontawesome-free@6.7.2", "", {}, "sha512-JUOtgFW6k9u4Y+xeIaEiLr3+cjoUPiAuLXoyKOJSia6Duzb7pq+A76P9ZdPDoAoxHdHzq6gE9/jKBGXlZT8FbA=="], "@gulpjs/to-absolute-glob": ["@gulpjs/to-absolute-glob@4.0.0", "", { "dependencies": { "is-negated-glob": "^1.0.0" } }, "sha512-kjotm7XJrJ6v+7knhPaRgaT6q8F8K2jiafwYdNHLzmV0uGLuZY43FK6smNSHUPrhq5kX2slCUy+RGG/xGqmIKA=="], diff --git a/core/http/react-ui/e2e/access-fixtures.js b/core/http/react-ui/e2e/access-fixtures.js new file mode 100644 index 000000000..c12bab615 --- /dev/null +++ b/core/http/react-ui/e2e/access-fixtures.js @@ -0,0 +1,156 @@ +// Fixtures for Users and keys, Account, sign-in and the invite page: auth +// status in each variant the server can answer, five users, invites, keys, +// quota rules and a month of usage, and one function that answers the calls +// those pages make and records the writes. +// +// Pure of Playwright imports so a script that is not a test can use it too. + +const iso = (offsetMs) => new Date(Date.now() + offsetMs).toISOString() +const DAY = 86_400_000 +const HOUR = 3_600_000 + +export const ADMIN = { id: 'u-alice', email: 'alice@lab.example', name: 'alice', role: 'admin', provider: 'local' } +export const MEMBER = { id: 'u-bob', email: 'bob@lab.example', name: 'bob', role: 'user', provider: 'github' } + +export function statusFixture(variant = 'admin', extra = {}) { + const base = { authEnabled: true, staticApiKeyRequired: false, providers: ['local', 'github'], hasUsers: true, registrationMode: 'approval', user: null } + const variants = { + admin: { user: { ...ADMIN, permissions: {} } }, + member: { user: { ...MEMBER, provider: 'local', permissions: { chat: true } } }, + oauthMember: { user: { ...MEMBER, permissions: { chat: true } } }, + signedOut: {}, + localOnly: { providers: ['local'] }, + oauthOnly: { providers: ['github', 'oidc'] }, + firstAdmin: { providers: ['local'], hasUsers: false, registrationMode: 'approval' }, + keyOnly: { authEnabled: false, staticApiKeyRequired: true, providers: [], hasUsers: false, registrationMode: '' }, + inviteOnly: { providers: ['local'], registrationMode: 'invite' }, + } + return { ...base, ...variants[variant], ...extra } +} + +export const FEATURES = { + api_features: [ + { key: 'chat', label: 'Chat completions', default: true }, + { key: 'images', label: 'Image generation', default: true }, + { key: 'audio_speech', label: 'Audio speech', default: true }, + { key: 'embeddings', label: 'Embeddings', default: true }, + ], + agent_features: [ + { key: 'agents', label: 'Agents', default: false }, + { key: 'skills', label: 'Skills', default: false }, + ], + general_features: [{ key: 'fine_tuning', label: 'Fine-tuning', default: false }], + models: ['qwen3-8b-instruct', 'bge-m3', 'kokoro-82m', 'gemma-3-12b-it'], +} + +const allOn = { chat: true, images: true, audio_speech: true, embeddings: true, agents: false, skills: false, fine_tuning: false } + +export function usersFixture() { + return [ + { id: 'u-alice', email: 'alice@lab.example', name: 'alice', role: 'admin', status: 'active', provider: 'local', createdAt: iso(-90 * DAY), permissions: allOn }, + { id: 'u-bob', email: 'bob@lab.example', name: 'bob', role: 'user', status: 'active', provider: 'github', createdAt: iso(-60 * DAY), + permissions: { ...allOn, agents: true, skills: true }, allowed_models: { enabled: true, models: ['qwen3-8b-instruct', 'bge-m3'] }, quotas: [] }, + { id: 'u-carol', email: 'carol@lab.example', name: 'carol', role: 'user', status: 'active', provider: 'oidc', createdAt: iso(-30 * DAY), + permissions: { ...allOn, agents: true }, allowed_models: { enabled: false, models: [] }, + quotas: [{ id: 'q1', model: '', max_requests: null, max_total_tokens: 2_000_000, window: '1d', current_requests: 0, current_total_tokens: 1_180_000 }] }, + { id: 'u-dave', email: 'dave@lab.example', name: 'dave', role: 'user', status: 'pending', provider: 'local', createdAt: iso(-1 * DAY), permissions: allOn, allowed_models: { enabled: false, models: [] } }, + { id: 'u-erin', email: 'erin@lab.example', name: '', role: 'user', status: 'disabled', provider: 'local', createdAt: iso(-120 * DAY), permissions: allOn, allowed_models: { enabled: false, models: [] } }, + ] +} + +export function invitesFixture() { + return [ + { id: 'i1', codePrefix: '3f9a1c2d', createdAt: iso(-1 * DAY), expiresAt: iso(6 * DAY), usedAt: null, usedBy: null, createdBy: { id: 'u-alice', name: 'alice' } }, + { id: 'i2', codePrefix: '77be0a14', createdAt: iso(-5 * DAY), expiresAt: iso(2 * DAY), usedAt: iso(-3 * DAY), usedBy: { id: 'u-dave', name: 'dave' }, createdBy: { id: 'u-alice', name: 'alice' } }, + { id: 'i3', codePrefix: '9c11d5e0', createdAt: iso(-9 * DAY), expiresAt: iso(-2 * DAY), usedAt: null, usedBy: null, createdBy: { id: 'u-alice', name: 'alice' } }, + ] +} + +export function keysFixture() { + return [ + { id: 'k1', name: 'ci-bot', keyPrefix: 'lai-3f9a1c', role: 'admin', createdAt: iso(-35 * DAY), lastUsed: iso(-2 * HOUR), disabled: false, expiresAt: iso(55 * DAY) }, + { id: 'k2', name: 'home-assistant', keyPrefix: 'lai-71bc09', role: 'admin', createdAt: iso(-270 * DAY), lastUsed: iso(-3 * DAY), disabled: true }, + { id: 'k3', name: 'notebook', keyPrefix: 'lai-a02d44', role: 'admin', createdAt: iso(-200 * DAY), lastUsed: null, disabled: false }, + ] +} + +export const QUOTAS = [ + { id: 'q1', model: '', max_requests: null, max_total_tokens: 2_000_000, window: '1d', current_requests: 0, current_total_tokens: 1_180_000, resets_at: iso(5 * HOUR) }, + { id: 'q2', model: '', max_requests: 300, max_total_tokens: null, window: '1h', current_requests: 212, current_total_tokens: 0, resets_at: iso(HOUR / 2) }, + { id: 'q3', model: 'qwen3-8b-instruct', max_requests: null, max_total_tokens: 1_100_000, window: '1d', current_requests: 0, current_total_tokens: 1_030_000, resets_at: iso(5 * HOUR) }, +] + +export function myUsageFixture() { + const usage = [] + const models = [['qwen3-8b-instruct', 181_000, 120], ['bge-m3', 44_000, 90], ['kokoro-82m', 31_000, 30], ['gemma-3-12b-it', 35_000, 35]] + for (const [model, total, requests] of models) { + usage.push({ bucket: '2026-10-07', model, prompt_tokens: Math.round(total * 0.74), completion_tokens: Math.round(total * 0.26), total_tokens: total, request_count: requests }) + } + const totals = usage.reduce((t, b) => ({ + prompt_tokens: t.prompt_tokens + b.prompt_tokens, completion_tokens: t.completion_tokens + b.completion_tokens, + total_tokens: t.total_tokens + b.total_tokens, request_count: t.request_count + b.request_count, + }), { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, request_count: 0 }) + return { usage, totals } +} + +// Answers the auth calls the pages make. `writes` collects every call that +// changes something, as { method, path, body }. Pass `status` to pick the +// auth status variant. +export async function mockAccess(page, { status = 'admin', statusExtra = {}, users = usersFixture(), invites = invitesFixture(), keys = keysFixture(), quotas = QUOTAS } = {}) { + const state = { writes: [], users, invites, keys, quotas, failNext: null } + const body = (route) => { try { return route.request().postDataJSON() } catch { return null } } + const record = (route, path) => state.writes.push({ method: route.request().method(), path, body: body(route) }) + const json = (route, data, status = 200) => route.fulfill({ status, json: data }) + + await page.route('**/api/auth/status', route => json(route, statusFixture(status, statusExtra))) + await page.route('**/api/auth/me', route => json(route, { user: statusFixture(status).user })) + await page.route('**/api/auth/quota', route => json(route, { quotas: state.quotas })) + await page.route('**/api/auth/usage?*', route => json(route, myUsageFixture())) + await page.route('**/api/auth/admin/features', route => json(route, FEATURES)) + await page.route('**/api/auth/admin/users', route => json(route, { users: state.users })) + await page.route('**/api/auth/admin/users/*/**', async route => { + const req = route.request() + const path = new URL(req.url()).pathname + if (req.method() === 'GET' && path.endsWith('/quotas')) return json(route, state.quotas.slice(0, 1)) + record(route, path) + if (state.failNext) { const error = state.failNext; state.failNext = null; return json(route, { error }, 400) } + return json(route, { message: 'ok' }) + }) + await page.route('**/api/auth/admin/users/*', async route => { + const req = route.request() + if (req.method() === 'GET') return route.fallback() + record(route, new URL(req.url()).pathname) + return json(route, { message: 'ok' }) + }) + await page.route('**/api/auth/admin/invites', async route => { + if (route.request().method() === 'POST') { + record(route, '/api/auth/admin/invites') + return json(route, { id: 'i-new', code: 'c0ffee00c0ffee00c0ffee00c0ffee00', expiresAt: iso(7 * DAY), createdAt: iso(0) }, 201) + } + return json(route, { invites: state.invites }) + }) + await page.route('**/api/auth/admin/invites/*', async route => { + record(route, new URL(route.request().url()).pathname) + return json(route, { message: 'ok' }) + }) + await page.route('**/api/auth/api-keys', async route => { + if (route.request().method() === 'POST') { + record(route, '/api/auth/api-keys') + return json(route, { key: 'lai-9d41f0c27be84a0c92aa5d6b', id: 'k-new', name: body(route)?.name || 'key', keyPrefix: 'lai-9d41f0', role: 'user', createdAt: iso(0) }, 201) + } + return json(route, { keys: state.keys }) + }) + await page.route('**/api/auth/api-keys/*', async route => { + const req = route.request() + record(route, new URL(req.url()).pathname) + if (req.method() === 'DELETE') state.keys = state.keys.filter(k => !req.url().endsWith(`/${k.id}`)) + return json(route, { message: 'ok' }) + }) + await page.route('**/api/auth/profile', async route => { record(route, '/api/auth/profile'); return json(route, { message: 'ok' }) }) + await page.route('**/api/auth/password', async route => { + record(route, '/api/auth/password') + if (state.failNext) { const e = state.failNext; state.failNext = null; return json(route, e, 400) } + return json(route, { message: 'ok' }) + }) + return state +} diff --git a/core/http/react-ui/e2e/account-api-key-pause.spec.js b/core/http/react-ui/e2e/account-api-key-pause.spec.js index 149a9cb85..8dde161aa 100644 --- a/core/http/react-ui/e2e/account-api-key-pause.spec.js +++ b/core/http/react-ui/e2e/account-api-key-pause.spec.js @@ -44,7 +44,7 @@ test.describe('Account API keys pause', () => { }) await page.goto('/app/account') - await page.getByRole('button', { name: /API Keys/ }).click() + await page.getByRole('tab', { name: 'API keys' }).click() }) test('shows paused badges and resume buttons', async ({ page }) => { diff --git a/core/http/react-ui/e2e/account-page.spec.js b/core/http/react-ui/e2e/account-page.spec.js new file mode 100644 index 000000000..de86b62b8 --- /dev/null +++ b/core/http/react-ui/e2e/account-page.spec.js @@ -0,0 +1,133 @@ +import { test, expect } from './coverage-fixtures.js' +import { mockAccess } from './access-fixtures.js' + +test.describe('Account', () => { + let state + test.beforeEach(async ({ page }) => { + state = await mockAccess(page) + await page.goto('/app/account') + await expect(page.getByTestId('account-profile')).toBeVisible() + }) + + test('has four tabs, opens on the profile and keeps the tab in the address', async ({ page }) => { + const tabs = await page.getByRole('tab').allTextContents() + expect(tabs).toEqual(['Profile', 'Security', 'API keys', 'Usage']) + await expect(page.getByRole('tab', { name: 'Profile' })).toHaveAttribute('aria-selected', 'true') + await page.getByRole('tab', { name: 'Usage' }).click() + await expect(page).toHaveURL(/tab=usage/) + await page.goBack().catch(() => {}) + await page.goto('/app/account?tab=keys') + await expect(page.getByTestId('api-keys-panel')).toBeVisible() + }) + + test('profile saves the name and the picture address', async ({ page }) => { + const save = page.getByRole('button', { name: 'Save' }) + await expect(save).toBeDisabled() + await page.getByLabel('Display name').fill('Alice L') + await page.getByLabel('Avatar URL').fill('https://example.org/a.png') + await expect(save).toBeEnabled() + await save.click() + await expect(page.getByText('Profile updated')).toBeVisible() + expect(state.writes[0]).toMatchObject({ method: 'PUT', path: '/api/auth/profile', body: { name: 'Alice L', avatar_url: 'https://example.org/a.png' } }) + await expect(page.getByTestId('account-profile')).toContainText('alice@lab.example') + await expect(page.getByTestId('account-profile')).toContainText('admin · local sign-in') + }) + + test('security changes a local password and checks the confirmation', async ({ page }) => { + await page.getByRole('tab', { name: 'Security' }).click() + const form = page.getByTestId('account-security') + await form.getByLabel('Current password').fill('old-password-123') + await form.getByLabel('New password').fill('new-password-1234') + await form.getByLabel('Confirm password').fill('different-1234') + await form.getByRole('button', { name: 'Change password' }).click() + await expect(page.getByText('Passwords do not match')).toBeVisible() + expect(state.writes).toEqual([]) + await form.getByLabel('Confirm password').fill('new-password-1234') + await form.getByRole('button', { name: 'Change password' }).click() + await expect(page.getByText('Password changed')).toBeVisible() + expect(state.writes[0].body).toEqual({ current_password: 'old-password-123', new_password: 'new-password-1234', acknowledge_weak_password: false }) + await expect(form.getByLabel('Current password')).toHaveValue('') + }) + + test('a weak password can be sent anyway after the person says so', async ({ page }) => { + state.failNext = { error: 'Password is weak', overridable: true } + await page.getByRole('tab', { name: 'Security' }).click() + const form = page.getByTestId('account-security') + await form.getByLabel('Current password').fill('old-password-123') + await form.getByLabel('New password').fill('password1234') + await form.getByLabel('Confirm password').fill('password1234') + await form.getByRole('button', { name: 'Change password' }).click() + await expect(form.getByRole('alert')).toContainText('Password is weak') + await form.getByLabel('Use this password anyway').check() + await form.getByRole('button', { name: 'Change password' }).click() + await expect(page.getByText('Password changed')).toBeVisible() + expect(state.writes[1].body.acknowledge_weak_password).toBe(true) + }) + + test('usage shows the last 30 days, tokens by model and the limits an admin set', async ({ page }) => { + await page.getByRole('tab', { name: 'Usage' }).click() + const usage = page.getByTestId('account-usage') + await expect(usage.getByTestId('usage-requests')).toHaveText('275') + await expect(usage).toContainText('Tokens in') + await expect(usage.getByRole('list', { name: 'Tokens by model' }).locator('li')).toHaveCount(4) + await expect(usage.getByRole('list', { name: 'Tokens by model' }).locator('li').first()).toContainText('qwen3-8b-instruct') + const limits = page.getByTestId('account-limits').locator('li') + await expect(limits).toHaveCount(3) + await expect(limits.first()).toContainText('All models, tokens per day') + await expect(limits.first()).toContainText('1.2M of 2M') + await expect(limits.nth(1)).toContainText('requests per hour') + await expect(limits.nth(2).getByRole('progressbar')).toHaveAttribute('aria-valuenow', '94') + await expect(usage.getByRole('link', { name: 'Open full usage' })).toHaveAttribute('href', '/app/usage') + }) +}) + +test.describe('Account: other people and states', () => { + test('a person who signs in with GitHub is told the password is not theirs to change', async ({ page }) => { + await mockAccess(page, { status: 'oauthMember' }) + await page.goto('/app/account?tab=security') + await expect(page.getByTestId('account-oauth-only')).toContainText('not available for github accounts') + await expect(page.getByLabel('Current password')).toHaveCount(0) + }) + + test('a person who is not an admin sees their own account, usage and limits', async ({ page }) => { + await mockAccess(page, { status: 'member' }) + await page.goto('/app/account?tab=usage') + await expect(page.getByTestId('account-usage')).toBeVisible() + await expect(page.getByTestId('account-page')).toContainText('bob · user') + await expect(page.locator('.sidebar-nav a.nav-item', { hasText: 'Operate' })).toHaveCount(0) + }) + + test('without authentication there is no account', async ({ page }) => { + await page.route('**/api/auth/status', route => route.fulfill({ json: { authEnabled: false, staticApiKeyRequired: false, providers: [] } })) + await page.goto('/app/account') + await expect(page.getByText('Account unavailable')).toBeVisible() + }) + + test('the usage tab says so when the call fails', async ({ page }) => { + await mockAccess(page) + await page.route('**/api/auth/usage?*', route => route.fulfill({ status: 500, json: { error: 'boom' } })) + await page.goto('/app/account?tab=usage') + await expect(page.getByRole('alert')).toContainText('Failed to load your usage') + }) + + test('a person with no limits and no requests sees calm empty states', async ({ page }) => { + await mockAccess(page, { quotas: [] }) + await page.route('**/api/auth/usage?*', route => route.fulfill({ json: { usage: [], totals: {} } })) + await page.goto('/app/account?tab=usage') + await expect(page.getByText('No requests from you in the last 30 days.')).toBeVisible() + await expect(page.getByText('No limits are set on your account.')).toBeVisible() + }) +}) + +test.describe('Account: phone', () => { + test.use({ viewport: { width: 390, height: 844 } }) + test('every tab fits the screen', async ({ page }) => { + await mockAccess(page) + for (const tab of ['profile', 'security', 'keys', 'usage']) { + await page.goto(`/app/account?tab=${tab}`) + await expect(page.getByRole('tabpanel')).toBeVisible() + await page.waitForTimeout(150) + expect(await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth), tab).toBe(false) + } + }) +}) diff --git a/core/http/react-ui/e2e/activity-page.spec.js b/core/http/react-ui/e2e/activity-page.spec.js index 09f9b46fe..0f1ddc435 100644 --- a/core/http/react-ui/e2e/activity-page.spec.js +++ b/core/http/react-ui/e2e/activity-page.spec.js @@ -40,7 +40,139 @@ test('lists live operations and cancels one from a labelled button', async ({ pa await expect(card).toContainText('22%') await card.locator('.operation-card__cancel').click() - expect(cancelledPath).toBe('/api/operations/job-gemma/cancel') + // A cancel waits for its undo window. Nothing has been stopped yet. + await expect(page.getByTestId('activity-undo-toast')).toContainText('Cancelling gemma-3-27b-it') + await expect(card).toContainText('Cancelling unless you undo') + expect(cancelledPath).toBe('') + await expect.poll(() => cancelledPath, { timeout: 15_000 }).toBe('/api/operations/job-gemma/cancel') +}) + +const download = (id, over = {}) => ({ + id, + name: id, + jobID: `job-${id}`, + progress: 22, + taskType: 'installation', + isBackend: false, + isQueued: false, + isDeletion: false, + cancellable: true, + phase: 'downloading', + ...over, +}) + +test('undoing a cancel keeps the download running and never calls the server', async ({ page }) => { + await stub(page, { operations: [download('gemma-3-27b-it')] }) + const calls = [] + await page.route('**/api/operations/job-gemma-3-27b-it/*', (route) => { + calls.push(new URL(route.request().url()).pathname) + return route.fulfill({ contentType: 'application/json', body: '{}' }) + }) + await page.goto('/app/activity') + + const card = page.locator('.operation-card').filter({ hasText: 'gemma-3-27b-it' }) + await card.locator('.operation-card__cancel').click() + await page.getByTestId('activity-undo-toast').getByRole('button', { name: 'Undo' }).click() + + await expect(page.getByTestId('activity-undo-toast')).toHaveCount(0) + await expect(card.locator('.operation-card__cancel')).toBeVisible() + await expect(card).not.toContainText('Cancelling unless you undo') + // The window is 8 seconds; wait it out to be sure nothing was queued. + await page.waitForTimeout(9_000) + expect(calls).toEqual([]) +}) + +test('closing the undo toast cancels at once', async ({ page }) => { + await stub(page, { operations: [download('gemma-3-27b-it')] }) + const calls = [] + await page.route('**/api/operations/job-gemma-3-27b-it/cancel', (route) => { + calls.push('cancel') + return route.fulfill({ contentType: 'application/json', body: '{}' }) + }) + await page.goto('/app/activity') + await page.locator('.operation-card__cancel').click() + await page.getByTestId('activity-undo-toast').getByRole('button', { name: 'Cancel now' }).click() + await expect.poll(() => calls).toEqual(['cancel']) +}) + +test('cancelling a second download ends the first one\'s window', async ({ page }) => { + await stub(page, { operations: [download('alpha'), download('beta')] }) + const calls = [] + await page.route('**/api/operations/*/cancel', (route) => { + calls.push(new URL(route.request().url()).pathname) + return route.fulfill({ contentType: 'application/json', body: '{}' }) + }) + await page.goto('/app/activity') + await page.locator('.operation-card').filter({ hasText: 'alpha' }).locator('.operation-card__cancel').click() + await page.locator('.operation-card').filter({ hasText: 'beta' }).locator('.operation-card__cancel').click() + // One toast at a time, and the first cancel went through. + await expect(page.getByTestId('activity-undo-toast')).toHaveCount(1) + await expect(page.getByTestId('activity-undo-toast')).toContainText('Cancelling beta') + await expect.poll(() => calls).toEqual(['/api/operations/job-alpha/cancel']) +}) + +test('leaving the page runs a cancel that was still waiting', async ({ page }) => { + await stub(page, { operations: [download('gemma-3-27b-it')] }) + const calls = [] + await page.route('**/api/operations/job-gemma-3-27b-it/cancel', (route) => { + calls.push('cancel') + return route.fulfill({ contentType: 'application/json', body: '{}' }) + }) + await page.goto('/app/activity') + await page.locator('.operation-card__cancel').click() + await expect(page.getByTestId('activity-undo-toast')).toBeVisible() + await page.getByRole('link', { name: 'Backends', exact: true }).first().click() + await expect.poll(() => calls).toEqual(['cancel']) +}) + +test('a download shows its progress, its size and the time left', async ({ page }) => { + await stub(page, { + operations: [download('gemma-3-27b-it', { progress: 38, currentBytes: 1.4 * 1024 ** 3, totalBytes: 3.8 * 1024 ** 3 })], + }) + await page.goto('/app/activity') + const card = page.locator('.operation-card').filter({ hasText: 'gemma-3-27b-it' }) + await expect(card.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '38') + await expect(card).toContainText('1.4 GB / 3.8 GB') + await expect(card.getByRole('button', { name: 'Pause gemma-3-27b-it and keep downloaded data' })).toBeVisible() +}) + +test('a cancelled install can be started again from the record', async ({ page }) => { + await stub(page, { + history: [{ + id: 'whisper-large-v3', name: 'whisper-large-v3', jobID: 'job-w', isBackend: false, taskType: 'installation', + outcome: 'cancelled', startedAt: '2026-07-28T13:40:00Z', finishedAt: '2026-07-28T13:41:00Z', + }, { + id: 'piper', name: 'piper', jobID: 'job-p', isBackend: true, taskType: 'deletion', + outcome: 'cancelled', startedAt: '2026-07-28T13:40:00Z', finishedAt: '2026-07-28T13:41:00Z', + }], + }) + const calls = [] + await page.route('**/api/models/install/**', (route) => { + calls.push(new URL(route.request().url()).pathname) + return route.fulfill({ contentType: 'application/json', body: '{}' }) + }) + await page.goto('/app/activity') + // A cancelled removal is not an install, so it offers nothing to start. + await expect(page.locator('.activity-row__resume')).toHaveCount(1) + await page.locator('.activity-row').filter({ hasText: 'whisper-large-v3' }).locator('.activity-row__resume').click() + await expect.poll(() => calls).toEqual(['/api/models/install/whisper-large-v3']) +}) + +test('the empty state names the next step', async ({ page }) => { + await stub(page) + await page.goto('/app/activity') + await expect(page.locator('.activity-empty')).toContainText('No operations since startup') + await expect(page.locator('.activity-empty').getByRole('link', { name: 'Browse models' })).toHaveAttribute('href', '/app/models') +}) + +test('fits a phone, with the actions under the name', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 800 }) + await stub(page, { operations: [download('gemma-3-27b-it')] }) + await page.goto('/app/activity') + const card = page.locator('.operation-card').filter({ hasText: 'gemma-3-27b-it' }) + await expect(card.locator('.operation-card__cancel')).toBeVisible() + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) + expect(overflow).toBeLessThanOrEqual(1) }) test('pauses a model download without invoking destructive cancel', async ({ page }) => { diff --git a/core/http/react-ui/e2e/add-node.spec.js b/core/http/react-ui/e2e/add-node.spec.js new file mode 100644 index 000000000..ace0f3e5b --- /dev/null +++ b/core/http/react-ui/e2e/add-node.spec.js @@ -0,0 +1,163 @@ +import { test, expect } from './coverage-fixtures.js' +import { mockSwarm, clusterNodes } from './swarm-fixtures.js' + +// "Add a node": which way the machine joins, the one command to run on it, and +// a live line that says when it arrived. "Found" is a real node that was not in +// the roster when the page opened, never a timer. + +const command = page => page.getByTestId('command-block').last() + +test.describe('Add a node', () => { + test('starts on a registered worker with a copyable command that carries no secret', async ({ page }) => { + await mockSwarm(page) + await page.goto('/app/nodes/add') + await expect(page.getByRole('heading', { name: 'Add a node' })).toBeVisible({ timeout: 15_000 }) + await expect(page.getByRole('radio', { name: 'Registered worker' })).toHaveAttribute('aria-checked', 'true') + await expect(command(page)).toContainText('LOCALAI_REGISTER_TO="http://127.0.0.1:8089"') + await expect(command(page)).toContainText('LOCALAI_REGISTRATION_TOKEN="$TOKEN"') + await expect(command(page)).toContainText('localai/localai:latest-cpu worker') + await expect(page.getByText('This page never shows it.')).toBeVisible() + await expect(page.getByRole('link', { name: 'All nodes' })).toHaveAttribute('href', '/app/nodes') + // A cluster needs no step for turning distributed mode on. + await expect(page.getByText('Turn on distributed mode first')).toHaveCount(0) + }) + + test('changes the command with the hardware, the way to run it and the worker type', async ({ page }) => { + await mockSwarm(page) + await page.goto('/app/nodes/add') + await page.getByRole('radio', { name: 'CUDA 12', exact: true }).click() + await expect(command(page)).toContainText('--gpus all') + await expect(command(page)).toContainText('latest-gpu-nvidia-cuda-12 worker') + await page.getByRole('radio', { name: 'Agent worker' }).click() + await expect(command(page)).toContainText('latest-gpu-nvidia-cuda-12 agent-worker') + await page.getByRole('button', { name: 'Dev' }).click() + await expect(command(page)).toContainText('master-gpu-nvidia-cuda-12 agent-worker') + await page.getByRole('radio', { name: 'CLI' }).click() + await expect(command(page)).toContainText('local-ai agent-worker') + await expect(command(page)).toContainText('--registration-token "$LOCALAI_REGISTRATION_TOKEN"') + // Hardware means nothing to a plain command. + await expect(page.getByRole('radio', { name: 'CUDA 12', exact: true })).toHaveCount(0) + }) + + test('copies the command and says so', async ({ page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']).catch(() => {}) + await mockSwarm(page) + await page.goto('/app/nodes/add') + const copy = page.getByRole('button', { name: 'Copy command' }).last() + await copy.click() + await expect(copy).toContainText('Copied') + const text = await command(page).locator('code').innerText() + expect(text).toContain('LOCALAI_REGISTER_TO') + }) + + test('waits for a worker, then shows it when one registers, and approves it', async ({ page }) => { + let polls = 0 + const known = clusterNodes() + const arrived = { id: 'n-new', name: 'gpu-box-9', node_type: 'backend', address: '10.0.4.19:50051', status: 'pending', version: 'v3.9.0', labels: {}, last_heartbeat: new Date().toISOString() } + const log = await mockSwarm(page, { onNodes: () => { polls += 1; return polls < 3 ? known : [...known, arrived] } }) + await page.goto('/app/nodes/add') + + const wait = page.getByRole('status').filter({ hasText: 'Listening for a worker' }) + await expect(wait).toBeVisible({ timeout: 15_000 }) + await expect(page.getByTestId('found-node')).toHaveCount(0) + + const found = page.getByTestId('found-node') + await expect(found).toBeVisible({ timeout: 20_000 }) + await expect(found).toContainText('gpu-box-9') + await expect(found).toContainText('Waiting for approval') + await expect(page.getByRole('heading', { name: 'A machine arrived' })).toBeVisible() + await expect(wait).toHaveCount(0) + await expect(found.getByRole('link', { name: 'Open node' })).toHaveAttribute('href', '/app/nodes/n-new') + + await found.getByRole('button', { name: 'Approve' }).click() + await expect.poll(() => log.filter(entry => entry.path === '/api/nodes/n-new/approve').length).toBe(1) + await expect(page.getByText('gpu-box-9 approved')).toBeVisible() + }) + + test('does not treat the nodes it already knew as arrivals', async ({ page }) => { + await mockSwarm(page) + await page.goto('/app/nodes/add') + await expect(page.getByRole('status').filter({ hasText: 'Listening for a worker' })).toBeVisible({ timeout: 15_000 }) + await page.waitForTimeout(3500) + await expect(page.getByTestId('found-node')).toHaveCount(0) + }) + + test('a single install starts with turning distributed mode on, as text it can copy', async ({ page }) => { + await mockSwarm(page, { nodes: null, features: { distributed: false } }) + await page.goto('/app/nodes/add') + await expect(page.getByRole('heading', { name: 'Turn on distributed mode first' })).toBeVisible({ timeout: 15_000 }) + await expect(page.getByTestId('command-block').first()).toContainText('local-ai run --distributed') + await expect(page.getByTestId('command-block').first()).toContainText('--distributed-nats') + await expect(page.getByRole('link', { name: 'distributed mode documentation' })).toHaveAttribute('href', 'https://localai.io/features/distributed-mode/') + await expect(page.getByRole('heading', { name: 'Run this on the new machine' })).toBeVisible() + }) + + test.describe('over P2P', () => { + const p2p = { token: 'net-token-1', stats: { federated: { online: 1, total: 1 }, llama_cpp_workers: { online: 0, total: 0 }, mlx_workers: { online: 0, total: 0 } } } + + test('a peer instance joins with the network token', async ({ page }) => { + await mockSwarm(page, { p2p }) + await page.goto('/app/nodes/add?join=peer') + await expect(page.getByRole('radio', { name: 'Peer instance' })).toHaveAttribute('aria-checked', 'true', { timeout: 15_000 }) + await expect(command(page)).toContainText('-e TOKEN="net-token-1"') + await expect(command(page)).toContainText('run --federated --p2p') + await expect(page.getByText('1 peers online')).toBeVisible() + }) + + test('a memory shard offers llama.cpp RPC and MLX', async ({ page }) => { + await mockSwarm(page, { p2p }) + await page.goto('/app/nodes/add?join=shard') + await expect(command(page)).toContainText('worker p2p-llama-cpp-rpc', { timeout: 15_000 }) + await page.getByRole('radio', { name: 'MLX (Apple Silicon)' }).click() + await expect(command(page)).toContainText('worker p2p-mlx') + await expect(command(page)).toContainText('latest-metal-darwin-arm64') + // MLX runs on a Mac: GPU build targets do not apply. + await expect(page.getByRole('radio', { name: 'CUDA 12', exact: true })).toHaveCount(0) + }) + + test('says to turn P2P on first when it is off', async ({ page }) => { + await mockSwarm(page) + await page.goto('/app/nodes/add?join=peer') + await expect(page.getByRole('heading', { name: 'Turn on P2P first' })).toBeVisible({ timeout: 15_000 }) + await expect(page.getByTestId('command-block').first()).toContainText('run --p2p') + await expect(page.getByTestId('command-block').last()).toContainText('your-token-here') + }) + + test('shows a new peer when the online count goes up', async ({ page }) => { + let calls = 0 + await mockSwarm(page) + await page.route('**/api/p2p/token', route => route.fulfill({ status: 200, contentType: 'text/plain', body: 'net-token-1' })) + await page.route('**/api/p2p/stats', route => { + calls += 1 + const online = calls < 3 ? 1 : 2 + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ federated: { online, total: 2 }, llama_cpp_workers: { online: 0, total: 0 }, mlx_workers: { online: 0, total: 0 } }) }) + }) + await page.goto('/app/nodes/add?join=peer') + await expect(page.getByTestId('found-peer')).toContainText('A new peer joined: 2 online', { timeout: 20_000 }) + }) + }) + + test('first run of a cluster carries the same steps in place', async ({ page }) => { + await mockSwarm(page, { nodes: [] }) + await page.goto('/app/nodes') + await expect(page.getByTestId('swarm-first-run')).toBeVisible({ timeout: 15_000 }) + await expect(page.getByRole('heading', { name: 'How should it join?' })).toBeVisible() + await expect(command(page)).toContainText('LOCALAI_REGISTER_TO') + }) + + test('a phone scrolls the command sideways instead of the page', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await mockSwarm(page) + await page.goto('/app/nodes/add') + const block = command(page).locator('pre') + await expect(block).toBeVisible({ timeout: 15_000 }) + const { scrollWidth, clientWidth } = await block.evaluate(el => ({ scrollWidth: el.scrollWidth, clientWidth: el.clientWidth })) + expect(scrollWidth).toBeGreaterThan(clientWidth) + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390) + const copy = await page.getByRole('button', { name: 'Copy command' }).last().boundingBox() + expect(copy.x + copy.width).toBeLessThanOrEqual(390) + // The method switch scrolls too; none of its choices is cut off for good. + await page.getByRole('radio', { name: 'Memory shard' }).scrollIntoViewIfNeeded() + await expect(page.getByRole('radio', { name: 'Memory shard' })).toBeInViewport() + }) +}) diff --git a/core/http/react-ui/e2e/admin-console.spec.js b/core/http/react-ui/e2e/admin-console.spec.js index 6bd459feb..7a50d60a7 100644 --- a/core/http/react-ui/e2e/admin-console.spec.js +++ b/core/http/react-ui/e2e/admin-console.spec.js @@ -1,30 +1,33 @@ import { test, expect } from './coverage-fixtures.js' test.describe('Admin console', () => { - test('admin pages render inside the grouped console rail', async ({ page }) => { + test('admin pages render under the Operate tab bar', async ({ page }) => { await page.goto('/app/backends') - const rail = page.locator('.console-rail') - await expect(rail).toBeVisible() - // Four groups since the overview landed: Inference folded into Runtime - // (both are "the runtime right now"), Access and System into Administration. - for (const group of ['Runtime', 'Cluster', 'Observability', 'Administration']) { - await expect(rail.locator('.console-group-title', { hasText: group })).toBeVisible() + const bar = page.locator('.dk-hubtabs') + await expect(bar).toBeVisible() + // Six tabs for the single-node case; Swarm only appears with distributed + // mode on. The tab that owns the current page carries aria-current. + for (const name of ['Status', 'This machine', 'Runtime', 'Traffic', 'Settings']) { + await expect(bar.getByRole('link', { name, exact: false })).toBeVisible() } + await expect(bar.locator('[data-hub-tab="runtime"]')).toHaveAttribute('aria-current', 'page') }) - test('console rail cross-navigates between admin pages', async ({ page }) => { + test('the tab bar cross-navigates between admin pages', async ({ page }) => { await page.goto('/app/backends') - const settings = page.locator('.console-rail a.nav-item[href="/app/settings"]') + const settings = page.locator('.dk-hubtabs a[href="/app/settings"]') await expect(settings).toBeVisible() await settings.click() await expect(page).toHaveURL(/\/app\/settings/) - // Rail persists across admin navigation (layout route, not per-page chrome) - await expect(page.locator('.console-rail')).toBeVisible() + // The bar persists across admin navigation (layout route, not per-page chrome) + await expect(page.locator('.dk-hubtabs')).toBeVisible() + await expect(page.locator('.dk-hubtabs [data-hub-tab="settings"]')).toHaveAttribute('aria-current', 'page') }) - test('rail links to external API docs', async ({ page }) => { + test('the bar links to the external API docs', async ({ page }) => { await page.goto('/app/settings') - const api = page.locator('.console-rail a[href$="/swagger/index.html"]') + const api = page.locator('.dk-hubtabs a[href$="/swagger/index.html"]') await expect(api).toBeVisible() + await expect(api).toHaveAttribute('target', '_blank') }) }) diff --git a/core/http/react-ui/e2e/agent-jobs-run.spec.js b/core/http/react-ui/e2e/agent-jobs-run.spec.js new file mode 100644 index 000000000..86483f640 --- /dev/null +++ b/core/http/react-ui/e2e/agent-jobs-run.spec.js @@ -0,0 +1,101 @@ +import { test, expect } from './coverage-fixtures.js' +import { mockJobs } from './jobs-fixtures.js' + +test.describe('A job run', () => { + test('a finished job reads as a document: task, outcome, delivery and steps', async ({ page }) => { + await mockJobs(page) + await page.goto('/app/agent-jobs/jobs/job-done-0002') + const doc = page.getByTestId('job-page') + await expect(doc.getByRole('heading', { level: 1 })).toContainText('repo-triage, today') + await expect(doc).toContainText('Done') + await expect(doc).toContainText('Started by the schedule') + await expect(doc).toContainText('Took 52 s') + await expect(doc).toContainText('job-done-0002') + await expect(doc.getByRole('heading', { name: 'Task' })).toBeVisible() + await expect(doc.locator('.aj-prompt')).toContainText('Read the new issues in example/app and label each one.') + await expect(doc.getByRole('heading', { name: 'Outcome' })).toBeVisible() + await expect(doc).toContainText('Labelled 12 issues and routed 3 of them to the person on call.') + await expect(doc.locator('.cx-prose table')).toBeVisible() + await expect(doc.getByTestId('job-webhook')).toContainText('Webhook delivered') + await expect(doc.getByRole('button', { name: 'Run again' })).toBeEnabled() + await expect(doc.getByRole('link', { name: 'Open task' })).toHaveAttribute('href', '/app/agent-jobs/tasks/t2') + }) + + test('the steps the server recorded are rows that open into what they hold', async ({ page }) => { + await mockJobs(page) + await page.goto('/app/agent-jobs/jobs/job-done-0002') + const rows = page.getByTestId('job-traces').locator('li') + await expect(rows).toHaveCount(2) + await expect(rows.nth(1)).toContainText('list_issues') + await expect(rows.nth(1)).toContainText('Tool call') + await expect(rows.nth(1).locator('.aj-trace__body')).toHaveCount(0) + await rows.nth(1).getByRole('button').click() + await expect(rows.nth(1).locator('.aj-trace__body')).toContainText('example/app') + await expect(rows.nth(1).locator('.aj-trace__body')).toContainText('Listing issues') + }) + + test('a job with no recorded steps says so', async ({ page }) => { + await mockJobs(page) + await page.goto('/app/agent-jobs/jobs/job-done-0004') + await expect(page.getByText('The server recorded no steps for this job.')).toBeVisible() + await expect(page.getByTestId('job-traces')).toHaveCount(0) + }) + + test('a failed job says what happened in plain words and offers one next action', async ({ page }) => { + const { seen } = await mockJobs(page) + await page.goto('/app/agent-jobs/jobs/job-fail-0001') + const fail = page.getByTestId('job-failed') + await expect(fail).toContainText('This job failed.') + await expect(fail).toContainText('read_page: timeout after 20 s (3 of 3 attempts)') + await expect(page.locator('.aj-prompt .aj-gap[data-filled]')).toHaveCount(3) + await fail.getByRole('button', { name: 'Run again now' }).click() + expect(seen.jobExecutes).toEqual([{ task_id: 't1', parameters: { topic: 'local inference', format: 'bullet points', items: '8' } }]) + await expect(page).toHaveURL(/\/app\/agent-jobs\/jobs\/job-new-0006$/) + await expect(page.getByTestId('job-working')).toContainText('waiting to start') + }) + + test('a running job says it is working and Cancel stops it', async ({ page }) => { + const { seen } = await mockJobs(page) + await page.goto('/app/agent-jobs/jobs/job-run-0003') + await expect(page.getByTestId('job-working')).toContainText('still running') + await expect(page.getByRole('button', { name: 'Run again' })).toHaveCount(0) + await page.getByRole('button', { name: 'Cancel' }).click() + await expect(page.getByText('Job cancelled')).toBeVisible() + expect(seen.cancels).toEqual(['job-run-0003']) + await expect(page.getByText('This job was cancelled before it finished.')).toBeVisible({ timeout: 6000 }) + }) + + test('does not invent a duration when the job has no end time', async ({ page }) => { + await mockJobs(page) + await page.goto('/app/agent-jobs/jobs/job-stop-0005') + await expect(page.getByTestId('job-page')).not.toContainText('Took') + await expect(page.getByTestId('job-page')).toContainText('Cancelled') + await expect(page.getByTestId('job-page')).toContainText('Started by the API') + }) + + test('says so when the job does not exist', async ({ page }) => { + await mockJobs(page) + await page.goto('/app/agent-jobs/jobs/nope') + await expect(page.getByTestId('job-missing')).toContainText('Job not found') + }) + + test('copies its address', async ({ page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']) + await mockJobs(page) + await page.goto('/app/agent-jobs/jobs/job-done-0002') + await page.getByRole('button', { name: 'Copy link' }).click() + await expect(page.getByText('Link copied')).toBeVisible() + }) +}) + +test.describe('A job run on a phone', () => { + test.use({ viewport: { width: 390, height: 844 } }) + + test('fits the width', async ({ page }) => { + await mockJobs(page) + await page.goto('/app/agent-jobs/jobs/job-fail-0001') + await expect(page.getByTestId('job-failed')).toBeVisible() + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth) + expect(overflow).toBeLessThanOrEqual(0) + }) +}) diff --git a/core/http/react-ui/e2e/agent-jobs-task.spec.js b/core/http/react-ui/e2e/agent-jobs-task.spec.js new file mode 100644 index 000000000..6fb9793f1 --- /dev/null +++ b/core/http/react-ui/e2e/agent-jobs-task.spec.js @@ -0,0 +1,227 @@ +import { test, expect } from './coverage-fixtures.js' +import { mockJobs } from './jobs-fixtures.js' + +test.describe('A task', () => { + test('shows what it is: model, schedule in words, runs, prompt with its gaps', async ({ page }) => { + await mockJobs(page) + await page.goto('/app/agent-jobs/tasks/t1') + const facts = page.getByTestId('task-facts') + await expect(page.getByRole('heading', { name: 'daily-digest' })).toBeVisible() + await expect(facts).toContainText('qwen3-8b-instruct') + await expect(facts).toContainText('Every day at 07:00') + await expect(facts).toContainText('0 7 * * *') + await expect(facts).toContainText('1 webhook') + await expect(facts.getByRole('switch')).toHaveAttribute('aria-checked', 'true') + const runs = page.getByTestId('task-runs') + await expect(runs.locator('a')).toHaveCount(2) + await expect(runs.locator('a').first()).toContainText('read_page: timeout after 20 s') + await expect(runs.locator('a').first()).toContainText('Started by the schedule, took 1 min 06 s') + const gaps = page.locator('.aj-prompt .aj-gap') + await expect(gaps).toHaveCount(3) + await expect(gaps.first()).toHaveText('local inference') + await expect(page.getByText('Audience: engineers who run models on their own hardware.')).toBeVisible() + await expect(page.getByText('Times follow the clock of the machine that runs LocalAI.')).toBeVisible() + await expect(page.locator('.dk-hubtabs [data-hub-tab="jobs"]')).toHaveAttribute('aria-current', 'page') + }) + + test('a task with no schedule says it runs when started; the API examples stay one click away', async ({ page }) => { + await mockJobs(page) + await page.goto('/app/agent-jobs/tasks/t4') + await expect(page.getByText('This task has no schedule.')).toBeVisible() + await expect(page.getByTestId('task-facts').getByRole('switch')).toHaveAttribute('aria-checked', 'false') + await expect(page.getByText('This task has not run yet.')).toBeVisible() + await page.getByText('Use it from the API').click() + await expect(page.getByText(/api\/agent\/tasks\/ad-hoc-research\/execute/).first()).toBeVisible() + }) + + test('the switch saves the task; Run now opens the dialog; a run row opens the job', async ({ page }) => { + const { seen } = await mockJobs(page) + await page.goto('/app/agent-jobs/tasks/t1') + await page.getByTestId('task-facts').getByRole('switch').click() + await expect(page.getByText('daily-digest is off')).toBeVisible() + expect(seen.puts[0].body.enabled).toBe(false) + await page.getByRole('button', { name: 'Run now' }).click() + await expect(page.getByTestId('run-task-dialog')).toContainText('Run daily-digest now') + await page.keyboard.press('Escape') + await page.getByTestId('task-runs').locator('a').first().click() + await expect(page).toHaveURL(/\/app\/agent-jobs\/jobs\/job-fail-0001$/) + }) + + test('a task that does not exist says so', async ({ page }) => { + await mockJobs(page) + await page.goto('/app/agent-jobs/tasks/nope') + await expect(page.getByTestId('task-missing')).toContainText('Task not found') + }) +}) + +test.describe('Edit a task', () => { + async function edit(page, options) { + const mocked = await mockJobs(page, options) + await page.goto('/app/agent-jobs/tasks/t1/edit') + await expect(page.getByTestId('task-edit')).toBeVisible() + return mocked + } + + test('sections fold, with a ready mark and a one-line summary', async ({ page }) => { + await edit(page) + const basics = page.locator('[data-section="basics"]') + await expect(basics.locator('.ag-fold__sum')).toHaveText('daily-digest, qwen3-8b-instruct') + await expect(basics.locator('.ag-ready')).toHaveAttribute('data-state', 'ready') + await expect(basics.locator('.ag-fold__button')).toHaveAttribute('aria-expanded', 'false') + await expect(page.locator('[data-section="schedule"] .ag-fold__sum')).toHaveText('Every day at 07:00, 0 7 * * *') + await expect(page.locator('[data-section="webhooks"] .ag-fold__sum')).toHaveText('1 webhook') + await expect(page.locator('[data-section="media"] .ag-fold__sum')).toHaveText('None') + await basics.locator('.ag-fold__button').click() + await expect(page.getByLabel('Task name')).toHaveValue('daily-digest') + }) + + test('the prompt lists the parameters it uses', async ({ page }) => { + await edit(page) + await expect(page.getByTestId('prompt-params')).toContainText('{{.topic}}') + await expect(page.getByTestId('prompt-params').locator('.aj-gap')).toHaveCount(3) + await page.getByLabel('Prompt template').fill('Plain prompt') + await expect(page.getByTestId('prompt-params')).toContainText('No parameters in the prompt.') + }) + + test('the schedule is said in words, with presets that write the cron expression', async ({ page }) => { + await edit(page) + const words = page.getByTestId('schedule-words') + await expect(words).toContainText('Every day at 07:00.') + await expect(page.locator('[data-preset="daily"]')).toHaveAttribute('aria-checked', 'true') + await expect(page.getByLabel('Time')).toHaveValue('07:00') + await page.getByLabel('Time').fill('18:30') + await expect(words).toContainText('Every day at 18:30.') + await page.locator('[data-preset="weekdays"]').click() + await expect(words).toContainText('Weekdays at 18:30.') + await page.locator('[data-preset="hourly"]').click() + await expect(words).toContainText('Every hour.') + await expect(page.getByLabel('Time')).toHaveCount(0) + await page.locator('[data-preset="none"]').click() + await expect(words).toHaveCount(0) + await expect(page.getByLabel('Parameters, one key=value per line')).toHaveCount(0) + await page.locator('[data-preset="custom"]').click() + await page.getByLabel('Cron expression').fill('*/15 * * * *') + await expect(words).toContainText('Every 15 minutes.') + await page.getByLabel('Cron expression').fill('0,30 9-17 * * 1-5') + await expect(words).toContainText('Custom schedule.') + }) + + test('a cron expression that is not valid says why and blocks the save', async ({ page }) => { + const { seen } = await edit(page) + await page.locator('[data-preset="custom"]').click() + await page.getByLabel('Cron expression').fill('* * * *') + await expect(page.getByRole('alert').filter({ hasText: 'Use five fields' })).toContainText('You wrote 4.') + await expect(page.getByLabel('Cron expression')).toHaveAttribute('aria-invalid', 'true') + await expect(page.locator('[data-section="schedule"] .ag-ready')).toHaveAttribute('data-state', 'needs') + await page.getByLabel('Cron expression').fill('61 * * * *') + await expect(page.getByRole('alert').filter({ hasText: 'minute field' })).toBeVisible() + await page.getByRole('button', { name: 'Save task' }).first().click() + await expect(page.getByText('Fix the schedule first')).toBeVisible() + expect(seen.puts).toEqual([]) + }) + + test('warns when a scheduled prompt has a gap the schedule does not fill', async ({ page }) => { + await edit(page) + await expect(page.getByTestId('schedule-gaps')).toHaveCount(0) + await page.getByLabel('Parameters, one key=value per line').fill('topic=news') + await expect(page.getByTestId('schedule-gaps')).toContainText('{{.format}}, {{.items}}') + }) + + test('saves the task as the API takes it: parameters as a map, headers as objects', async ({ page }) => { + const { seen } = await edit(page) + await page.getByLabel('Time').fill('08:15') + await page.getByLabel('Parameters, one key=value per line').fill('topic=chips\nformat=prose\nitems=5') + await page.locator('[data-section="webhooks"] .ag-fold__button').click() + await expect(page.getByLabel('Headers (JSON)')).toHaveValue('{"X-Token":"abc"}') + await page.getByRole('button', { name: 'Save task' }).first().click() + await expect(page.getByText('Task updated')).toBeVisible() + await expect(page).toHaveURL(/\/app\/agent-jobs$/) + expect(seen.puts).toHaveLength(1) + const { id, body } = seen.puts[0] + expect(id).toBe('t1') + expect(body.cron).toBe('15 8 * * *') + expect(body.cron_parameters).toEqual({ topic: 'chips', format: 'prose', items: '5' }) + expect(body.webhooks[0].headers).toEqual({ 'X-Token': 'abc' }) + expect(body.webhooks[0].url).toBe('https://hooks.example.org/digest') + expect(body.enabled).toBe(true) + }) + + test('a webhook header that is not JSON stops the save and names the webhook', async ({ page }) => { + const { seen } = await edit(page) + await page.locator('[data-section="webhooks"] .ag-fold__button').click() + await page.getByLabel('Headers (JSON)').fill('{oops') + await page.getByRole('button', { name: 'Save task' }).first().click() + await expect(page.getByText('Headers must be valid JSON: Webhooks 1')).toBeVisible() + expect(seen.puts).toEqual([]) + }) + + test('the preview shows the prompt as a scheduled run sends it, and what changed', async ({ page }) => { + await edit(page) + await page.getByLabel('Time').fill('08:00') + await page.getByTestId('task-preview-open').click() + const sheet = page.getByTestId('task-preview') + await expect(sheet.getByTestId('task-preview-prompt')).toContainText('Summarise overnight news about local inference in bullet points format. Keep it to 8 items.') + await sheet.getByRole('tab', { name: /Changes/ }).click() + const changes = sheet.getByTestId('task-preview-changes') + await expect(changes.locator('[data-key="cron"]')).toContainText('0 7 * * *') + await expect(changes.locator('[data-key="cron"]')).toContainText('0 8 * * *') + await page.keyboard.press('Escape') + await expect(sheet).toHaveCount(0) + }) + + test('leaving with unsaved changes asks first', async ({ page }) => { + await edit(page) + await page.getByLabel('Time').fill('09:00') + await page.getByRole('button', { name: 'Back', exact: true }).click() + await expect(page.getByRole('alertdialog')).toBeVisible() + }) + + test('media sources and webhooks add and remove', async ({ page }) => { + await edit(page, { tasks: undefined }) + await page.locator('[data-section="media"] .ag-fold__button').click() + await page.getByRole('button', { name: 'Add source' }).click() + await expect(page.getByTestId('media-source')).toHaveCount(1) + await page.getByLabel('URL').fill('https://example.org/clip.mp4') + await page.getByLabel('Type').selectOption('video') + await expect(page.locator('[data-section="media"] .ag-fold__sum')).toHaveText('1 source') + await page.getByRole('button', { name: 'Remove source' }).click() + await expect(page.getByTestId('media-source')).toHaveCount(0) + }) +}) + +test.describe('Create a task', () => { + test('starts with sections that need a name, and creates the task', async ({ page }) => { + const { seen } = await mockJobs(page) + await page.goto('/app/agent-jobs/tasks/new') + await expect(page.getByRole('heading', { name: 'New task' })).toBeVisible() + await expect(page.locator('[data-section="basics"] .ag-ready')).toHaveAttribute('data-state', 'needs') + await expect(page.locator('[data-section="basics"] .ag-fold__sum')).toHaveText('Needs a name') + await page.getByRole('button', { name: 'Create task' }).first().click() + await expect(page.getByText('Task name is required')).toBeVisible() + expect(seen.posts).toEqual([]) + await page.getByLabel('Task name').fill('nightly') + await page.getByLabel('Prompt template').fill('Check {{.thing}}.') + await page.locator('[data-preset="weekdays"]').click() + await expect(page.getByTestId('schedule-words')).toContainText('Weekdays at 09:00.') + await page.getByRole('button', { name: 'Create task' }).first().click() + await expect(page.getByText('Task created')).toBeVisible() + expect(seen.posts).toHaveLength(1) + expect(seen.posts[0].name).toBe('nightly') + expect(seen.posts[0].cron).toBe('0 9 * * 1-5') + expect(seen.posts[0].enabled).toBe(true) + expect(seen.posts[0].model).toBe('qwen3-8b-instruct') + }) +}) + +test.describe('The task form on a phone', () => { + test.use({ viewport: { width: 390, height: 844 } }) + + test('fits the width', async ({ page }) => { + await mockJobs(page) + await page.goto('/app/agent-jobs/tasks/t1/edit') + await expect(page.getByTestId('task-edit')).toBeVisible() + await expect(page.locator('[data-preset="daily"]')).toBeVisible() + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth) + expect(overflow).toBeLessThanOrEqual(0) + }) +}) diff --git a/core/http/react-ui/e2e/agent-jobs.spec.js b/core/http/react-ui/e2e/agent-jobs.spec.js new file mode 100644 index 000000000..ad8804645 --- /dev/null +++ b/core/http/react-ui/e2e/agent-jobs.spec.js @@ -0,0 +1,291 @@ +import { test, expect } from './coverage-fixtures.js' +import { mockJobs, fixtureData } from './jobs-fixtures.js' + +async function open(page, options) { + const mocked = await mockJobs(page, options) + await page.goto('/app/agent-jobs') + await expect(page.getByTestId('jobs-page')).toBeVisible() + return mocked +} + +test.describe('Jobs page: the week and the tasks', () => { + test('one sentence says what the week held, from the jobs the API returned', async ({ page }) => { + await open(page) + const sum = page.getByTestId('jobs-summary') + await expect(sum).toContainText('5 runs in the last 7 days.') + await expect(sum).toContainText('2 finished, 1 failed, 1 cancelled, 1 still going.') + await expect(sum).toContainText('The last run of daily-digest failed.') + }) + + test('a week with no runs says so, and a page with no jobs at all says nothing ran', async ({ page }) => { + const old = fixtureData().jobs.filter(j => j.id === 'job-stop-0005').map(j => ({ ...j, created_at: new Date(Date.now() - 10 * 86400000).toISOString(), started_at: undefined })) + await open(page, { jobs: old }) + await expect(page.getByTestId('jobs-summary')).toHaveText('No runs in the last 7 days.') + const none = await page.context().newPage() + await mockJobs(none, { jobs: [] }) + await none.goto('/app/agent-jobs') + await expect(none.getByTestId('jobs-summary')).toHaveText('Nothing has run yet.') + await expect(none.getByText('No runs yet')).toBeVisible() + }) + + test('each task shows its model, its schedule in words, its last runs and its last outcome', async ({ page }) => { + await open(page) + const digest = page.locator('[data-task="daily-digest"]') + await expect(digest).toContainText('qwen3-8b-instruct') + await expect(digest).toContainText('Every day at 07:00') + await expect(digest.locator('.aj-sched__cron')).toHaveText('0 7 * * *') + await expect(digest).toContainText('last run failed') + await expect(digest.locator('.ag-pip[data-state="failed"]')).toHaveCount(1) + await expect(digest.locator('.ag-pip[data-state="done"]')).toHaveCount(1) + await expect(page.locator('[data-task="repo-triage"]')).toContainText('Every 30 minutes') + await expect(page.locator('[data-task="weekly-report"]')).toContainText('Every Monday at 09:00') + const manual = page.locator('[data-task="ad-hoc-research"]') + await expect(manual).toContainText('Runs when you start it') + await expect(manual).toContainText('Not run yet') + await expect(manual.getByRole('switch')).toHaveAttribute('aria-checked', 'false') + }) + + test('a task name opens the task, and the model opens its editor', async ({ page }) => { + await open(page) + await expect(page.locator('[data-task="daily-digest"] .aj-model')).toHaveAttribute('href', '/app/model-editor/qwen3-8b-instruct') + await page.locator('[data-task="daily-digest"]').getByRole('link', { name: 'daily-digest' }).click() + await expect(page).toHaveURL(/\/app\/agent-jobs\/tasks\/t1$/) + }) + + test('the task list folds away and comes back', async ({ page }) => { + await open(page) + await page.getByRole('button', { name: 'Hide' }).click() + await expect(page.getByTestId('jobs-tasks')).toHaveCount(0) + await page.getByRole('button', { name: 'Show' }).click() + await expect(page.getByTestId('jobs-tasks')).toBeVisible() + }) + + test('the enabled switch saves the task and says so', async ({ page }) => { + const { seen } = await open(page) + await page.locator('[data-task="daily-digest"]').getByRole('switch').click() + await expect(page.getByText('daily-digest is off')).toBeVisible() + expect(seen.puts).toHaveLength(1) + expect(seen.puts[0].id).toBe('t1') + expect(seen.puts[0].body.enabled).toBe(false) + expect(seen.puts[0].body.cron).toBe('0 7 * * *') + expect(seen.puts[0].body.cron_parameters).toEqual({ topic: 'local inference', format: 'bullet points', items: '8' }) + }) + + test('the switch goes back when the save fails', async ({ page }) => { + await open(page, { updateStatus: 500 }) + const toggle = page.locator('[data-task="daily-digest"]').getByRole('switch') + await toggle.click() + await expect(page.getByText(/Could not change the task/)).toBeVisible() + await expect(toggle).toHaveAttribute('aria-checked', 'true') + }) + + test('with no tasks the page teaches what a task is and how to make one', async ({ page }) => { + await open(page, { tasks: [], jobs: [] }) + const empty = page.getByTestId('jobs-empty') + await expect(empty).toContainText('A task is a prompt a model runs for you') + await expect(empty.locator('li')).toHaveCount(3) + await expect(empty).toContainText('{{.name}}') + await empty.getByRole('button', { name: 'Create task' }).click() + await expect(page).toHaveURL(/\/app\/agent-jobs\/tasks\/new$/) + }) + + test('without a model it says Jobs need one, and without MCP it shows how to add it', async ({ page }) => { + await mockJobs(page, { models: [] }) + await page.goto('/app/agent-jobs') + await expect(page.getByTestId('jobs-no-models')).toContainText('No models installed') + const other = await page.context().newPage() + await mockJobs(other, { tasks: [], jobs: [], mcp: false }) + await other.goto('/app/agent-jobs') + await expect(other.getByTestId('jobs-no-mcp')).toContainText('MCP is not set up') + await expect(other.getByTestId('jobs-no-mcp')).toContainText('command: /path/to/tool') + }) +}) + +test.describe('Jobs page: run history', () => { + test('is grouped by day, newest first, one outcome sentence per run', async ({ page }) => { + await open(page) + const days = page.locator('.aj-day') + await expect(days.nth(0)).toHaveText('Today') + await expect(days.nth(1)).toHaveText('Yesterday') + await expect(days).toHaveCount(3) + const rows = page.locator('[data-testid="jobs-history"] tr[data-job]') + await expect(rows).toHaveCount(5) + await expect(rows.nth(0)).toHaveAttribute('data-job', 'job-run-0003') + await expect(rows.nth(1)).toHaveAttribute('data-job', 'job-fail-0001') + await expect(rows.nth(2)).toHaveAttribute('data-job', 'job-done-0002') + await expect(rows.nth(3)).toHaveAttribute('data-job', 'job-done-0004') + await expect(rows.nth(4)).toHaveAttribute('data-job', 'job-stop-0005') + await expect(page.locator('[data-job="job-fail-0001"]')).toContainText('read_page: timeout after 20 s') + await expect(page.locator('[data-job="job-fail-0001"]')).toContainText('1 min 06 s') + await expect(page.locator('[data-job="job-done-0002"]')).toContainText('Labelled 12 issues and routed 3 of them') + await expect(page.locator('[data-job="job-run-0003"]')).toContainText('Working since') + await expect(page.locator('[data-job="job-stop-0005"]')).toContainText('Cancelled before it finished') + }) + + test('shows no duration for a run the server has no end time for', async ({ page }) => { + await open(page) + const cells = page.locator('[data-job="job-run-0003"] td.dk-num') + await expect(cells.nth(1)).toHaveText('') + }) + + test('status chips filter, with the counts, and a second press clears', async ({ page }) => { + await open(page) + await expect(page.locator('[data-filter="all"]')).toContainText('5') + await expect(page.locator('[data-filter="failed"]')).toContainText('1') + await expect(page.locator('[data-filter="completed"]')).toContainText('2') + await expect(page.locator('[data-filter="pending"]')).toHaveCount(0) + await page.locator('[data-filter="completed"]').click() + await expect(page.locator('[data-testid="jobs-history"] tr[data-job]')).toHaveCount(2) + await page.locator('[data-filter="completed"]').click() + await expect(page.locator('[data-testid="jobs-history"] tr[data-job]')).toHaveCount(5) + }) + + test('the task filter narrows the history and the counts follow it', async ({ page }) => { + await open(page) + await page.getByLabel('Task', { exact: true }).selectOption({ label: 'repo-triage' }) + await expect(page.locator('[data-testid="jobs-history"] tr[data-job]')).toHaveCount(2) + await expect(page.locator('[data-filter="all"]')).toContainText('2') + await expect(page.locator('[data-filter="failed"]')).toHaveCount(0) + }) + + test('a filter with no match says so and clears', async ({ page }) => { + await open(page) + await page.locator('[data-filter="failed"]').click() + await page.getByLabel('Task', { exact: true }).selectOption({ label: 'weekly-report' }) + await expect(page.getByTestId('jobs-history-empty')).toContainText('No runs match these filters.') + await page.getByRole('button', { name: 'Clear filters' }).click() + await expect(page.locator('[data-testid="jobs-history"] tr[data-job]')).toHaveCount(5) + }) + + test('a row opens into its error and one next action', async ({ page }) => { + const { seen } = await open(page) + const toggle = page.getByRole('button', { name: 'Details for the run of daily-digest' }).first() + await expect(toggle).toHaveAttribute('aria-expanded', 'false') + await toggle.click() + await expect(toggle).toHaveAttribute('aria-expanded', 'true') + const detail = page.getByTestId('job-detail') + await expect(detail.getByTestId('job-excerpt')).toContainText('read_page: timeout after 20 s (3 of 3 attempts)') + await detail.getByRole('button', { name: 'Run again' }).click() + await expect(page.getByText('Started a new run')).toBeVisible() + expect(seen.jobExecutes).toEqual([{ task_id: 't1', parameters: { topic: 'local inference', format: 'bullet points', items: '8' } }]) + await expect(detail.getByRole('link', { name: 'Open the run' })).toHaveAttribute('href', '/app/agent-jobs/jobs/job-fail-0001') + }) + + test('a run in flight offers Cancel, and Cancel calls the API', async ({ page }) => { + const { seen } = await open(page) + await page.locator('[data-job="job-run-0003"]').getByRole('button', { name: /Details for/ }).click() + await page.getByTestId('job-detail').getByRole('button', { name: 'Cancel' }).click() + await expect.poll(() => seen.cancels).toEqual(['job-run-0003']) + }) + + test('the page menu stops every running job after a confirm', async ({ page }) => { + const { seen } = await open(page) + await page.getByRole('button', { name: 'More job actions' }).click() + await page.getByRole('menuitem', { name: 'Stop running jobs' }).click() + await expect(page.getByRole('alertdialog')).toContainText('Finished jobs stay in the history') + await page.getByRole('alertdialog').getByRole('button', { name: 'Stop jobs' }).click() + await expect.poll(() => seen.cancels).toEqual(['job-run-0003']) + }) + + test('the Jobs tab stays lit around the page', async ({ page }) => { + await open(page) + await expect(page.locator('.dk-hubtabs [data-hub-tab="jobs"]')).toHaveAttribute('aria-current', 'page') + }) +}) + +test.describe('Jobs page: run a task now', () => { + test('the dialog asks for the values the prompt uses, filled from the schedule', async ({ page }) => { + const { seen } = await open(page) + await page.locator('[data-task="daily-digest"]').getByRole('button', { name: 'Run now' }).click() + const dialog = page.getByTestId('run-task-dialog') + await expect(dialog).toContainText('Run daily-digest now') + await expect(dialog.getByLabel('topic')).toHaveValue('local inference') + await expect(dialog.getByLabel('format')).toHaveValue('bullet points') + await dialog.getByLabel('topic').fill('open models') + await dialog.getByLabel('More parameters, one key=value per line').fill('tone=dry') + await dialog.getByRole('button', { name: 'Start run' }).click() + await expect(page.getByText('Task "daily-digest" started')).toBeVisible() + expect(seen.executes).toEqual([{ name: 'daily-digest', body: { topic: 'open models', format: 'bullet points', items: '8', tone: 'dry' } }]) + await expect(dialog).toHaveCount(0) + }) + + test('a task with no gaps says so, and Escape closes the dialog', async ({ page }) => { + await open(page) + await page.locator('[data-task="weekly-report"]').getByRole('button', { name: 'Run now' }).click() + await expect(page.getByTestId('run-task-dialog')).toContainText('This prompt has no gaps to fill.') + await page.keyboard.press('Escape') + await expect(page.getByTestId('run-task-dialog')).toHaveCount(0) + }) + + test('attached media goes with the run through the job call', async ({ page }) => { + const { seen } = await open(page) + await page.locator('[data-task="weekly-report"]').getByRole('button', { name: 'Run now' }).click() + const dialog = page.getByTestId('run-task-dialog') + await dialog.getByText('Media', { exact: true }).click() + const chooser = page.waitForEvent('filechooser') + await dialog.getByRole('button', { name: 'Add' }).first().click() + await (await chooser).setFiles({ name: 'board.png', mimeType: 'image/png', buffer: Buffer.from('png') }) + await expect(dialog.getByText('board.png')).toBeVisible() + await dialog.getByRole('button', { name: 'Start run' }).click() + await expect.poll(() => seen.jobExecutes.length).toBe(1) + expect(seen.jobExecutes[0].task_id).toBe('t3') + expect(seen.jobExecutes[0].images).toHaveLength(1) + expect(seen.jobExecutes[0].images[0]).toMatch(/^data:image\/png;base64,/) + expect(seen.executes).toEqual([]) + }) +}) + +test.describe('Jobs page: delete a task', () => { + test('hides the task, offers undo and sends nothing until the time ends; undo brings it back', async ({ page }) => { + const { seen } = await open(page) + await page.locator('[data-task="repo-triage"]').getByRole('button', { name: /More actions for/ }).click() + await page.getByRole('menuitem', { name: 'Delete' }).click() + await page.getByRole('alertdialog').getByRole('button', { name: 'Delete' }).click() + await expect(page.locator('[data-task="repo-triage"]')).toHaveCount(0) + const toast = page.getByTestId('task-undo-toast') + await expect(toast).toContainText('Task "repo-triage" deleted') + expect(seen.deletes).toEqual([]) + await toast.getByRole('button', { name: 'Undo' }).click() + await expect(page.locator('[data-task="repo-triage"]')).toBeVisible() + await expect(toast).toHaveCount(0) + expect(seen.deletes).toEqual([]) + }) + + test('without undo the delete is sent when the time ends', async ({ page }) => { + await page.clock.install() + const { seen } = await open(page) + await page.locator('[data-task="repo-triage"]').getByRole('button', { name: /More actions for/ }).click() + await page.getByRole('menuitem', { name: 'Delete' }).click() + await page.getByRole('alertdialog').getByRole('button', { name: 'Delete' }).click() + await expect(page.getByTestId('task-undo-toast')).toBeVisible() + expect(seen.deletes).toEqual([]) + await page.clock.runFor(30500) + await expect.poll(() => seen.deletes).toEqual(['t2']) + await expect(page.getByText('Task deleted')).toBeVisible() + }) + + test('leaving the page during the wait deletes nothing', async ({ page }) => { + const { seen } = await open(page) + await page.locator('[data-task="repo-triage"]').getByRole('button', { name: /More actions for/ }).click() + await page.getByRole('menuitem', { name: 'Delete' }).click() + await page.getByRole('alertdialog').getByRole('button', { name: 'Delete' }).click() + await expect(page.getByTestId('task-undo-toast')).toBeVisible() + await page.getByRole('link', { name: 'Skills' }).first().click() + await expect(page.getByText('Nothing was deleted, because the page was closed first.')).toBeVisible() + expect(seen.deletes).toEqual([]) + }) +}) + +test.describe('Jobs page on a phone', () => { + test.use({ viewport: { width: 390, height: 844 } }) + + test('fits the width, keeps the essentials and drops the wide columns', async ({ page }) => { + await open(page) + await expect(page.locator('[data-task="daily-digest"]')).toBeVisible() + await expect(page.locator('[data-task="daily-digest"] .aj-sched')).toBeHidden() + await expect(page.locator('[data-task="daily-digest"]').getByRole('button', { name: 'Run now' })).toBeVisible() + await expect(page.locator('[data-job="job-fail-0001"]')).toBeVisible() + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth) + expect(overflow).toBeLessThanOrEqual(0) + }) +}) diff --git a/core/http/react-ui/e2e/agents-edit.spec.js b/core/http/react-ui/e2e/agents-edit.spec.js new file mode 100644 index 000000000..60192d454 --- /dev/null +++ b/core/http/react-ui/e2e/agents-edit.spec.js @@ -0,0 +1,126 @@ +import { test, expect } from './coverage-fixtures.js' +import { installFakeStream, mockAgents, RESEARCH_CONFIG } from './agents-fixtures.js' + +test.describe('Create and edit an agent', () => { + test('sections fold, show ready marks and a one-line summary', async ({ page }) => { + await installFakeStream(page) + await mockAgents(page) + await page.goto('/app/agents/research-assistant/edit') + await expect(page.getByRole('heading', { name: 'Edit Agent: research-assistant' })).toBeVisible() + const basics = page.locator('[data-section="BasicInfo"]') + await expect(basics.locator('.ag-fold__button')).toHaveAttribute('aria-expanded', 'true') + const model = page.locator('[data-section="ModelSettings"]') + await expect(model.locator('.ag-fold__button')).toHaveAttribute('aria-expanded', 'false') + await expect(model.locator('.ag-fold__sum')).toHaveText('qwen3-14b-instruct') + await expect(model.locator('.ag-ready')).toHaveAttribute('data-state', 'ready') + await expect(model.locator('.ag-fold__body')).toHaveCount(0) + await model.locator('.ag-fold__button').click() + await expect(model.locator('.ag-fold__body')).toBeVisible() + await model.locator('.ag-fold__button').click() + await expect(model.locator('.ag-fold__body')).toHaveCount(0) + }) + + test('a new agent starts with sections that need a name and a model', async ({ page }) => { + await installFakeStream(page) + await mockAgents(page) + await page.goto('/app/agents/new') + await expect(page.locator('[data-section="BasicInfo"] .ag-ready')).toHaveAttribute('data-state', 'needs') + await expect(page.locator('[data-section="ModelSettings"] .ag-fold__sum')).toHaveText('Needs a model') + await expect(page.locator('[data-section="MemorySettings"] .ag-ready')).toHaveAttribute('data-state', 'empty') + }) + + test('a template fills the form and saves nothing', async ({ page }) => { + await installFakeStream(page) + const seen = await mockAgents(page) + await page.goto('/app/agents/new') + await page.getByRole('button', { name: 'Code reviewer' }).click() + await expect(page.locator('[data-section="BasicInfo"] .ag-fold__sum')).toContainText('code-reviewer') + expect(seen.saves).toEqual([]) + }) + + test('a template link from the launcher opens the form already filled', async ({ page }) => { + await installFakeStream(page) + await mockAgents(page) + await page.goto('/app/agents/new?template=digest') + await expect(page.locator('[data-section="BasicInfo"] .ag-fold__sum')).toContainText('daily-digest') + }) + + test('the preview sheet shows the config and, when editing, what changed', async ({ page }) => { + await installFakeStream(page) + const seen = await mockAgents(page, { agents: { 'research-assistant': { active: true, config: { ...RESEARCH_CONFIG, api_key: 'sk-secret-value', mcp_servers: [{ url: 'https://x.example.org', token: 'tok-123' }] } } } }) + await page.goto('/app/agents/research-assistant/edit') + await page.locator('[data-section="PromptsGoals"] .ag-fold__button').click() + await page.locator('[data-section="PromptsGoals"] textarea').fill('A new instruction.') + await expect(page.locator('[data-section="PromptsGoals"] .ag-badge-changed')).toHaveText('1 changed') + + await page.getByTestId('agent-preview-open').click() + const sheet = page.getByTestId('agent-preview') + await expect(sheet.getByTestId('agent-preview-config')).toContainText('"system_prompt": "A new instruction."') + const cfg = await sheet.getByTestId('agent-preview-config').textContent() + expect(cfg).not.toContain('sk-secret-value') + expect(cfg).not.toContain('tok-123') + + await sheet.getByRole('tab', { name: /Changes/ }).click() + const changes = sheet.getByTestId('agent-preview-changes') + await expect(changes.locator('[data-key]')).toHaveCount(1) + await expect(changes.locator('[data-key="system_prompt"]')).toContainText('You are a careful research assistant') + await expect(changes.locator('[data-key="system_prompt"]')).toContainText('A new instruction.') + + await page.keyboard.press('Escape') + await expect(sheet).toHaveCount(0) + + await page.getByTestId('agent-preview-open').click() + await page.getByTestId('agent-preview').getByRole('button', { name: 'Save Changes' }).click() + await expect.poll(() => seen.saves.length).toBe(1) + expect(seen.saves[0]).toMatchObject({ method: 'PUT', name: 'research-assistant' }) + expect(seen.saves[0].body.system_prompt).toBe('A new instruction.') + }) + + test('with no edits the Changes tab says the form matches the saved agent', async ({ page }) => { + await installFakeStream(page) + await mockAgents(page) + await page.goto('/app/agents/research-assistant/edit') + await page.getByTestId('agent-preview-open').click() + await page.getByRole('tab', { name: /Changes/ }).click() + await expect(page.getByTestId('agent-preview-changes')).toContainText('No changes') + }) + + test('creating an agent sends the form and offers no Dry run it cannot run', async ({ page }) => { + await installFakeStream(page) + const seen = await mockAgents(page) + await page.goto('/app/agents/new') + await page.locator('[data-section="BasicInfo"] input').first().fill('my-agent') + await page.locator('[data-section="ModelSettings"] .ag-fold__button').click() + await page.locator('[data-section="ModelSettings"] input').first().fill('qwen3-8b-instruct') + await page.getByTestId('agent-preview-open').click() + await expect(page.getByRole('tab', { name: /Dry run/ })).toHaveCount(0) + await page.getByRole('tab', { name: /Changes/ }).click() + await expect(page.getByTestId('agent-preview-changes')).toContainText('qwen3-8b-instruct') + await page.keyboard.press('Escape') + await page.getByRole('button', { name: 'Create Agent' }).first().click() + await expect.poll(() => seen.saves.length).toBe(1) + expect(seen.saves[0].body).toMatchObject({ name: 'my-agent', model: 'qwen3-8b-instruct' }) + }) + + test('draft from a sentence needs a model first and is labelled optional', async ({ page }) => { + await installFakeStream(page) + await mockAgents(page) + await page.goto('/app/agents/new') + await expect(page.getByTestId('agent-start')).toContainText('Optional') + await page.getByLabel('Describe the agent in one sentence').fill('reads release notes') + await page.getByRole('button', { name: 'Draft' }).click() + await expect(page.getByText(/Pick a model in the Model section first/)).toBeVisible() + }) + + test('draft from a sentence fills name, description and instructions from the chosen model', async ({ page }) => { + await installFakeStream(page) + await mockAgents(page) + await page.route('**/v1/chat/completions', route => route.fulfill({ json: { choices: [{ message: { content: '```json\n{"name":"release-reader","description":"Reads release notes.","system_prompt":"List breaking changes."}\n```' } }] } })) + await page.goto('/app/agents/new') + await page.locator('[data-section="ModelSettings"] .ag-fold__button').click() + await page.locator('[data-section="ModelSettings"] input').first().fill('qwen3-8b-instruct') + await page.getByLabel('Describe the agent in one sentence').fill('reads release notes') + await page.getByRole('button', { name: 'Draft' }).click() + await expect(page.locator('[data-section="BasicInfo"] .ag-fold__sum')).toContainText('release-reader') + }) +}) diff --git a/core/http/react-ui/e2e/agents-fixtures.js b/core/http/react-ui/e2e/agents-fixtures.js new file mode 100644 index 000000000..427e38134 --- /dev/null +++ b/core/http/react-ui/e2e/agents-fixtures.js @@ -0,0 +1,139 @@ +// Shared fixtures for the Agents specs: stubbed agent API routes, a fake event +// stream the test drives by hand, and run records written to the browser +// storage the pages read. + +export const RUNS_KEY = (agent, userId) => `localai_agent_runs_${userId ? `${userId}:` : ''}${agent}` + +export const RESEARCH_CONFIG = { + name: 'research-assistant', + description: 'Searches the web, reads sources and writes short briefs.', + model: 'qwen3-14b-instruct', + system_prompt: 'You are a careful research assistant. Search first, read at least two sources, then summarise in plain language.', + actions: [{ name: 'web_search', config: '{}' }, { name: 'read_page', config: '{}' }, { name: 'write_file', config: '{}' }], + mcp_stdio_servers: JSON.stringify({ mcpServers: { filesystem: { command: 'npx', args: [], env: {} } } }), + enable_kb: true, + enable_skills: true, + selected_skills: ['citations'], +} + +export const AGENTS = { + 'research-assistant': { active: true, config: RESEARCH_CONFIG }, + 'code-reviewer': { + active: true, + config: { name: 'code-reviewer', description: 'Reviews a change and lists what to fix first.', model: 'qwen3-14b-instruct', system_prompt: 'You review code changes.' }, + }, + 'daily-digest': { + active: false, + config: { name: 'daily-digest', description: 'Collects what is new and summarises it.', model: 'qwen3-8b-instruct', system_prompt: 'You collect news.' }, + }, +} + +const META = { + Fields: [ + { name: 'name', label: 'Name', type: 'text', required: true, tags: { section: 'BasicInfo' } }, + { name: 'description', label: 'Description', type: 'textarea', tags: { section: 'BasicInfo' } }, + { name: 'model', label: 'Model', type: 'text', required: true, tags: { section: 'ModelSettings' } }, + { name: 'enable_kb', label: 'Knowledge base', type: 'checkbox', tags: { section: 'MemorySettings' } }, + { name: 'system_prompt', label: 'System prompt', type: 'textarea', tags: { section: 'PromptsGoals' } }, + { name: 'enable_skills', label: 'Skills', type: 'checkbox', tags: { section: 'AdvancedSettings' } }, + ], + Connectors: [], Actions: [], Filters: [], DynamicPrompts: [], +} + +// Replace EventSource with one the test drives: window.__emit(type, data). +export async function installFakeStream(page) { + await page.addInitScript(() => { + const sources = [] + class FakeES { + constructor(url) { + this.url = url + this.listeners = {} + this.readyState = 1 + sources.push(this) + setTimeout(() => this.onopen && this.onopen({}), 0) + } + addEventListener(type, fn) { (this.listeners[type] = this.listeners[type] || []).push(fn) } + removeEventListener(type, fn) { this.listeners[type] = (this.listeners[type] || []).filter(f => f !== fn) } + close() { this.readyState = 2; const i = sources.indexOf(this); if (i >= 0) sources.splice(i, 1) } + } + window.EventSource = FakeES + window.__emit = (type, data) => { + sources.forEach(s => (s.listeners[type] || []).forEach(fn => fn({ data: typeof data === 'string' ? data : JSON.stringify(data) }))) + } + window.__streams = () => sources.length + }) +} + +export function emit(page, type, data) { + return page.evaluate(([t, d]) => window.__emit(t, d), [type, data]) +} + +// Stub every agent endpoint the area reads. Returns the requests the page +// sent, so a spec can assert on them. +export async function mockAgents(page, { + agents = AGENTS, + observables = {}, + chatStatus = 202, + chatError = 'agent unavailable', + features = { distributed: false, localai_assistant: true, agents: true, mcp: true }, + skills = [{ name: 'citations', description: 'Cite sources' }], +} = {}) { + const seen = { chats: [], saves: [], deletes: [], pauses: [], resumes: [], clears: [] } + await page.route('**/api/features', route => route.fulfill({ json: features })) + await page.route('**/api/agents/skills', route => route.fulfill({ json: { skills } })) + await page.route('**/api/agents/config/metadata', route => route.fulfill({ json: META })) + await page.route('**/api/agents', async route => { + const req = route.request() + if (req.method() === 'POST') { + seen.saves.push({ method: 'POST', body: req.postDataJSON() }) + return route.fulfill({ json: { status: 'ok' } }) + } + const statuses = Object.fromEntries(Object.entries(agents).map(([n, a]) => [n, a.active])) + return route.fulfill({ json: { agents: Object.keys(agents), statuses } }) + }) + await page.route(/\/api\/agents\/[^/?]+(\/[a-z]+)?(\?.*)?$/, async route => { + const req = route.request() + const url = new URL(req.url()) + const m = url.pathname.match(/\/api\/agents\/([^/]+)(?:\/([a-z]+))?$/) + if (!m) return route.fallback() + const name = decodeURIComponent(m[1]) + const tail = m[2] + if (['skills', 'collections', 'actions', 'config', 'git-repos', 'import'].includes(name)) return route.fallback() + const agent = agents[name] + if (!agent) return route.fulfill({ status: 404, json: { error: 'Agent not found' } }) + if (tail === 'chat' && req.method() === 'POST') { + seen.chats.push({ name, ...req.postDataJSON() }) + if (chatStatus !== 202) return route.fulfill({ status: chatStatus, json: { error: chatError } }) + return route.fulfill({ status: 202, json: { status: 'message_received', message_id: `m${seen.chats.length}` } }) + } + if (tail === 'config') return route.fulfill({ json: agent.config }) + if (tail === 'observables') { + if (req.method() === 'DELETE') { seen.clears.push(name); return route.fulfill({ json: { cleared: true } }) } + return route.fulfill({ json: { Name: name, History: observables[name] || [] } }) + } + if (tail === 'status') return route.fulfill({ json: { Name: name, History: [] } }) + if (tail === 'pause') { seen.pauses.push(name); return route.fulfill({ json: { status: 'ok' } }) } + if (tail === 'resume') { seen.resumes.push(name); return route.fulfill({ json: { status: 'ok' } }) } + if (tail === 'export') return route.fulfill({ json: agent.config }) + if (req.method() === 'PUT') { seen.saves.push({ method: 'PUT', name, body: req.postDataJSON() }); return route.fulfill({ json: { status: 'ok' } }) } + if (req.method() === 'DELETE') { seen.deletes.push(name); return route.fulfill({ json: { status: 'ok' } }) } + return route.fulfill({ json: { active: agent.active } }) + }) + return seen +} + +// Seed run records. Each run: { id, task, status, outcome, ... } is expanded +// into the stored shape. +export function storedRun(agent, { id, task, status = 'done', outcome = '', error = '', steps = [], startedAt, seconds = 30, metadata = null, turns = null }) { + const first = { + task, startedAt, endedAt: status === 'running' ? null : startedAt + seconds * 1000, status, outcome, error, steps, + live: { reasoning: '', content: '' }, metadata, + } + return { id, agent, startedAt, updatedAt: startedAt + seconds * 1000, turns: turns || [first] } +} + +export async function seedRuns(page, agent, runs, userId) { + await page.addInitScript(([key, data]) => { + try { localStorage.setItem(key, JSON.stringify(data)) } catch { /* ignore */ } + }, [RUNS_KEY(agent, userId), { version: 1, runs }]) +} diff --git a/core/http/react-ui/e2e/agents-runs.spec.js b/core/http/react-ui/e2e/agents-runs.spec.js new file mode 100644 index 000000000..9d992476a --- /dev/null +++ b/core/http/react-ui/e2e/agents-runs.spec.js @@ -0,0 +1,316 @@ +import { test, expect } from './coverage-fixtures.js' +import { installFakeStream, mockAgents, seedRuns, storedRun, emit, AGENTS } from './agents-fixtures.js' + +const TASK = 'Find two sources on speculative decoding and summarise them.' +const OUTCOME = 'I read two sources.\n\n| A | B | C | D |\n| - | - | - | - |\n| 1 | 2 | 3 | 4 |' + +async function startRun(page, seen) { + await page.goto('/app/agents/research-assistant') + await expect(page.getByTestId('agent-page')).toBeVisible() + await page.locator('#ag-task-input').fill(TASK) + await page.getByRole('button', { name: 'Start run' }).click() + await expect(page).toHaveURL(/\/app\/agents\/research-assistant\/runs\/r_[a-z0-9]{6}$/) + await expect(page.getByTestId('run-live')).toBeVisible() + await expect.poll(() => seen.chats.length).toBe(1) +} + +test.describe('Agent page', () => { + test('shows what the agent is: model, tools, memory, skills, instructions', async ({ page }) => { + await installFakeStream(page) + await mockAgents(page) + await page.goto('/app/agents/research-assistant') + const facts = page.getByTestId('agent-facts') + await expect(facts).toContainText('qwen3-14b-instruct') + await expect(facts).toContainText('web_search') + await expect(facts).toContainText('mcp: filesystem') + await expect(facts.getByRole('link', { name: 'Knowledge base' })).toHaveAttribute('href', '/app/collections') + await expect(facts.getByRole('link', { name: 'citations' })).toHaveAttribute('href', '/app/skills') + await expect(facts).toContainText('You are a careful research assistant') + await expect(page.getByText('No runs yet')).toBeVisible() + }) + + test('an agent with no memory or skills shows None, not a guess', async ({ page }) => { + await installFakeStream(page) + await mockAgents(page) + await page.goto('/app/agents/code-reviewer') + const facts = page.getByTestId('agent-facts') + await expect(facts.getByText('None')).toHaveCount(3) + await expect(facts.getByRole('link')).toHaveCount(0) + }) + + test('lists runs newest first and opens one', async ({ page }) => { + await installFakeStream(page) + await mockAgents(page) + const t = Date.now() + await seedRuns(page, 'research-assistant', [ + storedRun('research-assistant', { id: 'r_new', task: 'Newest task', startedAt: t - 1000, outcome: 'Wrote the digest.' }), + storedRun('research-assistant', { id: 'r_old', task: 'Older task', startedAt: t - 90000, status: 'failed', error: 'The file is not a valid PDF.' }), + ]) + await page.goto('/app/agents/research-assistant') + const rows = page.getByTestId('agent-runs').locator('a') + await expect(rows).toHaveCount(2) + await expect(rows.first()).toContainText('Newest task') + await expect(rows.last()).toContainText('The file is not a valid PDF.') + await rows.last().click() + await expect(page).toHaveURL(/\/runs\/r_old$/) + await expect(page.getByTestId('run-report')).toBeVisible() + }) + + test('a paused agent cannot start a run and offers Resume', async ({ page }) => { + await installFakeStream(page) + const seen = await mockAgents(page) + await page.goto('/app/agents/daily-digest') + await expect(page.locator('#ag-task-input')).toBeDisabled() + await page.getByTestId('agent-page').getByRole('button', { name: 'Resume', exact: true }).first().click() + await expect.poll(() => seen.resumes).toEqual(['daily-digest']) + }) + + test('an unknown agent says so', async ({ page }) => { + await installFakeStream(page) + await mockAgents(page) + await page.goto('/app/agents/nobody') + await expect(page.getByTestId('agent-missing')).toContainText('No agent named "nobody"') + }) + + test('Clear run record removes this browser\'s runs after a confirmation', async ({ page }) => { + await installFakeStream(page) + await mockAgents(page) + await seedRuns(page, 'research-assistant', [storedRun('research-assistant', { id: 'r_x', task: 'One', startedAt: Date.now() - 1000, outcome: 'ok' })]) + await page.goto('/app/agents/research-assistant') + await expect(page.getByTestId('agent-runs')).toBeVisible() + await page.getByTestId('agent-page').getByRole('button', { name: /More actions/ }).click() + await page.getByRole('menuitem', { name: 'Clear run record' }).click() + await page.getByRole('button', { name: 'Clear', exact: true }).click() + await expect(page.getByText('No runs yet')).toBeVisible() + }) +}) + +test.describe('A run', () => { + test('starts from the task box, shows the thread live and settles into the report', async ({ page }) => { + await installFakeStream(page) + const seen = await mockAgents(page) + await startRun(page, seen) + expect(seen.chats[0]).toMatchObject({ name: 'research-assistant', message: TASK, history: [] }) + + await expect(page.getByTestId('run-task')).toContainText(TASK) + await emit(page, 'stream_event', { type: 'reasoning', content: 'Search first.' }) + await emit(page, 'stream_event', { type: 'done' }) + await emit(page, 'stream_event', { type: 'tool_call', tool_name: 'web_search', tool_args: '{"q":"x"}' }) + await expect(page.getByTestId('run-working')).toContainText('Using web_search') + await emit(page, 'stream_event', { type: 'tool_result', tool_name: 'web_search', tool_result: '8 results' }) + await expect(page.getByTestId('run-fold')).toContainText('1 steps so far') + + await emit(page, 'json_message', { sender: 'agent', content: OUTCOME }) + await expect(page.getByTestId('agent-run')).toHaveAttribute('data-status', 'done') + await expect(page.getByTestId('run-settling')).toContainText('Report ready') + await expect(page.getByTestId('run-report')).toBeVisible({ timeout: 5000 }) + await expect(page.getByRole('heading', { name: 'Outcome' })).toBeVisible() + await expect(page.getByTestId('run-evidence')).toContainText('8 results') + await expect(page.getByRole('heading', { name: 'Steps' })).toBeVisible() + // Wide content opens wider on demand. + const wideBtn = page.locator('.ag-wide__btn').first() + await expect(wideBtn).toHaveText('Open wider') + await wideBtn.click() + await expect(page.locator('.ag-wide[data-wide]')).toHaveCount(1) + await expect(wideBtn).toHaveText('Back to column') + }) + + test('choosing a view stops the page from moving by itself', async ({ page }) => { + await installFakeStream(page) + const seen = await mockAgents(page) + await startRun(page, seen) + await page.getByRole('button', { name: 'Live' }).click() + await emit(page, 'json_message', { sender: 'agent', content: 'Done.' }) + await page.waitForTimeout(2200) + await expect(page.getByTestId('run-live')).toBeVisible() + await expect(page.getByTestId('run-report')).toHaveCount(0) + }) + + test('has its own address that survives a reload', async ({ page }) => { + await installFakeStream(page) + const seen = await mockAgents(page) + await startRun(page, seen) + await emit(page, 'json_message', { sender: 'agent', content: 'The answer.' }) + await expect(page.getByTestId('run-report')).toBeVisible({ timeout: 5000 }) + const url = page.url() + await page.reload() + await expect(page).toHaveURL(url) + await expect(page.getByTestId('run-report')).toContainText('The answer.') + // The reload did not send the task again. + expect(seen.chats.length).toBe(1) + }) + + test('a run from another browser opens a notice, not an error', async ({ page }) => { + await installFakeStream(page) + await mockAgents(page) + await page.goto('/app/agents/research-assistant/runs/r_nope12') + await expect(page.getByTestId('run-missing')).toContainText('not in this browser') + await expect(page.getByRole('link', { name: 'Open research-assistant' }).last()).toBeVisible() + }) + + test('a follow-up sits under the outcome and carries the earlier turns', async ({ page }) => { + await installFakeStream(page) + const seen = await mockAgents(page) + await startRun(page, seen) + await emit(page, 'json_message', { sender: 'agent', content: 'First answer.' }) + await expect(page.getByTestId('run-report')).toBeVisible({ timeout: 5000 }) + await page.getByTestId('run-followup').locator('textarea').fill('Add one more source') + await page.getByRole('button', { name: 'Send' }).click() + await expect.poll(() => seen.chats.length).toBe(2) + expect(seen.chats[1].history).toEqual([ + { role: 'user', content: TASK }, + { role: 'assistant', content: 'First answer.' }, + ]) + await emit(page, 'json_message', { sender: 'agent', content: 'Second answer.' }) + await expect(page.getByTestId('run-report')).toBeVisible({ timeout: 5000 }) + await expect(page.getByTestId('run-followups')).toContainText('Add one more source') + await expect(page.getByTestId('run-followups')).toContainText('Second answer.') + }) + + test('the agent name switches to another agent', async ({ page }) => { + await installFakeStream(page) + await mockAgents(page) + await seedRuns(page, 'research-assistant', [storedRun('research-assistant', { id: 'r_s', task: 'T', startedAt: Date.now() - 1000, outcome: 'ok' })]) + await page.goto('/app/agents/research-assistant/runs/r_s') + await page.getByTestId('agent-switcher').click() + await page.getByRole('menuitem', { name: 'code-reviewer' }).click() + await expect(page).toHaveURL(/\/app\/agents\/code-reviewer$/) + }) + + test('there is no Stop, approval or steer control the API cannot honour', async ({ page }) => { + await installFakeStream(page) + const seen = await mockAgents(page) + await startRun(page, seen) + await expect(page.getByRole('button', { name: /^Stop$/ })).toHaveCount(0) + await expect(page.getByRole('button', { name: /Approve|Allow/ })).toHaveCount(0) + await expect(page.getByTestId('run-dock-note')).toContainText('Follow-ups open when it answers') + }) +}) + +test.describe('A run that fails', () => { + test('a task the server refuses says so in plain words with one next action', async ({ page }) => { + await installFakeStream(page) + const seen = await mockAgents(page, { chatStatus: 500, chatError: 'model is not loaded' }) + await page.goto('/app/agents/research-assistant') + await page.locator('#ag-task-input').fill(TASK) + await page.keyboard.press('Enter') + const failed = page.getByTestId('run-failed') + await expect(failed).toContainText('The task did not reach the agent') + await expect(failed).toContainText('model is not loaded') + await expect(failed.getByRole('button')).toHaveCount(1) + await expect(page.getByTestId('agent-run')).toHaveAttribute('data-status', 'failed') + await failed.getByRole('button', { name: 'Run again' }).click() + await expect.poll(() => seen.chats.length).toBe(2) + await expect(page).toHaveURL(/\/runs\/r_[a-z0-9]{6}$/) + }) + + test('an error the agent reports stays in the live view and is the outcome in the report', async ({ page }) => { + await installFakeStream(page) + const seen = await mockAgents(page) + await startRun(page, seen) + await emit(page, 'json_error', { error: 'A page did not answer' }) + await expect(page.getByTestId('run-failed')).toContainText('The run stopped') + await expect(page.getByTestId('run-live')).toBeVisible() + await page.getByRole('button', { name: 'Report' }).click() + await expect(page.getByTestId('run-report').getByTestId('run-failed')).toContainText('A page did not answer') + }) + + test('a run cut off before it answered reads as stopped', async ({ page }) => { + await installFakeStream(page) + await mockAgents(page) + const started = Date.now() - 20 * 60_000 + await seedRuns(page, 'research-assistant', [storedRun('research-assistant', { id: 'r_cut', task: 'Cut off', startedAt: started, status: 'running' })]) + await page.goto('/app/agents/research-assistant/runs/r_cut') + await expect(page.getByTestId('agent-run')).toHaveAttribute('data-status', 'stopped') + await expect(page.getByTestId('run-report')).toContainText('No answer was recorded') + }) +}) + +test.describe('Status page', () => { + test('is a quiet panel with the raw record and a way back', async ({ page }) => { + await installFakeStream(page) + await mockAgents(page, { observables: { 'research-assistant': [ + { id: 1, name: 'web_search', creation: { function_definition: { name: 'web_search' } }, completion: { action_result: '8 results' } }, + { id: 2, name: 'read_page', completion: { error: 'page did not answer' } }, + ] } }) + await page.goto('/app/agents/research-assistant/status') + const panel = page.getByTestId('agent-status') + await expect(panel.getByRole('heading', { name: 'Status' })).toBeVisible() + await expect(panel.getByTestId('status-observable')).toHaveCount(2) + await panel.getByRole('button', { name: 'Failed', exact: true }).click() + await expect(panel.getByTestId('status-observable')).toHaveCount(1) + await panel.getByRole('link', { name: 'research-assistant' }).click() + await expect(page).toHaveURL(/\/app\/agents\/research-assistant$/) + }) + + test('Clear removes the records through the API', async ({ page }) => { + await installFakeStream(page) + const seen = await mockAgents(page, { observables: { 'research-assistant': [{ id: 1, name: 'x', completion: {} }] } }) + await page.goto('/app/agents/research-assistant/status') + await page.getByRole('button', { name: 'Clear' }).click() + await expect.poll(() => seen.clears).toEqual(['research-assistant']) + }) +}) + +test.describe('Layouts', () => { + async function seeded(page) { + await installFakeStream(page) + await mockAgents(page) + await seedRuns(page, 'research-assistant', [storedRun('research-assistant', { id: 'r_l', task: TASK, startedAt: Date.now() - 4000, outcome: OUTCOME, steps: [{ kind: 'tool', name: 'web_search', args: '{}', result: 'r', ts: Date.now() - 3000 }] })]) + } + const noOverflow = page => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1) + + test('phone: no sideways scroll on the launcher, agent page, report and editor', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await seeded(page) + for (const path of ['/app/agents', '/app/agents/research-assistant', '/app/agents/research-assistant/runs/r_l', '/app/agents/research-assistant/edit', '/app/agents/research-assistant/status']) { + await page.goto(path) + await page.waitForTimeout(400) + expect(await noOverflow(page), path).toBe(true) + } + }) + + test('phone: the preview opens as a sheet that fits the screen', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await seeded(page) + await page.goto('/app/agents/research-assistant/edit') + await page.getByTestId('agent-preview-open').click() + await page.waitForTimeout(600) // the sheet rises into place + const box = await page.getByTestId('agent-preview').boundingBox() + expect(box.width).toBeLessThanOrEqual(390) + expect(box.y + box.height).toBeLessThanOrEqual(844 + 1) + }) + + test('2560 wide: the report keeps a reading column and opens a table wider on demand', async ({ page }) => { + await page.setViewportSize({ width: 2560, height: 1200 }) + await seeded(page) + await page.goto('/app/agents/research-assistant/runs/r_l') + const col = page.locator('.ag-doc__in') + const colBox = await col.boundingBox() + expect(colBox.width).toBeLessThanOrEqual(830) + // Centred in the area beside the sidebar. + expect(colBox.x).toBeGreaterThan(500) + await page.locator('.ag-wide__btn').first().click() + const wide = await page.locator('.ag-wide[data-wide]').boundingBox() + expect(wide.width).toBeGreaterThan(colBox.width) + expect(wide.x + wide.width).toBeLessThanOrEqual(2560) + }) + + test('1440: the run page uses the page height and docks the follow-up box', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await seeded(page) + await page.goto('/app/agents/research-assistant/runs/r_l') + const dock = await page.getByTestId('run-followup').boundingBox() + expect(dock.y + dock.height).toBeGreaterThan(780) + expect(await noOverflow(page)).toBe(true) + }) + + test('reduced motion: the working dot does not animate', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }) + await installFakeStream(page) + await mockAgents(page, { observables: { 'research-assistant': [{ id: 1, name: 'x' }] } }) + await page.goto('/app/agents/research-assistant') + const anim = await page.locator('.ag-state[data-state="running"] .dk-dot').first().evaluate(el => getComputedStyle(el).animationName) + expect(anim).toBe('none') + }) +}) diff --git a/core/http/react-ui/e2e/agents.spec.js b/core/http/react-ui/e2e/agents.spec.js index d4d65d9e5..e58ea01f6 100644 --- a/core/http/react-ui/e2e/agents.spec.js +++ b/core/http/react-ui/e2e/agents.spec.js @@ -1,18 +1,23 @@ import { test, expect } from './coverage-fixtures.js' +import { installFakeStream, mockAgents, seedRuns, storedRun, emit, AGENTS } from './agents-fixtures.js' -// Agents feature page (src/pages/Agents.jsx). +// Agents launcher (src/pages/Agents.jsx) and the routes around it. test.describe('Agents page', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/app/agents') - }) - test('renders the agents list and empty state', async ({ page }) => { + await page.goto('/app/agents') await expect(page).toHaveURL(/\/app\/agents$/) await expect(page.getByRole('heading', { name: 'Agents', exact: true })).toBeVisible() await expect(page.getByText(/No agents configured/i)).toBeVisible() await expect(page.getByRole('button', { name: 'Create Agent' }).first()).toBeVisible() }) + test('the empty state teaches with starting points', async ({ page }) => { + await page.goto('/app/agents') + const empty = page.getByTestId('agents-empty') + await expect(empty.getByRole('link', { name: 'Research assistant' })).toHaveAttribute('href', '/app/agents/new?template=researcher') + await expect(empty.getByRole('link', { name: 'Code reviewer' })).toBeVisible() + }) + test('keeps Import Agent visible when agents exist', async ({ page }) => { await page.route('**/api/agents', route => route.fulfill({ json: { agents: ['existing-agent'], statuses: { 'existing-agent': true } }, @@ -20,12 +25,12 @@ test.describe('Agents page', () => { await page.route('**/api/agents/existing-agent/observables', route => route.fulfill({ json: { History: [] }, })) - await page.reload() - + await page.goto('/app/agents') await expect(page.locator('.header-actions label', { hasText: 'Import' })).toBeVisible() }) test('Create Agent navigates to the agent creation form', async ({ page }) => { + await page.goto('/app/agents') const create = page.getByRole('button', { name: 'Create Agent' }).last() await create.scrollIntoViewIfNeeded() await Promise.all([ @@ -33,18 +38,109 @@ test.describe('Agents page', () => { create.click(), ]) // Wait for AgentCreate.jsx to actually render, not just for the URL to - // change. Ending the test the instant the route matched let the component - // mount race the coverage teardown — its ~400 lines were collected only - // when the render won, swinging total UI coverage ~1pp run-to-run. + // change, so the coverage teardown does not race the component mount. await expect(page.getByRole('heading', { name: 'Create Agent' })).toBeVisible() }) - test('opens agent status without leaving an active chat', async ({ page }) => { - await page.goto('/app/agents/demo/chat?user_id=test-user') - + test('opens agent status without leaving an active run', async ({ page }) => { + await mockAgents(page) + await installFakeStream(page) + await seedRuns(page, 'research-assistant', [storedRun('research-assistant', { id: 'r_a1', task: 'Do it', startedAt: Date.now() - 5000, outcome: 'Done.' })], 'test-user') + await page.goto('/app/agents/research-assistant/runs/r_a1?user_id=test-user') const status = page.getByRole('link', { name: 'Status' }) - await expect(status).toHaveAttribute('href', '/app/agents/demo/status?user_id=test-user') + await expect(status).toHaveAttribute('href', '/app/agents/research-assistant/status?user_id=test-user') await expect(status).toHaveAttribute('target', '_blank') - await expect(page).toHaveURL(/\/app\/agents\/demo\/chat\?user_id=test-user$/) + await expect(page).toHaveURL(/\/runs\/r_a1\?user_id=test-user$/) + }) + + test('an old chat link opens the agent page and keeps the user', async ({ page }) => { + await mockAgents(page) + await page.goto('/app/agents/research-assistant/chat?user_id=u7') + await expect(page).toHaveURL(/\/app\/agents\/research-assistant\?user_id=u7$/) + await expect(page.getByTestId('agent-page')).toBeVisible() + }) +}) + +test.describe('Launcher with agents', () => { + const now = Date.now() + const hours = h => now - h * 3600_000 + + async function open(page, opts = {}) { + await installFakeStream(page) + const seen = await mockAgents(page, { + observables: { 'research-assistant': [{ id: 7, name: 'read_page', creation: { function_definition: { name: 'read_page' } } }] }, + ...opts, + }) + const runs = [] + for (let i = 0; i < 16; i++) { + runs.push(storedRun('code-reviewer', { + id: `r_c${i}`, task: `Review ${i}`, startedAt: hours(60 - i), status: i === 3 ? 'failed' : i === 5 ? 'stopped' : 'done', outcome: 'ok', error: i === 3 ? 'boom' : '', + })) + } + await seedRuns(page, 'code-reviewer', runs.reverse()) + await seedRuns(page, 'daily-digest', [storedRun('daily-digest', { id: 'r_d1', task: 'Collect news', startedAt: hours(2), status: 'failed', error: 'A page did not answer.' })]) + await page.goto('/app/agents') + await expect(page.getByTestId('agents-list')).toBeVisible() + return seen + } + + test('shows each agent with its model, attached chips and run strip', async ({ page }) => { + await open(page) + const row = page.locator('[data-agent="research-assistant"]') + await expect(row.getByText('qwen3-14b-instruct')).toBeVisible() + await expect(row.getByRole('link', { name: 'Knowledge base' })).toHaveAttribute('href', '/app/collections') + await expect(row.getByRole('link', { name: 'citations' })).toHaveAttribute('href', '/app/skills') + const strip = page.locator('[data-agent="code-reviewer"] [data-testid="agent-run-strip"]') + await expect(strip.locator('.ag-pip')).toHaveCount(14) + await expect(strip.locator('.ag-pip[data-state="failed"]')).toHaveCount(1) + await expect(strip.locator('.ag-pip[data-state="stopped"]')).toHaveCount(1) + await expect(page.locator('[data-agent="code-reviewer"]')).toContainText('of 16 runs finished') + }) + + test('an agent with no runs says so instead of showing a record', async ({ page }) => { + await open(page) + await expect(page.locator('[data-agent="research-assistant"]')).toContainText('No runs recorded here yet') + }) + + test('the Now strip lists work in flight and a run that failed in the last day', async ({ page }) => { + await open(page) + const now = page.getByTestId('agents-now') + await expect(now).toContainText('research-assistant') + await expect(now).toContainText('Working') + await expect(now).toContainText('daily-digest') + await expect(now).toContainText('Failed') + await expect(now).toContainText('A page did not answer.') + await now.getByRole('link', { name: 'Open' }).last().click() + await expect(page).toHaveURL(/\/app\/agents\/daily-digest\/runs\/r_d1$/) + }) + + test('Run again from the Now strip puts the task in the agent page box', async ({ page }) => { + await open(page) + await page.getByTestId('agents-now').getByRole('link', { name: 'Run again' }).click() + await expect(page).toHaveURL(/\/app\/agents\/daily-digest$/) + await expect(page.locator('#ag-task-input')).toHaveValue('Collect news') + }) + + test('search narrows the list and says when nothing matches', async ({ page }) => { + await open(page) + await page.getByLabel('Search agents...').fill('review') + await expect(page.locator('[data-agent]')).toHaveCount(1) + await page.getByLabel('Search agents...').fill('zzz') + await expect(page.getByText('No matching agents')).toBeVisible() + }) + + test('the row menu pauses an agent through the API', async ({ page }) => { + const seen = await open(page) + await page.locator('[data-agent="code-reviewer"]').getByRole('button', { name: /More actions/ }).click() + await page.getByRole('menuitem', { name: 'Pause' }).click() + await expect.poll(() => seen.pauses).toEqual(['code-reviewer']) + }) + + test('no Now strip when nothing needs a look', async ({ page }) => { + await installFakeStream(page) + await mockAgents(page, { agents: { 'code-reviewer': AGENTS['code-reviewer'] } }) + await page.goto('/app/agents') + await expect(page.getByTestId('agents-list')).toBeVisible() + await expect(page.getByTestId('agents-now')).toHaveCount(0) }) }) diff --git a/core/http/react-ui/e2e/audio-transform.spec.js b/core/http/react-ui/e2e/audio-transform.spec.js index c428e95f8..306ae77af 100644 --- a/core/http/react-ui/e2e/audio-transform.spec.js +++ b/core/http/react-ui/e2e/audio-transform.spec.js @@ -143,6 +143,8 @@ test.describe('Audio Transform', () => { await expect(page.getByText('mic.wav')).toBeVisible() // Set a backend tuning param so the form posts params[noise_gate]=true. + // The key=value box lives in the Advanced fold. + await page.getByRole('button', { name: /Advanced parameters/ }).click() await page.locator('.textarea').fill('noise_gate=true') await page.getByRole('button', { name: /Transform/ }).last().click() diff --git a/core/http/react-ui/e2e/backend-logs.spec.js b/core/http/react-ui/e2e/backend-logs.spec.js index 0390aa6bc..716e936b4 100644 --- a/core/http/react-ui/e2e/backend-logs.spec.js +++ b/core/http/react-ui/e2e/backend-logs.spec.js @@ -1,64 +1,142 @@ import { test, expect } from './coverage-fixtures.js' +import { LOG_LINES, mockOperate } from './operate-fixtures.js' + +// Runtime, Logs: what a backend process printed, read live over the log +// stream. The viewer filters by stream and by text, follows the end, shows or +// hides times, exports, and clears (with an undo window). + +async function openLogs(page, { lines = LOG_LINES, path = '/app/backend-logs/qwen3-8b-instruct' } = {}) { + await mockOperate(page) + await page.routeWebSocket('**/ws/backend-logs/**', ws => { + ws.send(JSON.stringify({ type: 'initial', lines })) + }) + await page.goto(path) +} + +const lineRows = page => page.locator('[data-log-line]') test.describe('Backend Logs', () => { test('model detail page shows title', async ({ page }) => { - await page.goto('/app/backend-logs/mock-model') - await expect(page.locator('.page-title')).toContainText('mock-model') + await openLogs(page) + await expect(page.locator('.page-title')).toContainText('qwen3-8b-instruct') }) - test('no back arrow link on detail page', async ({ page }) => { - await page.goto('/app/backend-logs/mock-model') - await expect(page.locator('a[href="/app/backend-logs"]')).not.toBeVisible() + test('the way out is the Backends list, which is where a process comes from', async ({ page }) => { + await openLogs(page) + await expect(page.locator('.lg-back')).toHaveAttribute('href', '/app/backends?view=installed') }) - test('filter buttons are visible', async ({ page }) => { - await page.goto('/app/backend-logs/mock-model') - await expect(page.locator('button', { hasText: 'All' })).toBeVisible() - await expect(page.locator('button', { hasText: 'stdout' })).toBeVisible() - await expect(page.locator('button', { hasText: 'stderr' })).toBeVisible() + test('reads the lines the process printed, with the error line marked', async ({ page }) => { + await openLogs(page) + await expect(lineRows(page)).toHaveCount(5) + await expect(lineRows(page).nth(3)).toContainText('out of memory') + await expect(lineRows(page).nth(3)).toHaveAttribute('data-stream', 'stderr') + await expect(lineRows(page).nth(3)).toContainText('err') }) - test('filter buttons toggle active state', async ({ page }) => { - await page.goto('/app/backend-logs/mock-model') - - const allBtn = page.locator('button', { hasText: 'All' }) - const stdoutBtn = page.locator('button', { hasText: 'stdout' }) - - // All is active by default - await expect(allBtn).toHaveClass(/btn-primary/) - - // Click stdout - await stdoutBtn.click() - await expect(stdoutBtn).toHaveClass(/btn-primary/) - await expect(allBtn).not.toHaveClass(/btn-primary/) + test('stream buttons narrow the lines and say which is on', async ({ page }) => { + await openLogs(page) + const all = page.getByRole('button', { name: 'All', exact: true }) + const stderr = page.getByRole('button', { name: 'stderr', exact: true }) + await expect(all).toHaveAttribute('aria-pressed', 'true') + await stderr.click() + await expect(stderr).toHaveAttribute('aria-pressed', 'true') + await expect(all).toHaveAttribute('aria-pressed', 'false') + await expect(lineRows(page)).toHaveCount(1) + await page.getByRole('button', { name: 'stdout', exact: true }).click() + await expect(lineRows(page)).toHaveCount(4) }) - test('export button is present', async ({ page }) => { - await page.goto('/app/backend-logs/mock-model') - await expect(page.locator('button', { hasText: 'Export' })).toBeVisible() + test('the filter keeps the lines that contain the text', async ({ page }) => { + await openLogs(page) + await page.getByRole('textbox', { name: 'Filter lines' }).fill('backend') + await expect(lineRows(page)).toHaveCount(2) + await page.getByRole('textbox', { name: 'Filter lines' }).fill('nothing like this') + await expect(page.getByText('No line contains that text.')).toBeVisible() }) - test('auto-scroll checkbox is present', async ({ page }) => { - await page.goto('/app/backend-logs/mock-model') - await expect(page.locator('text=Auto-scroll')).toBeVisible() + test('Times hides the time and the stream label for easier copying', async ({ page }) => { + await openLogs(page) + const times = page.getByRole('switch', { name: 'Times' }) + await expect(times).toHaveAttribute('aria-checked', 'true') + await expect(page.locator('.lg-line__time').first()).toBeVisible() + await times.click() + await expect(times).toHaveAttribute('aria-checked', 'false') + await expect(page.locator('.lg-line__time')).toHaveCount(0) + await expect(page.locator('.lg-line__stream')).toHaveCount(0) }) - test('clear button is present', async ({ page }) => { - await page.goto('/app/backend-logs/mock-model') - await expect(page.locator('button', { hasText: 'Clear' })).toBeVisible() + test('Follow is on by default and can be turned off', async ({ page }) => { + await openLogs(page) + const follow = page.getByRole('switch', { name: 'Follow' }) + await expect(follow).toHaveAttribute('aria-checked', 'true') + await follow.click() + await expect(follow).toHaveAttribute('aria-checked', 'false') }) - test('details toggle button is present and toggles', async ({ page }) => { - await page.goto('/app/backend-logs/mock-model') + test('Export offers the visible lines as a file', async ({ page }) => { + await openLogs(page) + const download = page.waitForEvent('download') + await page.getByRole('button', { name: 'Export' }).click() + expect((await download).suggestedFilename()).toMatch(/^backend-logs-qwen3-8b-instruct-\d{4}-\d{2}-\d{2}\.json$/) + }) - // "Text only" button visible by default (details are shown) - const toggleBtn = page.locator('button', { hasText: 'Text only' }) - await expect(toggleBtn).toBeVisible() + test('Clear hides the lines at once and wipes them when the window ends', async ({ page }) => { + await openLogs(page) + const calls = [] + await page.route('**/api/backend-logs/qwen3-8b-instruct/clear', route => { calls.push('clear'); return route.fulfill({ json: {} }) }) + await page.getByRole('button', { name: 'Clear' }).click() + await expect(lineRows(page)).toHaveCount(0) + await expect(page.getByTestId('logs-undo-toast')).toContainText('Clearing the log') + expect(calls).toEqual([]) + await expect.poll(() => calls, { timeout: 15_000 }).toEqual(['clear']) + }) - // Click to hide details - await toggleBtn.click() + test('Undo brings the lines back and never calls the server', async ({ page }) => { + await openLogs(page) + const calls = [] + await page.route('**/api/backend-logs/qwen3-8b-instruct/clear', route => { calls.push('clear'); return route.fulfill({ json: {} }) }) + await page.getByRole('button', { name: 'Clear' }).click() + await page.getByTestId('logs-undo-toast').getByRole('button', { name: 'Undo' }).click() + await expect(lineRows(page)).toHaveCount(5) + await page.waitForTimeout(7_000) + expect(calls).toEqual([]) + }) - // Button label changes to "Show details" - await expect(page.locator('button', { hasText: 'Show details' })).toBeVisible() + test('says so when the process has printed nothing', async ({ page }) => { + await openLogs(page, { lines: [] }) + await expect(page.getByText('No log lines')).toBeVisible() + await expect(page.getByText('Log output will appear here as the backend process runs.')).toBeVisible() + }) + + test('a picker switches to another process', async ({ page }) => { + await openLogs(page) + await page.getByRole('combobox', { name: 'Process' }).selectOption('whisper-large-v3') + await expect(page).toHaveURL(/\/app\/backend-logs\/whisper-large-v3$/) + }) + + test('the Logs tab lists the processes that have output', async ({ page }) => { + await mockOperate(page) + await page.goto('/app/backends') + await page.locator('.hub-subnav').getByRole('link', { name: 'Logs' }).click() + await expect(page).toHaveURL(/\/app\/backend-logs$/) + const list = page.getByTestId('logs-processes') + await expect(list.getByRole('link', { name: /qwen3-8b-instruct/ })).toHaveAttribute('href', '/app/backend-logs/qwen3-8b-instruct') + await expect(page.locator('.dk-hubtabs [data-hub-tab="runtime"]')).toHaveAttribute('aria-current', 'page') + }) + + test('says so when no process has printed anything', async ({ page }) => { + await mockOperate(page) + await page.route('**/api/backend-logs', route => route.fulfill({ json: [] })) + await page.goto('/app/backend-logs') + await expect(page.getByText('No process has printed anything')).toBeVisible() + }) + + test('fits a phone', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 800 }) + await openLogs(page) + await expect(lineRows(page)).toHaveCount(5) + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) + expect(overflow).toBeLessThanOrEqual(1) }) }) diff --git a/core/http/react-ui/e2e/backends-lifecycle.spec.js b/core/http/react-ui/e2e/backends-lifecycle.spec.js index 28ebc88a7..7de4b6215 100644 --- a/core/http/react-ui/e2e/backends-lifecycle.spec.js +++ b/core/http/react-ui/e2e/backends-lifecycle.spec.js @@ -117,12 +117,19 @@ test.describe('Backends lifecycle page', () => { await expect(page.getByRole('link', { name: 'Installed', exact: true })).toHaveAttribute('aria-current', 'page') await expect(page.getByRole('textbox', { name: /search installed backends/i })).toHaveValue('llama') await expect(page.getByRole('tab', { name: /updates/i })).toHaveAttribute('aria-selected', 'true') - await expect(page.locator('[data-testid="backends-installed-pane"]')).toContainText('llama-cpp') + // The open row is the one in the URL. + await expect(backendRow(page, 'llama-cpp')).toHaveAttribute('data-selected', 'true') + await expect(page.locator('[data-testid="backends-installed-pane"]')).toContainText('v1.1.0') await expect(page).toHaveURL(/[?&]target=worker-1(?:&|$)/) await page.getByRole('link', { name: 'Catalog', exact: true }).click() await expect(page).toHaveURL(/[?&]target=worker-1(?:&|$)/) await expect(page.getByText(/installing only on GPU worker/i)).toBeVisible() + // The row opened from the URL; a click closes it and a second one opens it. + // Neither touches the scope. + await backendRow(page, 'llama-cpp').click() + await expect(page).not.toHaveURL(/[?&]backend=/) + await expect(page).toHaveURL(/[?&]target=worker-1(?:&|$)/) await backendRow(page, 'llama-cpp').click() await expect(page).toHaveURL(/[?&]backend=llama-cpp(?:&|$)/) await expect(page).toHaveURL(/[?&]target=worker-1(?:&|$)/) @@ -136,8 +143,8 @@ test.describe('Backends lifecycle page', () => { }) await page.goto('/app/backends?view=installed&backend=llama-cpp') - await expect(page.getByRole('button', { name: /upgrade to v1\.1\.0/i })).toBeVisible() - await page.getByRole('button', { name: 'Actions for llama-cpp' }).click() + await expect(page.getByRole('button', { name: /update to v1\.1\.0/i })).toBeVisible() + await page.getByRole('button', { name: 'Actions for llama-cpp', exact: true }).click() const reinstall = page.getByRole('menuitem', { name: 'Reinstall backend' }) await expect(reinstall).toBeVisible() await reinstall.click() @@ -147,7 +154,7 @@ test.describe('Backends lifecycle page', () => { test('keeps Reinstall beside Upgrade for an installed backend in Catalog', async ({ page }) => { await page.goto('/app/backends?view=catalog&backend=llama-cpp') - await expect(page.locator('button[title^="Upgrade to"]')).toBeVisible() + await expect(page.locator('button[title^="Update to"]')).toBeVisible() await expect(page.locator('button[title="Reinstall"]')).toBeVisible() }) @@ -158,7 +165,7 @@ test.describe('Backends lifecycle page', () => { })) await page.goto('/app/backends?view=installed&backend=llama-cpp') - await page.getByRole('button', { name: 'Actions for llama-cpp' }).click() + await page.getByRole('button', { name: 'Actions for llama-cpp', exact: true }).click() await page.getByRole('menuitem', { name: 'Reinstall backend' }).click() const detail = page.locator('[data-testid="backends-installed-pane"]') @@ -172,7 +179,7 @@ test.describe('Backends lifecycle page', () => { })) await page.goto('/app/backends?view=installed') - await page.getByRole('button', { name: /upgrade all/i }).click() + await page.getByRole('button', { name: /update all/i }).click() await expect(page.getByRole('alert')).toContainText('upgrade registry unavailable') }) @@ -199,7 +206,7 @@ test.describe('Backends lifecycle page', () => { }) await page.goto('/app/backends?view=installed') - await page.getByRole('button', { name: /upgrade all/i }).click() + await page.getByRole('button', { name: /update all/i }).click() await expect(page.getByRole('alert')).toContainText('first registry unavailable') await expect.poll(() => laterUpgradeRequests).toBe(1) @@ -269,7 +276,7 @@ test.describe('Backends lifecycle page', () => { }) await page.goto('/app/backends?view=installed&backend=llama-cpp') - await page.getByRole('button', { name: 'Actions for llama-cpp' }).click() + await page.getByRole('button', { name: 'Actions for llama-cpp', exact: true }).click() await page.getByRole('menuitem', { name: 'Delete backend' }).click() await expect(page.getByRole('alertdialog')).toContainText('Delete backend llama-cpp?') expect(deleteRequests).toBe(0) @@ -278,18 +285,23 @@ test.describe('Backends lifecycle page', () => { await expect.poll(() => deleteRequests).toBe(1) }) - test('narrow detail Back restores focus to the originating backend', async ({ page }) => { + test('on a phone a backend opens in place and closes again, with its action in reach', async ({ page }) => { await page.setViewportSize({ width: 390, height: 800 }) await page.goto('/app/backends?view=installed') const backend = backendRow(page, 'llama-cpp') - await backend.click() - await expect(page.locator('[data-testid="backends-installed-pane"]')).toContainText('llama-cpp') - await expect(backend).not.toBeVisible() - - await page.locator('[data-testid="backends-installed-back"]').click() - await expect(backend).toBeVisible() - await expect(backend).toBeFocused() + // The row is a card: name, state and the action, no sideways scroll. + await expect(backend.getByRole('button', { name: 'Update llama-cpp' })).toBeVisible() + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) + expect(overflow).toBeLessThanOrEqual(1) + + await backend.click() + await expect(backend).toHaveAttribute('data-selected', 'true') + await expect(page.locator('[data-testid="backends-installed-pane"]')).toContainText('v1.1.0') + await expect(backend).toBeVisible() + + await backend.getByRole('button', { name: /Hide details for llama-cpp/ }).click() + await expect(page.locator('[data-testid="backends-installed-pane"]')).toHaveCount(0) }) }) diff --git a/core/http/react-ui/e2e/backends-management.spec.js b/core/http/react-ui/e2e/backends-management.spec.js index 34408c92b..16c9362da 100644 --- a/core/http/react-ui/e2e/backends-management.spec.js +++ b/core/http/react-ui/e2e/backends-management.spec.js @@ -1,8 +1,9 @@ import { test, expect } from './coverage-fixtures.js' -// Backends admin page (src/pages/Backends.jsx). -const PANE = '[data-testid="backends-pane"]' -const railItem = (page, name) => page.locator(`[data-entity="${name}"]`) +// Backends admin page (src/pages/Backends.jsx): one list with an Installed and +// a Catalog view. A row is a name, a version, a state and the one action; it +// opens in place into the rest. +const row = (page, name) => page.locator(`[data-entity="${name}"]`) test.describe('Backends management page', () => { test.beforeEach(async ({ page }) => { @@ -12,7 +13,7 @@ test.describe('Backends management page', () => { test('renders the management header and gallery tabs', async ({ page }) => { await expect(page).toHaveURL(/\/app\/backends$/) await expect(page.getByRole('heading', { name: 'Backend Management' })).toBeVisible() - await expect(page.getByRole('button', { name: 'Manual Install' })).toBeVisible() + await expect(page.getByRole('button', { name: 'From URL' })).toBeVisible() await expect(page.getByRole('button').filter({ hasText: /^All$/ })).toBeVisible() await expect(page.getByRole('button').filter({ hasText: /^Image$/ })).toBeVisible() }) @@ -24,8 +25,8 @@ test.describe('Backends management page', () => { await expect(search).toHaveValue('whisper') }) - test('Manual Install reveals the OCI install form', async ({ page }) => { - await page.getByRole('button', { name: 'Manual Install' }).click() + test('From URL reveals the OCI install form', async ({ page }) => { + await page.getByRole('button', { name: 'From URL' }).click() await expect(page.getByPlaceholder('oci://quay.io/example/backend:latest')).toBeVisible() }) }) @@ -52,14 +53,11 @@ test.describe('Backends management page - Markdown descriptions', () => { }) }) await page.goto('/app/backends') - // Rendered means the rail has entries. The old gate waited on a column - // header, and there are no columns now. - await expect(railItem(page, 'markdown-backend')).toBeVisible({ timeout: 10_000 }) + await expect(row(page, 'markdown-backend')).toBeVisible({ timeout: 10_000 }) }) - test('the pane lede shows the description as clean text, not raw Markdown', async ({ page }) => { - await railItem(page, 'markdown-backend').click() - const cell = page.locator('.detail-pane__lede') + test('the row shows the description as clean text, not raw Markdown', async ({ page }) => { + const cell = row(page, 'markdown-backend').locator('.dk-table-sub') await expect(cell).toHaveText(STRIPPED_DESCRIPTION) // The syntax itself must be gone, not merely rendered somewhere. @@ -71,23 +69,27 @@ test.describe('Backends management page - Markdown descriptions', () => { await expect(cell.locator('h1')).toHaveCount(0) }) - test("the lede's tooltip carries the stripped text, not raw Markdown", async ({ page }) => { - await railItem(page, 'markdown-backend').click() - await expect(page.locator('.detail-pane__lede')).toHaveAttribute('title', STRIPPED_DESCRIPTION) + test("the row's tooltip carries the stripped text, not raw Markdown", async ({ page }) => { + await expect(row(page, 'markdown-backend').locator('.dk-table-sub')).toHaveAttribute('title', STRIPPED_DESCRIPTION) }) - test('a backend with no description renders no lede rather than a blank one', async ({ page }) => { - // The table needed a placeholder because an empty cell in a grid of full - // ones reads as a fault. The pane has no grid to keep aligned, so it omits - // the line - but must never print "undefined". - await railItem(page, 'plain-backend').click() - await expect(page.locator(PANE)).toContainText('plain-backend') - await expect(page.locator('.detail-pane__lede')).toHaveCount(0) - await expect(page.locator(PANE)).not.toContainText('undefined') + test('opening the row renders the Markdown, with its link', async ({ page }) => { + await row(page, 'markdown-backend').click() + const detail = page.getByTestId('backend-detail') + await expect(detail.locator('.bk-detail__desc h1')).toHaveText('InsightFace') + await expect(detail.locator('.bk-detail__desc a[href="https://example.com/docs"]')).toBeVisible() + }) + + test('a backend with no description renders no blank line and never "undefined"', async ({ page }) => { + await expect(row(page, 'plain-backend')).toContainText('plain-backend') + await expect(row(page, 'plain-backend')).not.toContainText('undefined') + await row(page, 'plain-backend').click() + await expect(page.getByTestId('backend-detail')).not.toContainText('undefined') + await expect(page.locator('.bk-detail__desc')).toHaveCount(0) }) }) -test.describe('Backends gallery - split view', () => { +test.describe('Backends gallery - list', () => { test.beforeEach(async ({ page }) => { await page.route('**/api/backends*', (route) => { route.fulfill({ @@ -102,46 +104,50 @@ test.describe('Backends gallery - split view', () => { }) }) await page.goto('/app/backends') - await expect(railItem(page, 'llama-cpp')).toBeVisible({ timeout: 10_000 }) + await expect(row(page, 'llama-cpp')).toBeVisible({ timeout: 10_000 }) }) - test('the gallery renders no table', async ({ page }) => { + test('is a table with columns, not a rail and a pane', async ({ page }) => { await expect(page.locator('[data-testid="backends"]')).toBeVisible() - await expect(page.locator('table thead th')).toHaveCount(0) + await expect(page.locator('table thead th').first()).toBeVisible() + await expect(page.locator('[data-testid="backends-pane"]')).toHaveCount(0) }) - test('with nothing selected the pane describes the host', async ({ page }) => { - await expect(page.locator(PANE)).toContainText('This host') - await expect(page.locator('[data-testid="backends-back"]')).toHaveCount(0) + test('opening a row shows its facts, and the chevron closes it', async ({ page }) => { + await row(page, 'llama-cpp').click() + const detail = page.getByTestId('backend-detail') + await expect(detail).toContainText('MIT') + await expect(detail).toContainText('chat') + await row(page, 'llama-cpp').getByRole('button', { name: /Hide details for llama-cpp/ }).click() + await expect(page.getByTestId('backend-detail')).toHaveCount(0) }) - test('choosing a backend turns the pane into its detail, and back returns', async ({ page }) => { - await railItem(page, 'llama-cpp').click() - await expect(page.locator(PANE)).toContainText('llama-cpp') - await expect(page.locator(PANE)).toContainText('MIT') - await expect(page.locator(PANE)).not.toContainText('This host') - - await page.locator('[data-testid="backends-back"]').click() - await expect(page.locator(PANE)).toContainText('This host') + test('only one row is open at a time', async ({ page }) => { + await row(page, 'llama-cpp').click() + await row(page, 'whisper').click() + await expect(page.getByTestId('backend-detail')).toHaveCount(1) + await expect(page.getByTestId('backend-detail')).toContainText('transcript') + await expect(page).toHaveURL(/[?&]backend=whisper/) }) - test('the selection lives in the URL and survives a reload', async ({ page }) => { - await railItem(page, 'whisper').click() + test('the open row lives in the URL and survives a reload', async ({ page }) => { + await row(page, 'whisper').click() await expect(page).toHaveURL(/[?&]backend=whisper/) await page.reload() - await expect(railItem(page, 'whisper')).toBeVisible({ timeout: 10_000 }) - await expect(page.locator('[data-testid="backends-back"]')).toBeVisible() + await expect(row(page, 'whisper')).toBeVisible({ timeout: 10_000 }) + await expect(page.getByTestId('backend-detail')).toContainText('transcript') }) - - test('the rail groups while browsing and flattens on a query', async ({ page }) => { - await expect(page.locator('[data-testid^="backends-rail-group-"]').first()).toBeVisible() - await page.locator('input[placeholder*="Search backends"]').fill('llama') - await expect(page.locator('[data-testid^="backends-rail-group-"]')).toHaveCount(0) + test('a filter chip narrows the list', async ({ page }) => { + await page.getByRole('button', { name: 'Image', exact: true }).click() + await expect(page).toHaveURL(/[?&]state=image/) + await expect(row(page, 'diffusers')).toBeVisible() + await expect(row(page, 'llama-cpp')).toHaveCount(0) }) test('an installed backend states its version, an absent one says so', async ({ page }) => { - await expect(railItem(page, 'llama-cpp')).toContainText('v1.52.0') - await expect(railItem(page, 'diffusers')).toContainText('not installed') + await expect(row(page, 'llama-cpp')).toContainText('v1.52.0') + await expect(row(page, 'llama-cpp')).toContainText('Current') + await expect(row(page, 'diffusers')).toContainText('Not installed') }) }) diff --git a/core/http/react-ui/e2e/backends-notice.spec.js b/core/http/react-ui/e2e/backends-notice.spec.js index cf7b5cbeb..882d47653 100644 --- a/core/http/react-ui/e2e/backends-notice.spec.js +++ b/core/http/react-ui/e2e/backends-notice.spec.js @@ -1,23 +1,23 @@ import { test, expect } from './coverage-fixtures.js' +import { UPGRADE_LLAMA, mockOperate } from './operate-fixtures.js' -// A notice is a hairline with a coloured left edge, not a filled panel. A tint -// makes every notice shout at the weight of an error, which is how notices stop -// being read — and it is the same treatment the Operate overview uses for the -// rows that want a decision. +// An update is said where it can be acted on: in the row's state, in the one +// primary button above the list and on the tab. It is not a banner. A filled +// panel makes every notice shout at the weight of an error, and a coloured +// rail on its edge is decoration that repeats on every item. -test('the backends notice is an edge, not a filled card', async ({ page }) => { - // The upgrade banner is the notice worth pinning, so make one exist. - await page.route('**/api/backends/upgrades', route => route.fulfill({ - json: { 'llama-cpp': { backend_name: 'llama-cpp', installed_version: '0.9.4', available_version: '0.9.7' } }, - })) - await page.goto('/app/backends') - const notice = page.locator('.bk-notice', { hasText: /update/i }).first() - await expect(notice).toBeVisible() - const s = await notice.evaluate(el => { - const cs = getComputedStyle(el) - return { bg: cs.backgroundColor, left: parseFloat(cs.borderLeftWidth), top: parseFloat(cs.borderTopWidth) } - }) - expect(s.bg).toMatch(/rgba\(0, 0, 0, 0\)|transparent/) - expect(s.left).toBeGreaterThanOrEqual(3) - expect(s.top).toBeLessThanOrEqual(1) +test('an update is a row state and a header button, not a banner or a rail', async ({ page }) => { + await mockOperate(page, { upgrades: UPGRADE_LLAMA }) + await page.goto('/app/backends?view=installed') + + const row = page.locator('[data-entity="llama-cpp"]') + await expect(row).toContainText('Update 0.9.7') + await expect(page.getByRole('button', { name: 'Update all (1)' })).toBeVisible() + await expect(page.locator('.dk-hubtabs [data-hub-tab="runtime"] .dk-hubtab-attn')).toContainText('1') + + // Nothing here is a filled card with an edge. + await expect(page.locator('.bk-notice')).toHaveCount(0) + const edges = await page.evaluate(() => [...document.querySelectorAll('.bk-table tr, .op-notice, .bk-recommend, .bk-manual')] + .map(el => parseFloat(getComputedStyle(el).borderLeftWidth))) + expect(Math.max(0, ...edges)).toBeLessThanOrEqual(1) }) diff --git a/core/http/react-ui/e2e/backends-runtime.spec.js b/core/http/react-ui/e2e/backends-runtime.spec.js new file mode 100644 index 000000000..fe208d62a --- /dev/null +++ b/core/http/react-ui/e2e/backends-runtime.spec.js @@ -0,0 +1,307 @@ +import { test, expect } from './coverage-fixtures.js' +import { + CATALOG, FAILED_OP, INSTALLED, RUNNING_OP, UPGRADE_LLAMA, catalogBackend, installedBackend, mockOperate, +} from './operate-fixtures.js' + +// Runtime, Backends: Installed and Catalog as two views of one list. A row says +// what the backend is doing now, carries the one button that matters, and opens +// into the rest. + +const row = (page, name) => page.locator(`[data-entity="${name}"]`) +const jsonOk = route => route.fulfill({ json: { status: 'ok' } }) + +const VLLM_INSTALLING = { ...RUNNING_OP, name: 'vllm', id: 'vllm', fullName: 'localai@vllm', jobID: 'job-vllm', progress: 6 } + +test.describe('Backends views', () => { + test('Installed and Catalog carry their counts, and the current one says so', async ({ page }) => { + await mockOperate(page) + await page.goto('/app/backends?view=installed') + const installed = page.getByRole('link', { name: 'Installed', exact: true }) + const catalog = page.getByRole('link', { name: 'Catalog', exact: true }) + await expect(installed).toHaveAttribute('aria-current', 'page') + await expect(installed).toContainText('3') + await expect(catalog).toContainText('6') + await expect(row(page, 'llama-cpp')).toBeVisible() + await catalog.click() + await expect(catalog).toHaveAttribute('aria-current', 'page') + await expect(row(page, 'vllm')).toBeVisible() + }) + + test('the Installed list puts what has an update first', async ({ page }) => { + await mockOperate(page, { upgrades: { whisper: { backend_name: 'whisper', installed_version: '1.7.2', available_version: '1.8.0' } } }) + await page.goto('/app/backends?view=installed') + await expect(page.getByTestId('backend-row').first()).toHaveAttribute('data-entity', 'whisper') + await expect(row(page, 'whisper')).toContainText('Update 1.8.0') + }) + + test('an empty Installed list sends you to the catalog', async ({ page }) => { + await mockOperate(page, { installed: [] }) + await page.goto('/app/backends?view=installed') + await expect(page.getByText('No backends installed yet')).toBeVisible() + await page.getByRole('link', { name: 'Open the catalog' }).click() + await expect(page.getByRole('link', { name: 'Catalog', exact: true })).toHaveAttribute('aria-current', 'page') + }) + + test('a first run recommends llama-cpp and installs it with one button', async ({ page }) => { + await mockOperate(page, { catalog: CATALOG.map(b => ({ ...b, installed: false })), installed: [] }) + const calls = [] + await page.route('**/api/backends/install/llama-cpp', route => { calls.push('install'); return jsonOk(route) }) + await page.goto('/app/backends') + const recommend = page.getByTestId('backends-recommend') + await expect(recommend).toContainText('Start with llama-cpp') + await recommend.getByRole('button', { name: 'Install llama-cpp' }).click() + await expect.poll(() => calls).toEqual(['install']) + }) + + test('no recommendation once something is installed', async ({ page }) => { + await mockOperate(page) + await page.goto('/app/backends') + await expect(row(page, 'llama-cpp')).toBeVisible() + await expect(page.getByTestId('backends-recommend')).toHaveCount(0) + }) +}) + +test.describe('Backend install progress', () => { + test('shows progress in the row and offers Cancel with an undo window', async ({ page }) => { + await mockOperate(page, { operations: [VLLM_INSTALLING] }) + const calls = [] + await page.route('**/api/operations/job-vllm/cancel', route => { calls.push('cancel'); return jsonOk(route) }) + await page.goto('/app/backends') + + const vllm = row(page, 'vllm') + await expect(vllm.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '6') + await expect(vllm).toContainText('6%') + await vllm.getByRole('button', { name: 'Cancel vllm' }).click() + await expect(page.getByTestId('backends-undo-toast')).toContainText('Cancelling vllm') + expect(calls).toEqual([]) + await expect.poll(() => calls, { timeout: 15_000 }).toEqual(['cancel']) + }) + + test('Undo keeps the install going', async ({ page }) => { + await mockOperate(page, { operations: [VLLM_INSTALLING] }) + const calls = [] + await page.route('**/api/operations/job-vllm/cancel', route => { calls.push('cancel'); return jsonOk(route) }) + await page.goto('/app/backends') + await row(page, 'vllm').getByRole('button', { name: 'Cancel vllm' }).click() + await page.getByTestId('backends-undo-toast').getByRole('button', { name: 'Undo' }).click() + await expect(page.getByTestId('backends-undo-toast')).toHaveCount(0) + await expect(row(page, 'vllm').getByRole('progressbar')).toBeVisible() + await page.waitForTimeout(9_000) + expect(calls).toEqual([]) + }) + + test('a queued install says so and a failed one says why, with Retry', async ({ page }) => { + await mockOperate(page, { + operations: [ + { ...VLLM_INSTALLING, isQueued: true, progress: 0 }, + { ...FAILED_OP, id: 'diffusers', name: 'diffusers', fullName: 'localai@diffusers', jobID: 'job-diff', isBackend: true, error: 'no space left on device' }, + ], + }) + const calls = [] + await page.route('**/api/backends/install/diffusers', route => { calls.push('install'); return jsonOk(route) }) + await page.goto('/app/backends') + await expect(row(page, 'vllm')).toContainText('Queued') + await expect(row(page, 'diffusers')).toContainText('Failed') + await expect(row(page, 'diffusers')).toContainText('no space left on device') + await row(page, 'diffusers').getByRole('button', { name: 'Retry diffusers' }).click() + await expect.poll(() => calls).toEqual(['install']) + }) +}) + +test.describe('Backend updates', () => { + test('Update on the row starts that update only', async ({ page }) => { + await mockOperate(page, { upgrades: UPGRADE_LLAMA }) + const calls = [] + await page.route('**/api/backends/upgrade/llama-cpp', route => { calls.push('llama-cpp'); return jsonOk(route) }) + await page.goto('/app/backends?view=installed') + await row(page, 'llama-cpp').getByRole('button', { name: 'Update llama-cpp' }).click() + await expect.poll(() => calls).toEqual(['llama-cpp']) + }) + + test('Update all starts every update and counts them', async ({ page }) => { + await mockOperate(page, { + upgrades: { + ...UPGRADE_LLAMA, + whisper: { backend_name: 'whisper', installed_version: '1.7.2', available_version: '1.8.0' }, + }, + }) + const calls = [] + await page.route('**/api/backends/upgrade/*', route => { calls.push(new URL(route.request().url()).pathname.split('/').pop()); return jsonOk(route) }) + await page.goto('/app/backends?view=installed') + await page.getByRole('button', { name: 'Update all (2)' }).click() + await expect.poll(() => calls.sort()).toEqual(['llama-cpp', 'whisper']) + }) + + test('Check for updates asks the server to check and says what it found', async ({ page }) => { + await mockOperate(page) + let checks = 0 + await page.route('**/api/backends/upgrades/check', route => { checks += 1; return route.fulfill({ json: UPGRADE_LLAMA }) }) + await page.goto('/app/backends?view=installed') + await page.getByRole('button', { name: 'Check for updates' }).click() + await expect.poll(() => checks).toBe(1) + await expect(page.getByRole('button', { name: 'Update all (1)' })).toBeVisible() + }) + + test('says when every backend is current', async ({ page }) => { + await mockOperate(page) + await page.route('**/api/backends/upgrades/check', route => route.fulfill({ json: {} })) + await page.goto('/app/backends?view=installed') + await page.getByRole('button', { name: 'Check for updates' }).click() + await expect(page.getByText('Every installed backend is current')).toBeVisible() + }) +}) + +test.describe('Removing a backend', () => { + test('names the models that would stop working', async ({ page }) => { + await mockOperate(page) + const calls = [] + await page.route('**/api/backends/system/delete/llama-cpp', route => { calls.push('delete'); return jsonOk(route) }) + await page.goto('/app/backends?view=installed') + await row(page, 'llama-cpp').getByRole('button', { name: 'Actions for llama-cpp' }).click() + await page.getByRole('menuitem', { name: 'Delete backend' }).click() + + const dialog = page.getByRole('alertdialog') + await expect(dialog).toContainText('Delete backend llama-cpp?') + await expect(dialog.getByTestId('backend-remove-warning')).toContainText('2 models ask for this backend') + await expect(dialog.getByTestId('backend-remove-warning')).toContainText('gemma-3-12b-it, qwen3-8b-instruct') + expect(calls).toEqual([]) + await dialog.getByRole('button', { name: 'Delete', exact: true }).click() + await expect.poll(() => calls).toEqual(['delete']) + }) + + test('a backend nothing depends on has no warning', async ({ page }) => { + await mockOperate(page) + await page.goto('/app/backends?view=installed') + await row(page, 'kokoro').getByRole('button', { name: 'Actions for kokoro' }).click() + await page.getByRole('menuitem', { name: 'Delete backend' }).click() + await expect(page.getByRole('alertdialog')).toContainText('Delete backend kokoro?') + await expect(page.getByTestId('backend-remove-warning')).toHaveCount(0) + }) + + test('a meta backend that points at the one removed is named', async ({ page }) => { + await mockOperate(page, { + installed: [ + installedBackend('llama-cpp', '0.9.4', { Metadata: { version: '0.9.4', meta_backend_for: 'cuda12-llama-cpp' } }), + installedBackend('cuda12-llama-cpp', '0.9.4'), + ], + models: { data: [] }, + }) + await page.goto('/app/backends?view=installed&show_all=1') + await row(page, 'cuda12-llama-cpp').getByRole('button', { name: 'Actions for cuda12-llama-cpp' }).click() + await page.getByRole('menuitem', { name: 'Delete backend' }).click() + await expect(page.getByTestId('backend-remove-warning')).toContainText('llama-cpp points at it and will stop resolving.') + }) + + test('keeps a model that uses a backend visible in the open row', async ({ page }) => { + await mockOperate(page) + await page.goto('/app/backends?view=installed&backend=whisper') + await expect(page.getByTestId('backend-used-by')).toContainText('Used by 1 model: whisper-large-v3') + }) + + test('a system backend is protected and offers no removal', async ({ page }) => { + await mockOperate(page, { installed: [installedBackend('cpu-ggml', '1.0.0', { IsSystem: true })] }) + await page.goto('/app/backends?view=installed') + await expect(row(page, 'cpu-ggml')).toContainText('Protected') + await expect(row(page, 'cpu-ggml').getByRole('button', { name: /Actions for/ })).toHaveCount(0) + }) +}) + +test.describe('Install from URL and the catalog', () => { + test('posts the image, the name and the alias', async ({ page }) => { + await mockOperate(page) + const bodies = [] + await page.route('**/api/backends/install-external', route => { bodies.push(route.request().postDataJSON()); return jsonOk(route) }) + await page.goto('/app/backends') + await page.getByRole('button', { name: 'From URL' }).click() + await page.getByLabel('OCI image, URL or path').fill('oci://quay.io/example/backend:latest') + await page.getByLabel('Name (required for an OCI image)').fill('my-backend') + await page.getByLabel('Alias (optional)').fill('mine') + await page.getByRole('button', { name: 'Install', exact: true }).click() + await expect.poll(() => bodies).toEqual([{ uri: 'oci://quay.io/example/backend:latest', name: 'my-backend', alias: 'mine' }]) + }) + + test('asks for an address before posting anything', async ({ page }) => { + await mockOperate(page) + await page.goto('/app/backends') + await page.getByRole('button', { name: 'From URL' }).click() + await page.getByRole('button', { name: 'Install', exact: true }).click() + await expect(page.getByRole('alert')).toContainText('Enter an image, URL or path') + }) + + test('a catalog row installs a backend that is not there', async ({ page }) => { + await mockOperate(page) + const calls = [] + await page.route('**/api/backends/install/vllm', route => { calls.push('vllm'); return jsonOk(route) }) + await page.goto('/app/backends') + await row(page, 'vllm').getByRole('button', { name: 'Install vllm' }).click() + await expect.poll(() => calls).toEqual(['vllm']) + }) + + test('the open row links to the logs list', async ({ page }) => { + await mockOperate(page) + await page.goto('/app/backends?view=installed&backend=llama-cpp') + await page.getByTestId('backend-detail').getByRole('link', { name: 'Logs' }).click() + await expect(page).toHaveURL(/\/app\/backend-logs$/) + }) + + test('shows no size, no rollback and no version history, because the API has none', async ({ page }) => { + await mockOperate(page) + await page.goto('/app/backends?view=installed&backend=llama-cpp') + await expect(page.getByTestId('backend-detail')).not.toContainText(/roll back|rollback|history/i) + await expect(page.locator('.bk-table thead')).not.toContainText(/size/i) + }) +}) + +test.describe('Backends in a cluster', () => { + const nodes = [ + { id: 'n1', name: 'gpu-box-1', status: 'healthy', node_type: 'backend' }, + { id: 'n2', name: 'gpu-box-2', status: 'healthy', node_type: 'backend' }, + ] + + test('a meta backend installs on all nodes, with a way to choose', async ({ page }) => { + await mockOperate(page, { distributed: true, nodes, catalog: [catalogBackend('vllm', { isMeta: true })], installed: [] }) + await page.goto('/app/backends') + const vllm = row(page, 'vllm') + await expect(vllm.getByRole('button', { name: 'Install vllm' })).toContainText('Install on all') + await vllm.getByRole('button', { name: 'More install options' }).click() + await expect(page.getByRole('menuitem', { name: 'Install on specific nodes…' })).toBeVisible() + }) + + test('a hardware-specific build goes straight to choosing nodes', async ({ page }) => { + await mockOperate(page, { distributed: true, nodes, catalog: [catalogBackend('cuda12-vllm')], installed: [] }) + await page.goto('/app/backends') + await expect(row(page, 'cuda12-vllm').getByRole('button', { name: 'Choose nodes…' })).toBeVisible() + }) + + test('the Nodes column shows where a backend is installed', async ({ page }) => { + await mockOperate(page, { + distributed: true, + nodes, + installed: [installedBackend('llama-cpp', '0.9.4', { Nodes: [{ node_id: 'n1', node_name: 'gpu-box-1', node_status: 'healthy', version: '0.9.4' }] })], + }) + await page.goto('/app/backends?view=installed') + await expect(page.locator('.bk-table thead')).toContainText('Nodes') + await expect(row(page, 'llama-cpp')).toContainText('gpu-box-1') + }) +}) + +test.describe('Backends layout', () => { + test('a catalog row fits a phone with its progress and Cancel', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 800 }) + await mockOperate(page, { operations: [VLLM_INSTALLING], installed: INSTALLED }) + await page.goto('/app/backends') + const vllm = row(page, 'vllm') + await expect(vllm.getByRole('progressbar')).toBeVisible() + await expect(vllm.getByRole('button', { name: 'Cancel vllm' })).toBeVisible() + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) + expect(overflow).toBeLessThanOrEqual(1) + }) + + test('the install bar does not move when motion is reduced', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }) + await mockOperate(page, { operations: [VLLM_INSTALLING] }) + await page.goto('/app/backends') + const duration = await row(page, 'vllm').locator('.dk-progress-bar').evaluate(el => parseFloat(getComputedStyle(el).transitionDuration)) + expect(duration).toBeLessThan(0.001) + }) +}) diff --git a/core/http/react-ui/e2e/build-landing.spec.js b/core/http/react-ui/e2e/build-landing.spec.js new file mode 100644 index 000000000..5696fc6d3 --- /dev/null +++ b/core/http/react-ui/e2e/build-landing.spec.js @@ -0,0 +1,161 @@ +import { test, expect } from './coverage-fixtures.js' +import { CPU_HOST, GPU_HOST, ftJob, mockFineTune, mockMachine, mockQuantize, qzJob } from './tools-fixtures.js' + +// The Build landing says what each tool is for and what it needs from this +// machine. The needs come from the machine's own figures and the installed +// backends, so each spec stubs those and reads the card. + +const tool = (page, id) => page.locator(`[data-testid="build-landing"] [data-tool="${id}"]`) + +async function setup(page, { host = GPU_HOST, ft = {}, qz = {}, features } = {}) { + await mockMachine(page, { host, ...(features ? { features } : {}) }) + await mockFineTune(page, { jobs: [], ...ft }) + await mockQuantize(page, { jobs: [], ...qz }) +} + +test.describe('Build landing', () => { + test('lists the tools in three groups with a sentence each', async ({ page }) => { + await setup(page) + await page.goto('/app/build') + const landing = page.getByTestId('build-landing') + await expect(landing.getByRole('heading', { name: 'Make a model' })).toBeVisible() + await expect(landing.getByRole('heading', { name: 'Recognise' })).toBeVisible() + await expect(landing.getByRole('heading', { name: 'Automate' })).toBeVisible() + await expect(tool(page, 'import')).toContainText('Bring in a model from Hugging Face') + await expect(tool(page, 'quantize')).toContainText('Make a model smaller') + await expect(tool(page, 'fine-tune')).toContainText('Teach a model your own data') + await expect(tool(page, 'agents')).toContainText('Create and run agents') + // The flow strip names the make-a-model steps in order. + await expect(page.getByRole('list', { name: /path from a model/i }).locator('li')).toHaveText(['Import', 'Quantize', 'Fine-Tune', 'Chat']) + }) + + test('every tool opens its page', async ({ page }) => { + await setup(page) + await page.goto('/app/build') + await expect(tool(page, 'fine-tune').getByRole('link', { name: 'Open Fine-Tune' })).toHaveAttribute('href', '/app/fine-tune') + await expect(tool(page, 'quantize').getByRole('link', { name: /Open Quantize/ })).toHaveAttribute('href', '/app/quantize') + await expect(tool(page, 'import').getByRole('link', { name: /Open Import/ })).toHaveAttribute('href', '/app/import-model') + }) + + test('a machine with a GPU and both backends shows ready tools and the figures', async ({ page }) => { + await setup(page) + await page.goto('/app/build') + const machine = page.getByTestId('build-machine') + await expect(machine).toContainText('RTX 4070') + await expect(machine).toContainText('9.8 GB of 12 GB free') + await expect(machine).toContainText('118 GB') + await expect(machine).toContainText('llama-cpp-quantization') + await expect(machine).toContainText('trl') + for (const id of ['import', 'quantize', 'fine-tune']) { + await expect(tool(page, id)).toHaveAttribute('data-state', 'ready') + await expect(tool(page, id).locator('.dk-badge--ok')).toHaveText('Ready') + } + await expect(tool(page, 'fine-tune')).toContainText('GPU memory: RTX 4070, 9.8 GB of 12 GB free') + await expect(tool(page, 'quantize')).toContainText('Runs on the CPU') + await expect(tool(page, 'import')).toContainText('118 GB free on the models disk') + }) + + test('no backend installed: the tool says why and an admin gets an Install link', async ({ page }) => { + await setup(page, { host: CPU_HOST, ft: { backends: [] }, qz: { backends: [] } }) + await page.goto('/app/build') + for (const id of ['quantize', 'fine-tune']) { + await expect(tool(page, id)).toHaveAttribute('data-state', 'needs-backend') + await expect(tool(page, id)).toContainText('Backend needed') + await expect(tool(page, id).getByRole('link', { name: 'Install' })).toHaveAttribute('href', '/app/backends') + } + await expect(tool(page, 'quantize')).toContainText('The quantization backend is not installed') + await expect(tool(page, 'fine-tune')).toContainText('No fine-tuning backend is installed') + await expect(tool(page, 'import')).toHaveAttribute('data-state', 'ready') + await expect(page.getByTestId('build-machine')).toContainText('none installed for these tools') + }) + + test('no GPU is a warning on Fine-tune, not a block', async ({ page }) => { + await setup(page, { host: CPU_HOST }) + await page.goto('/app/build') + await expect(page.getByTestId('build-machine')).toContainText('No GPU found') + await expect(tool(page, 'fine-tune')).toHaveAttribute('data-state', 'ready') + await expect(tool(page, 'fine-tune').locator('[data-tone="warn"]')).toContainText('No GPU found. Training runs on the CPU') + }) + + test('a running job is surfaced above the list and opens its page', async ({ page }) => { + await setup(page, { qz: { jobs: [qzJob('qz1', 'converting', { message: 'converting tensor 84 of 201' })] } }) + await page.goto('/app/build') + const now = page.getByTestId('build-now') + await expect(now).toHaveAttribute('data-kind', 'running') + await expect(now).toContainText('Quantizing meta-llama/Llama-3.2-1B to q4_k_m') + await expect(now).toContainText('converting tensor 84 of 201') + await expect(now.getByRole('link', { name: 'Open' })).toHaveAttribute('href', '/app/quantize') + }) + + test('a failed newest job is surfaced, an old failure is not', async ({ page }) => { + await setup(page, { ft: { jobs: [ftJob('j2', 'failed', { message: 'CUDA out of memory' })] } }) + await page.goto('/app/build') + await expect(page.getByTestId('build-now')).toHaveAttribute('data-kind', 'failed') + await expect(page.getByTestId('build-now')).toContainText('Fine-tuning TinyLlama/TinyLlama-1.1B-Chat-v1.0 failed') + }) + + test('no job, no line', async ({ page }) => { + await setup(page, { ft: { jobs: [ftJob('j1', 'completed')] } }) + await page.goto('/app/build') + await expect(page.getByTestId('build-landing')).toBeVisible() + await expect(page.getByTestId('build-now')).toHaveCount(0) + }) + + test('a tool switched off by a feature flag is left out', async ({ page }) => { + await setup(page, { features: { agents: false, mcp: false, fine_tuning: false, quantization: true, distributed: false } }) + await page.goto('/app/build') + await expect(tool(page, 'fine-tune')).toHaveCount(0) + await expect(tool(page, 'quantize')).toBeVisible() + await expect(tool(page, 'agents')).toHaveCount(0) + await expect(page.getByRole('list', { name: 'Build tools' }).locator('a[href="/app/import-model"]')).toBeVisible() + }) + + test('a member without admin sees no machine strip, no Install link, and the tools they may use', async ({ page }) => { + await mockMachine(page, { host: null }) + await mockFineTune(page, { jobs: [], backends: [] }) + await mockQuantize(page, { jobs: [] }) + await page.route('**/api/auth/status', route => route.fulfill({ + json: { authEnabled: true, staticApiKeyRequired: false, providers: ['local'], user: { id: 'u1', name: 'Sam', role: 'user', permissions: { fine_tuning: true } } }, + })) + await page.goto('/app/build') + await expect(tool(page, 'fine-tune')).toHaveAttribute('data-state', 'needs-backend') + await expect(tool(page, 'fine-tune')).toContainText('Ask an administrator to install the backend') + await expect(tool(page, 'fine-tune').getByRole('link', { name: 'Install' })).toHaveCount(0) + await expect(page.getByTestId('build-machine')).toHaveCount(0) + await expect(tool(page, 'import')).toHaveCount(0) + await expect(tool(page, 'quantize')).toHaveCount(0) + }) + + test('a user with no Build tool sees the empty line', async ({ page }) => { + await mockMachine(page, { host: null }) + await page.route('**/api/auth/status', route => route.fulfill({ + json: { authEnabled: true, staticApiKeyRequired: false, providers: ['local'], user: { id: 'u1', name: 'Sam', role: 'user', permissions: { chat: true } } }, + })) + await page.goto('/app/build') + await expect(page.getByTestId('build-empty')).toContainText('No build tools are available') + }) +}) + +test.describe('Build landing: phone', () => { + test.use({ viewport: { width: 390, height: 844 } }) + + test('fits the screen and keeps every Open link tappable', async ({ page }) => { + await setup(page, { qz: { jobs: [qzJob('qz1', 'converting')] } }) + await page.goto('/app/build') + await expect(page.getByTestId('build-landing')).toBeVisible() + expect(await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)).toBe(false) + const heights = await page.locator('[data-testid="build-landing"] .bt-tool__end a').evaluateAll(els => els.map(el => el.getBoundingClientRect().height)) + expect(heights.length).toBeGreaterThan(5) + expect(Math.min(...heights)).toBeGreaterThanOrEqual(30) + }) +}) + +test.describe('Build landing: reduced motion', () => { + test('page content does not animate', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }) + await setup(page) + await page.goto('/app/build') + const duration = await page.getByTestId('build-landing').locator('.bt-tool').first().evaluate(el => getComputedStyle(el).animationDuration) + expect(parseFloat(duration)).toBeLessThan(0.01) + }) +}) diff --git a/core/http/react-ui/e2e/build-tools.spec.js b/core/http/react-ui/e2e/build-tools.spec.js new file mode 100644 index 000000000..e63047f24 --- /dev/null +++ b/core/http/react-ui/e2e/build-tools.spec.js @@ -0,0 +1,513 @@ +import { test, expect } from './coverage-fixtures.js' +import { CPU_HOST, GPU_HOST, ftEvents, ftJob, mockFineTune, mockMachine, mockQuantize, qzJob } from './tools-fixtures.js' + +// Fine-tune and Quantize share one pattern: set up, check before you start (the +// list redraws as the form changes), run with progress and a log, then a result +// with real next steps. Every number on these pages comes from the stubs below. + +const MODEL = 'TinyLlama/TinyLlama-1.1B-Chat-v1.0' +const expectStep = (page, key, state) => expect(page.locator(`[data-testid="tool-steps"] [data-step="${key}"]`)).toHaveAttribute('data-state', state) +const check = (page, id) => page.locator(`[data-testid="tool-checks"] [data-check="${id}"]`) + +async function boot(page, { host = GPU_HOST, ft = {}, qz = {} } = {}) { + await mockMachine(page, { host }) + const ftState = { jobs: [], ...ft } + const qzState = { jobs: [], ...qz } + await mockFineTune(page, ftState) + await mockQuantize(page, qzState) + return { ftState, qzState } +} + +async function fillFineTune(page) { + await page.fill('#ft-model', MODEL) + await page.fill('#ft-dataset', 'tatsu-lab/alpaca') +} + +test.describe('Fine-tune: set up and check', () => { + test('starts empty: Set up is current, Start is off, and the check says what is missing', async ({ page }) => { + await boot(page) + await page.goto('/app/fine-tune') + await expect(page.getByTestId('fine-tune-form')).toBeVisible() + await expectStep(page, 'setup', 'current') + await expect(page.getByTestId('ft-start')).toBeDisabled() + await expect(check(page, 'model')).toHaveAttribute('data-tone', 'fail') + await expect(check(page, 'data')).toContainText('Add a dataset') + await expect(page.getByTestId('ft-bar')).toContainText('Enter the model to train.') + await expect(page.getByTestId('ft-recipe')).toContainText('Choose a model and a dataset') + }) + + test('a model and a dataset move to Check and switch Start on', async ({ page }) => { + await boot(page) + await page.goto('/app/fine-tune') + await fillFineTune(page) + await expectStep(page, 'check', 'current') + await expect(page.getByTestId('ft-start')).toBeEnabled() + await expect(check(page, 'model')).toHaveAttribute('data-tone', 'ok') + await expect(check(page, 'data')).toContainText('Data comes from tatsu-lab/alpaca') + await expect(check(page, 'backend')).toContainText('trl is installed') + await expect(check(page, 'memory')).toContainText('RTX 4070: 9.8 GB of 12 GB free') + await expect(check(page, 'memory')).toContainText('LocalAI does not estimate it before the job starts') + await expect(check(page, 'memory').getByRole('img')).toHaveAttribute('aria-label', /18 percent in use/) + await expect(check(page, 'disk')).toContainText('118 GB free on the models disk') + await expect(page.getByTestId('ft-recipe')).toContainText('Train a LoRA adapter of TinyLlama-1.1B-Chat-v1.0 with SFT, 3 epochs over tatsu-lab/alpaca.') + await expect(page.getByTestId('ft-bar')).toContainText('Nothing blocks the start.') + }) + + test('the check list redraws as the form changes', async ({ page }) => { + await boot(page) + await page.goto('/app/fine-tune') + await fillFineTune(page) + await expect(check(page, 'memory')).toContainText('Batch size 2') + await page.fill('#ft-batch', '4') + await expect(check(page, 'memory')).toContainText('Batch size 4') + await page.fill('#ft-epochs', '5') + await expect(page.getByTestId('ft-recipe')).toContainText('5 epochs') + await page.getByRole('radio', { name: /Full model/ }).check() + await expect(page.getByTestId('ft-recipe')).toContainText('full model') + + // GRPO needs a reward function: a warning that goes when one is chosen. + await page.getByTestId('ft-more-toggle').click() + await page.selectOption('#ft-method', 'grpo') + await expect(check(page, 'reward')).toHaveAttribute('data-tone', 'warn') + await expect(page.getByTestId('ft-bar')).toContainText('1 warning. You can still start.') + await expect(page.getByTestId('ft-start')).toBeEnabled() + await page.getByLabel('format_reward').check() + await expect(check(page, 'reward')).toHaveCount(0) + await expect(page.getByTestId('ft-bar')).toContainText('Nothing blocks the start.') + }) + + test('with no GPU the memory check warns and names the RAM', async ({ page }) => { + await boot(page, { host: CPU_HOST }) + await page.goto('/app/fine-tune') + await fillFineTune(page) + await expect(check(page, 'memory')).toHaveAttribute('data-tone', 'warn') + await expect(check(page, 'memory')).toContainText('No GPU found. Training uses the CPU and 20 GB of 32 GB RAM is free.') + }) + + test('with no backend installed it warns, links to Backends, and still lets you start', async ({ page }) => { + await boot(page, { ft: { backends: [] } }) + await page.goto('/app/fine-tune') + await fillFineTune(page) + await expect(check(page, 'backend')).toHaveAttribute('data-tone', 'warn') + await expect(check(page, 'backend')).toContainText('No fine-tuning backend is installed') + await expect(check(page, 'backend').getByRole('link', { name: 'Open Backends' })).toHaveAttribute('href', '/app/backends') + await expect(page.getByTestId('ft-start')).toBeEnabled() + }) + + test('a user who cannot read the machine still gets a check list', async ({ page }) => { + await boot(page, { host: null }) + await page.goto('/app/fine-tune') + await fillFineTune(page) + await expect(check(page, 'memory')).toContainText('This account cannot read the machine') + await expect(check(page, 'disk')).toHaveCount(0) + }) + + test('Start sends the request the form describes and opens the job', async ({ page }) => { + const { ftState } = await boot(page) + await page.goto('/app/fine-tune') + await fillFineTune(page) + await page.fill('#ft-split', 'train') + await page.fill('#ft-lr', '5e-5') + await page.getByTestId('ft-start').click() + await expect(page.getByTestId('job-view')).toBeVisible() + expect(ftState.started).toHaveLength(1) + expect(ftState.started[0]).toMatchObject({ + model: MODEL, backend: 'trl', training_method: 'sft', training_type: 'lora', dataset_source: 'tatsu-lab/alpaca', + dataset_split: 'train', num_epochs: 3, batch_size: 2, learning_rate: 0.00005, adapter_rank: 16, + }) + expect(ftState.started[0].extra_options).toMatchObject({ max_seq_length: '2048', eval_strategy: 'no' }) + await expectStep(page, 'run', 'current') + }) + + test('an uploaded file is the dataset, and is sent to the upload endpoint first', async ({ page }) => { + const { ftState } = await boot(page) + await page.goto('/app/fine-tune') + await page.fill('#ft-model', MODEL) + await page.setInputFiles('#ft-dataset-file', { name: 'pairs.jsonl', mimeType: 'application/json', buffer: Buffer.from('{"text":"a"}\n') }) + await expect(check(page, 'data')).toContainText('pairs.jsonl') + await page.getByTestId('ft-start').click() + await expect(page.getByTestId('job-view')).toBeVisible() + expect(ftState.started[0].dataset_source).toBe('/data/datasets/upload.jsonl') + }) + + test('a refused start shows the server message and keeps the form', async ({ page }) => { + await boot(page, { ft: { startError: 'failed to load backend trl: not found' } }) + await page.goto('/app/fine-tune') + await fillFineTune(page) + await page.getByTestId('ft-start').click() + await expect(page.getByTestId('tool-error')).toContainText('failed to load backend trl: not found') + await expect(page.getByTestId('fine-tune-form')).toBeVisible() + await expect(page.locator('#ft-model')).toHaveValue(MODEL) + }) + + test('More options carries the method, adapter and evaluation settings', async ({ page }) => { + await boot(page) + await page.goto('/app/fine-tune') + await expect(page.locator('#ft-method')).toHaveCount(0) + await page.getByTestId('ft-more-toggle').click() + for (const id of ['#ft-backend', '#ft-method', '#ft-type', '#ft-rank', '#ft-optimizer', '#ft-hf-token', '#ft-seq-len']) await expect(page.locator(id)).toBeVisible() + await page.getByRole('switch', { name: 'Evaluate while training' }).click() + await expect(page.locator('#ft-eval-strategy')).toBeVisible() + }) +}) + +test.describe('Fine-tune: run, fail, finish', () => { + const open = async (page) => { + await page.getByTestId('ft-jobs').getByRole('button', { name: MODEL }).click() + await expect(page.getByTestId('job-view')).toBeVisible() + } + + test('a running job shows progress, stages, chart and log from its events', async ({ page }) => { + await boot(page, { ft: { jobs: [ftJob('job-run', 'training')], events: { 'job-run': ftEvents(540) } } }) + await page.goto('/app/fine-tune') + await open(page) + await expectStep(page, 'run', 'current') + const view = page.getByTestId('job-view') + await expect(view.locator('.bt-job__percent')).toContainText('60') + await expect(view).toContainText('step 540 of 900') + await expect(view).toContainText('epoch 1.8 of 3') + await expect(view).toContainText('about 12 min left') + await expect(view).toContainText('312 tokens/s') + await expect(view.getByRole('progressbar', { name: 'Progress' })).toHaveAttribute('aria-valuenow', '60') + await expect(page.locator('[data-testid="job-stages"] [data-state="current"]')).toHaveText('Training') + // The chart: a line, the evaluation loss as hollow dots, the last value. + await expect(page.getByTestId('job-chart').locator('.bt-chart__line')).toBeVisible() + await expect(page.getByTestId('job-chart').locator('.bt-chart__eval')).toHaveCount(5) + await expect(page.getByTestId('job-chart')).toContainText('Hollow dots are the evaluation loss') + await page.getByRole('tab', { name: 'Learning rate' }).click() + await expect(page.getByTestId('job-chart').getByRole('heading', { name: 'Learning rate' })).toBeVisible() + await expect(page.getByTestId('job-chart').locator('.bt-chart__eval')).toHaveCount(0) + // The log is what the events said, and the filter shows warnings only. + await expect(page.getByTestId('job-log')).toContainText('step 530/900 loss') + await page.getByTestId('job-log-filter').check() + await expect(page.getByTestId('job-log')).toContainText('No warnings or errors.') + }) + + test('Stop asks whether to keep a checkpoint and sends the answer', async ({ page }) => { + const { ftState } = await boot(page, { ft: { jobs: [ftJob('job-run', 'training')], events: { 'job-run': ftEvents(100) } } }) + await page.goto('/app/fine-tune') + await open(page) + await page.getByTestId('job-stop').click() + await expect(page.getByTestId('stop-dialog')).toContainText('Stop training?') + await page.getByTestId('stop-keep').click() + await expect(page.getByTestId('stop-dialog')).toHaveCount(0) + expect(ftState.stopped).toEqual([{ id: 'job-run', save: 'true' }]) + await page.getByTestId('job-stop').click() + await page.getByTestId('stop-discard').click() + expect(ftState.stopped[1]).toEqual({ id: 'job-run', save: 'false' }) + await page.getByTestId('job-stop').click() + await page.getByRole('button', { name: 'Keep training' }).click() + expect(ftState.stopped).toHaveLength(2) + }) + + test('a failed job shows the server message, offers the fixes for a memory failure, and applies them to a copy', async ({ page }) => { + const message = 'torch.OutOfMemoryError: CUDA out of memory. Tried to allocate 1.34 GiB.' + await boot(page, { + ft: { + jobs: [ftJob('job-f', 'failed', { message })], + checkpoints: { 'job-f': [{ path: '/data/finetune/job-f/checkpoint-200', step: 200, epoch: 0.66, loss: 1.4012, created_at: '2026-10-08 10:10' }] }, + }, + }) + await page.goto('/app/fine-tune') + await open(page) + await expectStep(page, 'run', 'failed') + await expect(page.getByTestId('job-failed')).toHaveAttribute('role', 'alert') + await expect(page.getByTestId('job-failed-message')).toHaveText(message) + await expect(page.getByTestId('job-try')).toContainText('Lower the batch size to 1') + await expect(page.getByRole('button', { name: 'Resume from step 200' })).toBeVisible() + await page.getByTestId('job-retry').click() + // Back on the form, with the setup restored and the two fixes applied. + await expect(page.getByTestId('fine-tune-form')).toBeVisible() + await expect(page.locator('#ft-model')).toHaveValue(MODEL) + await expect(page.locator('#ft-batch')).toHaveValue('1') + await page.getByTestId('ft-more-toggle').click() + await expect(page.getByLabel(/Gradient checkpointing/)).toBeChecked() + }) + + test('a failure that is not about memory gets no invented advice', async ({ page }) => { + await boot(page, { ft: { jobs: [ftJob('job-f', 'failed', { message: 'dataset not found: tatsu-lab/alpaca' })] } }) + await page.goto('/app/fine-tune') + await open(page) + await expect(page.getByTestId('job-failed-message')).toHaveText('dataset not found: tatsu-lab/alpaca') + await expect(page.getByTestId('job-try')).not.toContainText('batch size') + await expect(page.getByTestId('job-try')).toContainText('change what the message points at') + }) + + test('Resume from a checkpoint puts it in the form', async ({ page }) => { + await boot(page, { + ft: { + jobs: [ftJob('job-f', 'failed', { message: 'stopped' })], + checkpoints: { 'job-f': [{ path: '/data/finetune/job-f/checkpoint-200', step: 200, epoch: 0.66, loss: 1.4012, created_at: 'x' }] }, + }, + }) + await page.goto('/app/fine-tune') + await open(page) + await page.getByRole('button', { name: 'Resume from step 200' }).click() + await expect(page.getByText('Resuming from checkpoint:')).toBeVisible() + await expect(page.getByTestId('fine-tune-form')).toContainText('/data/finetune/job-f/checkpoint-200') + await expect(page.getByTestId('ft-start')).toContainText('Resume training') + }) + + test('a finished job offers the export, and an exported model leads to Chat and Models', async ({ page }) => { + const { ftState } = await boot(page, { + ft: { + jobs: [ftJob('job-d', 'completed')], + checkpoints: { 'job-d': [{ path: '/data/finetune/job-d/checkpoint-500', step: 500, epoch: 1.66, loss: 0.8911, created_at: 'x' }] }, + }, + }) + await page.goto('/app/fine-tune') + await open(page) + await expectStep(page, 'result', 'current') + await expect(page.getByTestId('job-finished')).toContainText('Training finished.') + await expect(page.getByTestId('job-checkpoints')).toContainText('1.4012'.replace('1.4012', '0.8911')) + await page.fill('#ft-export-name', 'tinyllama-ft') + await page.selectOption('#ft-export-format', 'gguf') + await page.fill('#ft-export-quant', 'q5_k_m') + await page.getByRole('region', { name: 'Export as a model' }).getByRole('button', { name: 'Export', exact: true }).click() + expect(ftState.exported[0]).toMatchObject({ name: 'tinyllama-ft', export_format: 'gguf', quantization_method: 'q5_k_m', checkpoint_path: '/data/finetune/job-d', model: MODEL }) + }) + + test('an exported job links to Chat, Models and the archive', async ({ page }) => { + await boot(page, { ft: { jobs: [ftJob('job-d', 'completed', { export_status: 'completed', export_model_name: 'tinyllama-ft' })] } }) + await page.goto('/app/fine-tune') + await open(page) + const next = page.getByTestId('export-next') + await expect(next).toContainText('tinyllama-ft is a model now') + await expect(next.getByRole('link', { name: 'Chat with tinyllama-ft' })).toHaveAttribute('href', '/app/chat/tinyllama-ft') + await expect(next.getByRole('link', { name: 'Open in Models' })).toHaveAttribute('href', '/app/models?view=installed') + await expect(next.getByRole('link', { name: 'Download archive' })).toHaveAttribute('href', /jobs\/job-d\/download/) + }) + + test('New job returns to the form', async ({ page }) => { + await boot(page, { ft: { jobs: [ftJob('job-d', 'completed')] } }) + await page.goto('/app/fine-tune') + await open(page) + await page.getByTestId('job-back').click() + await expect(page.getByTestId('fine-tune-form')).toBeVisible() + }) +}) + +test.describe('Fine-tune: earlier jobs', () => { + test('lists jobs with kind and status, reuses a setup and deletes after a confirmation', async ({ page }) => { + const { ftState } = await boot(page, { + ft: { jobs: [ftJob('job-1', 'completed', { model: 'Qwen2.5-0.5B', training_method: 'dpo', config: { model: 'Qwen2.5-0.5B', dataset_source: 'x/y', training_method: 'dpo', batch_size: 3 } }), ftJob('job-2', 'failed', { model: 'gemma-2-2b', message: 'unsupported' })] }, + }) + await page.goto('/app/fine-tune') + const jobs = page.getByTestId('ft-jobs') + await expect(jobs).toContainText('2 kept on this server') + await expect(jobs.locator('tbody tr')).toHaveCount(2) + await expect(jobs.locator('tbody tr').first()).toContainText('lora, dpo') + await expect(jobs.locator('tbody tr').nth(1)).toContainText('unsupported') + await jobs.locator('tbody tr').first().getByRole('button', { name: 'Reuse' }).click() + await expect(page.locator('#ft-model')).toHaveValue('Qwen2.5-0.5B') + await expect(page.locator('#ft-dataset')).toHaveValue('x/y') + await expect(page.locator('#ft-batch')).toHaveValue('3') + await jobs.locator('tbody tr').nth(1).getByRole('button', { name: 'Delete' }).click() + await expect(page.getByRole('alertdialog')).toContainText('Delete this job?') + await page.getByRole('button', { name: 'Cancel' }).click() + expect(ftState.deleted).toEqual([]) + await jobs.locator('tbody tr').nth(1).getByRole('button', { name: 'Delete' }).click() + await page.getByTestId('delete-confirm').click() + await expect.poll(() => ftState.deleted).toEqual(['job-2']) + }) + + test('with no jobs it names the next step', async ({ page }) => { + await boot(page) + await page.goto('/app/fine-tune') + await expect(page.getByTestId('ft-jobs').locator('.dk-empty-title')).toHaveText('No fine-tuning jobs yet') + }) +}) + +test.describe('Quantize', () => { + test('setup: a model is needed, the type can be custom, and the check lists the facts', async ({ page }) => { + await boot(page) + await page.goto('/app/quantize') + await expectStep(page, 'setup', 'current') + await expect(page.getByTestId('qz-start')).toBeDisabled() + await page.fill('#qz-model', 'meta-llama/Llama-3.2-1B') + await expectStep(page, 'check', 'current') + await expect(page.getByTestId('qz-start')).toContainText('Quantize (q4_k_m)') + await expect(check(page, 'backend')).toContainText('llama-cpp-quantization is installed') + await expect(check(page, 'memory')).toContainText('20 GB of 32 GB RAM is free. Conversion runs on the CPU.') + await expect(check(page, 'disk')).toContainText('118 GB free on the models disk') + await expect(page.getByTestId('qz-recipe')).toContainText('Convert Llama-3.2-1B to GGUF at q4_k_m.') + await page.selectOption('#qz-type', 'q8_0') + await expect(page.getByTestId('qz-recipe')).toContainText('at q8_0') + await page.selectOption('#qz-type', '__custom__') + await expect(check(page, 'type')).toHaveAttribute('data-tone', 'fail') + await expect(page.getByTestId('qz-start')).toBeDisabled() + await page.fill('#qz-custom', 'iq4_nl') + await expect(page.getByTestId('qz-start')).toContainText('Quantize (iq4_nl)') + await expect(page.getByTestId('qz-start')).toBeEnabled() + }) + + test('no backend: a warning and a link, and the start stays possible', async ({ page }) => { + await boot(page, { qz: { backends: [] } }) + await page.goto('/app/quantize') + await page.fill('#qz-model', 'meta-llama/Llama-3.2-1B') + await expect(check(page, 'backend')).toHaveAttribute('data-tone', 'warn') + await expect(check(page, 'backend').getByRole('link', { name: 'Open Backends' })).toBeVisible() + await expect(page.getByTestId('qz-start')).toBeEnabled() + }) + + test('Start sends the request, with the token under extra options, and opens the job', async ({ page }) => { + const { qzState } = await boot(page) + await page.goto('/app/quantize') + await page.fill('#qz-model', 'meta-llama/Llama-3.2-1B') + await page.getByRole('button', { name: /More options/ }).click() + await page.fill('#qz-token', 'hf_secret') + await page.getByTestId('qz-start').click() + await expect(page.getByTestId('job-view')).toBeVisible() + expect(qzState.started[0]).toEqual({ model: 'meta-llama/Llama-3.2-1B', backend: 'llama-cpp-quantization', quantization_type: 'q4_k_m', extra_options: { hf_token: 'hf_secret' } }) + await expectStep(page, 'run', 'current') + }) + + test('a running job shows the stage, progress and log, and Stop sends the stop', async ({ page }) => { + const events = [{ job_id: 'qz1', status: 'downloading', progress_percent: 10, message: 'Downloading model files' }, { job_id: 'qz1', status: 'converting', progress_percent: 42, message: 'converting tensor 84 of 201' }] + const { qzState } = await boot(page, { qz: { jobs: [qzJob('qz1', 'converting')], events: { qz1: events } } }) + await page.goto('/app/quantize') + await page.getByTestId('qz-jobs').getByRole('button', { name: 'meta-llama/Llama-3.2-1B' }).click() + const view = page.getByTestId('job-view') + await expect(view.locator('.bt-job__percent')).toContainText('42') + await expect(view.getByRole('progressbar', { name: 'Progress' })).toHaveAttribute('aria-valuenow', '42') + await expect(view).toContainText('converting tensor 84 of 201') + await expect(page.locator('[data-testid="job-stages"] [data-state="current"]')).toHaveText('Converting') + await expect(page.locator('[data-testid="job-stages"] [data-state="done"]')).toHaveCount(2) + await expect(page.getByTestId('job-log')).toContainText('Downloading model files') + await page.getByTestId('job-stop').click() + expect(qzState.stopped).toEqual(['qz1']) + }) + + test('a failed job shows the server message and reuses its setup', async ({ page }) => { + await boot(page, { qz: { jobs: [qzJob('qz9', 'failed', { model: 'acme/odd-model', quantization_type: 'q5_k_m', message: 'unsupported architecture: OddForCausalLM' })] } }) + await page.goto('/app/quantize') + await page.getByTestId('qz-jobs').getByRole('button', { name: 'acme/odd-model' }).click() + await expectStep(page, 'run', 'failed') + await expect(page.getByTestId('job-failed-message')).toHaveText('unsupported architecture: OddForCausalLM') + await page.getByTestId('job-retry').click() + await expect(page.locator('#qz-model')).toHaveValue('acme/odd-model') + await expect(page.locator('#qz-type')).toHaveValue('q5_k_m') + }) + + test('a finished job imports under a name and then leads to Chat and Models', async ({ page }) => { + const { qzState } = await boot(page, { qz: { jobs: [qzJob('qz2', 'completed', { output_file: '/data/quant/qz2/model-q4_k_m.gguf' })] } }) + await page.goto('/app/quantize') + await page.getByTestId('qz-jobs').getByRole('button', { name: 'meta-llama/Llama-3.2-1B' }).click() + await expectStep(page, 'result', 'current') + await expect(page.getByTestId('job-finished')).toContainText('Quantization finished: q4_k_m.') + await expect(page.getByTestId('quantize-output')).toContainText('/data/quant/qz2/model-q4_k_m.gguf') + await expect(page.getByRole('link', { name: 'Download GGUF' })).toHaveAttribute('href', /jobs\/qz2\/download/) + await page.fill('#qz-import-name', 'llama-1b-q4') + await page.getByTestId('quantize-import').click() + const next = page.getByTestId('quantize-next') + await expect(next).toContainText('llama-1b-q4 is a model now') + expect(qzState.imported).toEqual([{ name: 'llama-1b-q4' }]) + await expect(next.getByRole('link', { name: 'Chat with llama-1b-q4' })).toHaveAttribute('href', '/app/chat/llama-1b-q4') + await expect(next.getByRole('link', { name: 'Open in Models' })).toHaveAttribute('href', '/app/models?view=installed') + }) + + test('earlier jobs: imported mark, failure line, delete after a confirmation', async ({ page }) => { + const { qzState } = await boot(page, { + qz: { jobs: [qzJob('qz0', 'completed', { import_status: 'completed', import_model_name: 'llama-3.2-1b-q4_k_m' }), qzJob('qz9', 'failed', { model: 'acme/odd-model', message: 'unsupported architecture' })] }, + }) + await page.goto('/app/quantize') + const jobs = page.getByTestId('qz-jobs') + await expect(jobs.locator('tbody tr').first()).toContainText('imported as llama-3.2-1b-q4_k_m') + await expect(jobs.locator('tbody tr').nth(1)).toContainText('unsupported architecture') + await jobs.locator('tbody tr').nth(1).getByRole('button', { name: 'Delete' }).click() + await page.getByTestId('delete-confirm').click() + await expect.poll(() => qzState.deleted).toEqual(['qz9']) + }) + + test('with no jobs it names the next step', async ({ page }) => { + await boot(page) + await page.goto('/app/quantize') + await expect(page.getByTestId('qz-jobs').locator('.dk-empty-title')).toHaveText('No quantization jobs yet') + }) +}) + +test.describe('Disabled: the account lacks the permission', () => { + const member = (permissions) => (page) => page.route('**/api/auth/status', route => route.fulfill({ + json: { authEnabled: true, staticApiKeyRequired: false, providers: ['local'], user: { id: 'u1', name: 'Sam', role: 'user', permissions } }, + })) + + test('Fine-tune says the account cannot use it, who can change that, and where to go', async ({ page }) => { + await boot(page) + await member({ chat: true })(page) + await page.goto('/app/fine-tune') + const off = page.getByTestId('tool-off') + await expect(off).toHaveAttribute('data-cause', 'account') + await expect(off).toContainText('Your account cannot fine-tune') + await expect(off).toContainText('Ask an administrator') + await expect(off.getByRole('link', { name: 'Go to Home' })).toHaveAttribute('href', '/app') + await expect(page.getByTestId('fine-tune-form')).toHaveCount(0) + }) + + test('Quantize has its own words', async ({ page }) => { + await boot(page) + await member({ chat: true, fine_tuning: true })(page) + await page.goto('/app/quantize') + await expect(page.getByTestId('tool-off')).toContainText('Your account cannot quantize') + await page.goto('/app/fine-tune') + await expect(page.getByTestId('fine-tune-form')).toBeVisible() + }) + + test('an administrator is never shown the page', async ({ page }) => { + await boot(page) + await page.goto('/app/fine-tune') + await expect(page.getByTestId('fine-tune-form')).toBeVisible() + await expect(page.getByTestId('tool-off')).toHaveCount(0) + }) +}) + +test.describe('Build tools: phone', () => { + test.use({ viewport: { width: 390, height: 844 } }) + const noOverflow = (page) => page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth) + + test('Fine-tune setup and check fit the screen', async ({ page }) => { + await boot(page, { ft: { jobs: [ftJob('job-1', 'completed'), ftJob('job-2', 'failed', { message: 'x' })] } }) + await page.goto('/app/fine-tune') + await fillFineTune(page) + await page.getByTestId('ft-more-toggle').click() + await expect(check(page, 'memory')).toBeVisible() + expect(await noOverflow(page)).toBe(false) + await expect(page.getByTestId('ft-start')).toBeVisible() + }) + + test('a running Fine-tune job fits the screen', async ({ page }) => { + await boot(page, { ft: { jobs: [ftJob('job-run', 'training')], events: { 'job-run': ftEvents(540) } } }) + await page.goto('/app/fine-tune') + await page.getByTestId('ft-jobs').getByRole('button', { name: MODEL }).click() + await expect(page.getByTestId('job-chart').locator('.bt-chart__line')).toBeVisible() + expect(await noOverflow(page)).toBe(false) + }) + + test('Quantize finished fits the screen', async ({ page }) => { + await boot(page, { qz: { jobs: [qzJob('qz2', 'completed', { output_file: '/data/quant/qz2/a-very-long-file-name-q4_k_m.gguf' })] } }) + await page.goto('/app/quantize') + await page.getByTestId('qz-jobs').getByRole('button', { name: 'meta-llama/Llama-3.2-1B' }).click() + await expect(page.getByTestId('quantize-output')).toBeVisible() + expect(await noOverflow(page)).toBe(false) + }) + + test('the disabled page fits the screen', async ({ page }) => { + await boot(page) + await page.route('**/api/auth/status', route => route.fulfill({ + json: { authEnabled: true, staticApiKeyRequired: false, providers: ['local'], user: { id: 'u1', name: 'Sam', role: 'user', permissions: { chat: true } } }, + })) + await page.goto('/app/quantize') + await expect(page.getByTestId('tool-off')).toBeVisible() + expect(await noOverflow(page)).toBe(false) + }) +}) + +test.describe('Build tools: reduced motion', () => { + test('the job card does not animate', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }) + await boot(page, { ft: { jobs: [ftJob('job-run', 'training')], events: { 'job-run': ftEvents(100) } } }) + await page.goto('/app/fine-tune') + await page.getByTestId('ft-jobs').getByRole('button', { name: MODEL }).click() + const duration = await page.getByTestId('job-view').locator('.bt-job').evaluate(el => getComputedStyle(el).animationDuration) + expect(parseFloat(duration)).toBeLessThan(0.01) + }) +}) diff --git a/core/http/react-ui/e2e/canonical-resource-navigation.spec.js b/core/http/react-ui/e2e/canonical-resource-navigation.spec.js index 7e2835589..aae3749d3 100644 --- a/core/http/react-ui/e2e/canonical-resource-navigation.spec.js +++ b/core/http/react-ui/e2e/canonical-resource-navigation.spec.js @@ -49,8 +49,8 @@ test.describe('Canonical resource navigation', () => { await expect(sidebar.getByRole('link', { name: 'Discover', exact: true })).toHaveCount(0) await sidebar.getByRole('link', { name: 'Operate', exact: true }).click() - const operateRail = page.locator('.console-rail') - await expect(operateRail.getByRole('link', { name: /Backends/ })).toBeVisible() - await expect(operateRail.getByRole('link', { name: /Host/ })).toHaveCount(0) + const operateBar = page.locator('.dk-hubtabs') + await expect(operateBar.getByRole('link', { name: /Runtime/ })).toBeVisible() + await expect(operateBar.getByRole('link', { name: /Host/ })).toHaveCount(0) }) }) diff --git a/core/http/react-ui/e2e/chat-canvas-find.spec.js b/core/http/react-ui/e2e/chat-canvas-find.spec.js new file mode 100644 index 000000000..5d3316ac0 --- /dev/null +++ b/core/http/react-ui/e2e/chat-canvas-find.spec.js @@ -0,0 +1,184 @@ +import { test, expect } from './coverage-fixtures.js' +import { chatOf, pair, mockChat, openChat, GO_BLOCK } from './chat-fixtures.js' + +// The canvas opens on demand beside the thread, find marks matches in the +// messages that are loaded, and a long thread has a way back to the end. + +const CODE_THREAD = chatOf('c1', 'Code', 'qwen3-8b', [ + { role: 'user', content: 'write the handler and a page' }, + { + role: 'assistant', + content: 'First the handler:\n\n```go\n' + GO_BLOCK + '\n```\n\nThen the page:\n\n```html\n

Hello canvas

\n```\n\nThat is all.', + }, +]) + +const canvas = (page) => page.getByTestId('canvas-panel') + +test.describe('Chat canvas', () => { + test.beforeEach(async ({ page }) => { + await mockChat(page) + }) + + test('the Canvas button on a code block opens that block in the panel', async ({ page }) => { + await openChat(page, [CODE_THREAD]) + await expect(canvas(page)).toHaveCount(0) + await page.locator('.code-block').nth(1).getByRole('button', { name: 'Canvas' }).click() + await expect(canvas(page)).toBeVisible() + // Canvas mode is on now, and the block that was clicked is the one shown. + await expect(page.getByTestId('chat-canvas-chip')).toHaveAttribute('aria-pressed', 'true') + await expect(canvas(page).getByRole('tab', { selected: true })).toContainText('.html') + await expect(canvas(page).getByRole('button', { name: 'Preview' })).toHaveAttribute('aria-pressed', 'true') + await expect(canvas(page).frameLocator('iframe').getByRole('heading', { name: 'Hello canvas' })).toBeVisible() + }) + + test('with Canvas on, code blocks are cards and a card opens the panel with tabs', async ({ page }) => { + await openChat(page, [CODE_THREAD]) + await page.getByTestId('chat-canvas-chip').click() + // Turning it on opens the panel on the newest block; the blocks are cards now. + await expect(canvas(page)).toBeVisible() + await expect(page.locator('.artifact-card')).toHaveCount(2) + await expect(canvas(page).getByRole('tab')).toHaveCount(2) + + await canvas(page).getByRole('tab', { name: /main.go/ }).click() + await expect(canvas(page)).toContainText('context.WithCancel') + // Code and Preview switch for html. + await canvas(page).getByRole('tab', { name: /\.html/ }).click() + await canvas(page).getByRole('button', { name: 'Code' }).click() + await expect(canvas(page)).toContainText('

Hello canvas

') + await expect(canvas(page).getByRole('button', { name: 'Copy' })).toBeVisible() + await expect(canvas(page).getByRole('button', { name: 'Download' })).toBeVisible() + + await canvas(page).getByRole('button', { name: 'Close canvas' }).click() + await expect(canvas(page)).toHaveCount(0) + // The chip carries the count and opens it again. + await page.getByRole('button', { name: 'Open canvas panel' }).click() + await expect(canvas(page)).toBeVisible() + }) + + test('Esc closes the canvas', async ({ page }) => { + await openChat(page, [CODE_THREAD]) + await page.getByTestId('chat-canvas-chip').click() + await expect(canvas(page)).toBeVisible() + await page.locator('.cx-body').click({ position: { x: 5, y: 5 } }) + await page.keyboard.press('Escape') + await expect(canvas(page)).toHaveCount(0) + }) + + test('the panel is wide beside the thread on a desktop window', async ({ page }) => { + await openChat(page, [CODE_THREAD]) + await page.getByTestId('chat-canvas-chip').click() + const panel = await canvas(page).boundingBox() + const view = page.viewportSize() + expect(panel.width).toBeGreaterThan(view.width * 0.3) + // The thread keeps its own room beside it. + const thread = await page.getByTestId('chat-thread').boundingBox() + expect(thread.x + thread.width).toBeLessThanOrEqual(panel.x + 1) + }) + + test('on a phone the canvas takes the whole page', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await openChat(page, [CODE_THREAD]) + await page.getByTestId('chat-canvas-chip').click() + const box = await canvas(page).boundingBox() + expect(box.width).toBeGreaterThanOrEqual(389) + expect(box.height).toBeGreaterThan(500) + await canvas(page).getByRole('button', { name: 'Close canvas' }).click() + await expect(canvas(page)).toHaveCount(0) + }) +}) + +test.describe('Chat find', () => { + test.beforeEach(async ({ page }) => { + await mockChat(page) + }) + + const THREAD = chatOf('c1', 'Find', 'qwen3-8b', [ + ...pair('Why do goroutines leak?', 'Because nothing watches the context. Each goroutine waits forever, and the goroutine count grows.'), + ...pair('And the fix?', 'Select on ctx.Done() in every goroutine.'), + ]) + + test('Ctrl+Shift+F searches the loaded messages, shows n of m and steps with Enter', async ({ page }) => { + let completions = 0 + await page.route('**/v1/chat/completions', (route) => { completions++; route.abort() }) + await openChat(page, [THREAD]) + await page.keyboard.press('Control+Shift+F') + const input = page.getByTestId('chat-find-input') + await expect(input).toBeFocused() + await input.fill('goroutine') + const count = page.getByTestId('chat-find-count') + await expect(count).toHaveText('1 of 4') + await expect(page.locator('mark.cx-hit')).toHaveCount(4) + await expect(page.locator('mark.cx-hit[data-cur]')).toHaveCount(1) + await expect(page.locator('mark.cx-hit').first()).toHaveAttribute('data-cur', '') + + await input.press('Enter') + await expect(count).toHaveText('2 of 4') + await expect(page.locator('mark.cx-hit').nth(1)).toHaveAttribute('data-cur', '') + await input.press('Shift+Enter') + await input.press('Shift+Enter') + await expect(count).toHaveText('4 of 4') + + // Case does not matter, and a miss says so. + await input.fill('CTX.DONE') + await expect(count).toHaveText('1 of 1') + await input.fill('nothing like this') + await expect(count).toHaveText('No matches') + await expect(page.locator('mark.cx-hit')).toHaveCount(0) + + // The search never leaves the page. + expect(completions).toBe(0) + }) + + test('Esc closes the search, clears the marks and puts the cursor back in the box', async ({ page }) => { + await openChat(page, [THREAD]) + await page.getByTestId('chat-find-button').click() + await page.getByTestId('chat-find-input').fill('context') + await expect(page.locator('mark.cx-hit')).not.toHaveCount(0) + await page.keyboard.press('Escape') + await expect(page.getByTestId('chat-find')).toHaveCount(0) + await expect(page.locator('mark.cx-hit')).toHaveCount(0) + await expect(page.getByTestId('chat-input')).toBeFocused() + // The text of the messages is whole again. + await expect(page.locator('[data-role="assistant"]').first()).toContainText('Because nothing watches the context.') + }) + + test('it finds text inside code blocks and in your own messages', async ({ page }) => { + await openChat(page, [CODE_THREAD]) + await page.keyboard.press('Control+Shift+F') + await page.getByTestId('chat-find-input').fill('write the') + await expect(page.locator('[data-role="user"] mark.cx-hit')).toHaveCount(1) + await page.getByTestId('chat-find-input').fill('WithCancel') + await expect(page.locator('.code-block mark.cx-hit')).toHaveCount(1) + }) +}) + +test.describe('Chat long threads', () => { + test('Jump to latest appears when you leave the end and takes you back', async ({ page }) => { + await mockChat(page) + const history = [] + for (let i = 1; i <= 40; i++) history.push(...pair(`Question ${i} about context sizes`, `Answer ${i}. The same advice applies each time, so keep going.`)) + await openChat(page, [chatOf('c1', 'Long', 'qwen3-8b', history)]) + const body = page.locator('.cx-body') + await expect(page.getByTestId('chat-message')).toHaveCount(80) + await expect(page.getByTestId('chat-jump-latest')).toHaveCount(0) + + await body.evaluate(el => { el.scrollTop = 0 }) + const jump = page.getByTestId('chat-jump-latest') + await expect(jump).toBeVisible() + await jump.click() + await expect(jump).toHaveCount(0) + await expect.poll(() => body.evaluate(el => el.scrollHeight - el.scrollTop - el.clientHeight)).toBeLessThan(80) + }) + + test('a long thread does not push the page: the thread scrolls inside the window', async ({ page }) => { + await mockChat(page) + const history = [] + for (let i = 1; i <= 40; i++) history.push(...pair(`Question ${i}`, `Answer ${i}`)) + await openChat(page, [chatOf('c1', 'Long', 'qwen3-8b', history)]) + await expect(page.getByTestId('chat-message')).toHaveCount(80) + const overflow = await page.evaluate(() => document.documentElement.scrollHeight - window.innerHeight) + expect(overflow).toBeLessThanOrEqual(1) + // The composer stays in view. + await expect(page.getByTestId('chat-input')).toBeInViewport() + }) +}) diff --git a/core/http/react-ui/e2e/chat-composer.spec.js b/core/http/react-ui/e2e/chat-composer.spec.js new file mode 100644 index 000000000..48c85c27d --- /dev/null +++ b/core/http/react-ui/e2e/chat-composer.spec.js @@ -0,0 +1,226 @@ +import { test, expect } from './coverage-fixtures.js' +import { + chatOf, pair, mockChat, openChat, storedChats, answerWith, PNG, +} from './chat-fixtures.js' + +// The Chat composer is the Home command bar: the model chip, the MCP chip, a +// Canvas chip, attach buttons, a solid Send and the slash menu. These specs +// check that it is the same object and that what it adds for a conversation +// (Stop, Up to edit, the token line) works. + +const input = (page) => page.getByTestId('chat-input') + +test.describe('Chat composer', () => { + test.beforeEach(async ({ page }) => { + await mockChat(page) + }) + + test('is the Home command bar, with the Canvas chip and the hint line', async ({ page }) => { + await openChat(page, [chatOf('c1', 'Bar', 'qwen3-8b', pair('hi', 'hello'))]) + const bar = page.getByTestId('chat-composer') + await expect(bar).toHaveClass(/home-cmd/) + await expect(bar.getByTestId('home-model-chip')).toContainText('qwen3-8b') + await expect(bar.locator('.chat-mcp-dropdown')).toBeVisible() + await expect(bar.getByTestId('chat-canvas-chip')).toHaveAttribute('aria-pressed', 'false') + await expect(bar.getByRole('button', { name: 'Attach image' })).toBeVisible() + await expect(bar.getByRole('button', { name: 'Attach audio' })).toBeVisible() + await expect(bar.getByRole('button', { name: 'Attach file' })).toBeVisible() + await expect(bar).toContainText('Enter to send, Shift+Enter for a new line') + await expect(bar).toContainText('for actions') + }) + + test('Send is quiet and disabled when empty, active with text', async ({ page }) => { + await openChat(page, [chatOf('c1', 'Send', 'qwen3-8b', [])]) + const send = page.getByTestId('chat-send') + await expect(send).toBeDisabled() + await expect(send).toHaveAttribute('data-empty', 'true') + await input(page).fill('hello') + await expect(send).toBeEnabled() + await expect(send).not.toHaveAttribute('data-empty', 'true') + }) + + test('Enter sends, Shift+Enter adds a line, and Ctrl, Cmd and Alt Enter do not send', async ({ page }) => { + const answer = await answerWith(page, 'ok') + await openChat(page, [chatOf('c1', 'Keys', 'qwen3-8b', [])]) + await input(page).fill('one') + await input(page).press('Shift+Enter') + await expect(input(page)).toHaveValue('one\n') + for (const combo of ['Control+Enter', 'Meta+Enter', 'Alt+Enter']) await input(page).press(combo) + await expect(page.locator('[data-role="user"]')).toHaveCount(0) + expect(answer.request()).toBeNull() + + await input(page).press('Enter') + await expect(page.locator('[data-role="user"]')).toContainText('one') + expect(answer.request().messages.at(-1).content).toBe('one') + }) + + test('the settings the chat holds go out with the message', async ({ page }) => { + const answer = await answerWith(page, 'ok') + await openChat(page, [chatOf('c1', 'Params', 'qwen3-8b', [], { temperature: 0.3, topP: 0.8, topK: 20, systemPrompt: 'Be brief.' })]) + await input(page).fill('go') + await page.keyboard.press('Enter') + await expect(page.locator('[data-role="assistant"]')).toContainText('ok') + const body = answer.request() + expect(body).toMatchObject({ model: 'qwen3-8b', temperature: 0.3, top_p: 0.8, top_k: 20, stream: true }) + expect(body.messages[0]).toEqual({ role: 'system', content: 'Be brief.' }) + }) + + test('the slash menu lists the chat actions in two groups and filters as you type', async ({ page }) => { + await openChat(page, [chatOf('c1', 'Slash', 'qwen3-8b', pair('hi', 'hello'))]) + await input(page).fill('/') + const list = page.getByRole('listbox', { name: 'Actions' }) + await expect(list).toBeVisible() + await expect(list.locator('.dk-cmd-label')).toHaveText(['Chat', 'View']) + const names = await list.locator('.dk-cmd-name').allTextContents() + expect(names).toEqual(['/model', '/new', '/chats', '/assistant', '/canvas', '/find', '/settings', '/export', '/clear']) + + await input(page).fill('/set') + await expect(list.locator('.dk-cmd-name')).toHaveText(['/settings']) + await input(page).fill('/zzz') + await expect(list).toHaveCount(0) + + // A message that merely starts with a slash is a message. + await input(page).fill('/etc/hosts how do I read it') + await expect(list).toHaveCount(0) + }) + + test('arrow keys, Enter, Tab and Esc work in the slash menu', async ({ page }) => { + await openChat(page, [chatOf('c1', 'Slash', 'qwen3-8b', pair('hi', 'hello'))]) + await input(page).fill('/') + await input(page).press('ArrowDown') + await expect(page.getByRole('option', { selected: true })).toContainText('/new') + await input(page).press('ArrowUp') + await expect(page.getByRole('option', { selected: true })).toContainText('/model') + await input(page).press('Escape') + await expect(page.getByRole('listbox', { name: 'Actions' })).toHaveCount(0) + // Escape closes the menu and leaves the text. + await expect(input(page)).toHaveValue('/') + }) + + test('/new starts an empty chat, /canvas toggles Canvas, /settings opens the sheet', async ({ page }) => { + await openChat(page, [chatOf('c1', 'Slash', 'qwen3-8b', pair('hi', 'hello'))]) + await input(page).fill('/canvas') + await input(page).press('Enter') + await expect(page.getByTestId('chat-canvas-chip')).toHaveAttribute('aria-pressed', 'true') + await expect(input(page)).toHaveValue('') + + await input(page).fill('/settings') + await input(page).press('Enter') + await expect(page.getByTestId('chat-settings')).toBeVisible() + await page.keyboard.press('Escape') + await expect(page.getByTestId('chat-settings')).toHaveCount(0) + + await input(page).fill('/new') + await input(page).press('Enter') + await expect(page.getByTestId('chat-empty')).toBeVisible() + await expect.poll(async () => (await storedChats(page)).chats.length).toBe(2) + }) + + test('/chats opens the conversations, /find opens the search, /clear asks first', async ({ page }) => { + await openChat(page, [chatOf('c1', 'Slash', 'qwen3-8b', pair('hi', 'hello'))]) + await input(page).fill('/chats') + await input(page).press('Enter') + await expect(page.getByTestId('chats-menu')).toBeVisible() + await page.keyboard.press('Escape') + + await input(page).fill('/find') + await input(page).press('Enter') + await expect(page.getByTestId('chat-find')).toBeVisible() + await page.getByTestId('chat-find-input').press('Escape') + + await input(page).fill('/clear') + await input(page).press('Enter') + const dialog = page.getByRole('alertdialog') + await expect(dialog).toContainText('Clear this conversation') + await dialog.getByRole('button', { name: 'Clear conversation' }).click() + await expect(page.getByTestId('chat-empty')).toBeVisible() + await expect.poll(async () => (await storedChats(page)).chats[0].history.length).toBe(0) + }) + + test('/export downloads the chat as Markdown', async ({ page }) => { + await openChat(page, [chatOf('c1', 'Slash export', 'qwen3-8b', pair('hi', 'hello'))]) + const download = page.waitForEvent('download') + await input(page).fill('/export') + await input(page).press('Enter') + expect((await download).suggestedFilename()).toBe('Slash_export.md') + }) + + test('the actions that need a thread are hidden in an empty chat', async ({ page }) => { + await openChat(page, [chatOf('c1', 'Empty', 'qwen3-8b', [])]) + await input(page).fill('/') + const names = await page.getByRole('listbox', { name: 'Actions' }).locator('.dk-cmd-name').allTextContents() + expect(names).toEqual(['/model', '/new', '/chats', '/assistant', '/canvas', '/settings']) + }) + + test('"/" from outside a text field starts a command', async ({ page }) => { + await openChat(page, [chatOf('c1', 'Slash', 'qwen3-8b', pair('hi', 'hello'))]) + await page.locator('.cx-body').click({ position: { x: 5, y: 5 } }) + await page.keyboard.press('/') + await expect(input(page)).toBeFocused() + await expect(input(page)).toHaveValue('/') + await expect(page.getByRole('listbox', { name: 'Actions' })).toBeVisible() + }) + + test('an attached image shows as a thumbnail tag and can be removed', async ({ page }) => { + await openChat(page, [chatOf('c1', 'Attach', 'gemma-3-12b', [])]) + await page.locator('input[type=file][accept="image/*"]').setInputFiles({ + name: 'pixel.png', mimeType: 'image/png', buffer: Buffer.from(PNG.split(',')[1], 'base64'), + }) + const tag = page.locator('.home-file-tag', { hasText: 'pixel.png' }) + await expect(tag).toBeVisible() + await expect(tag.locator('img')).toBeVisible() + await expect(page.getByTestId('chat-send')).toBeEnabled() + await tag.getByRole('button', { name: /Remove pixel.png/ }).click() + await expect(tag).toHaveCount(0) + }) + + test('a pasted image attaches as a file', async ({ page }) => { + await openChat(page, [chatOf('c1', 'Paste', 'gemma-3-12b', [])]) + await input(page).focus() + await input(page).evaluate((el, b64) => { + const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0)) + const dt = new DataTransfer() + dt.items.add(new File([bytes], 'image.png', { type: 'image/png' })) + el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })) + }, PNG.split(',')[1]) + await expect(page.locator('.home-file-tag', { hasText: 'pasted-image-1.png' })).toBeVisible() + }) + + test('the line under the box shows the token count against the context size', async ({ page }) => { + await openChat(page, [chatOf('c1', 'Tokens', 'qwen3-8b', pair('hi', 'hello'), { + contextSize: 8192, tokenUsage: { prompt: 2600, completion: 540, total: 3140 }, + })]) + await expect(page.getByTestId('chat-foot')).toContainText('3140 / 8192 tokens') + await expect(page.getByTestId('chat-context')).toContainText('38%') + }) + + test('the foot warns when the context is nearly full', async ({ page }) => { + await openChat(page, [chatOf('c1', 'Full', 'qwen3-8b', pair('hi', 'hello'), { + contextSize: 8192, tokenUsage: { prompt: 7000, completion: 400, total: 7400 }, + })]) + await expect(page.getByTestId('chat-foot')).toContainText('The context is nearly full') + await expect(page.getByTestId('chat-context')).toHaveAttribute('data-warn', 'true') + }) + + test('Up in an empty box edits your last message', async ({ page }) => { + await openChat(page, [chatOf('c1', 'Up', 'qwen3-8b', [...pair('first question', 'first answer'), ...pair('second question', 'second answer')])]) + await input(page).focus() + await input(page).press('ArrowUp') + const user = page.locator('[data-role="user"]').last() + await expect(user.getByRole('textbox')).toHaveValue('second question') + await user.getByRole('textbox').press('Escape') + + // With text in the box the key does what it always did. + await input(page).fill('draft') + await input(page).press('ArrowUp') + await expect(page.getByRole('textbox', { name: 'Edit message' })).toHaveCount(0) + }) + + test('the model chip changes the model of this chat', async ({ page }) => { + await openChat(page, [chatOf('c1', 'Model', 'qwen3-8b', [])]) + await page.getByTestId('home-model-chip').click() + await page.getByRole('option', { name: /phi-4-mini/ }).click() + await expect(page.getByTestId('home-model-chip')).toContainText('phi-4-mini') + await expect.poll(async () => (await storedChats(page)).chats[0].model).toBe('phi-4-mini') + }) +}) diff --git a/core/http/react-ui/e2e/chat-errors.spec.js b/core/http/react-ui/e2e/chat-errors.spec.js index b49817ec7..2562001d3 100644 --- a/core/http/react-ui/e2e/chat-errors.spec.js +++ b/core/http/react-ui/e2e/chat-errors.spec.js @@ -28,10 +28,10 @@ test.describe('Chat - Error Handling', () => { await page.goto('/app/chat') // Wait for the model to be auto-selected (ModelSelector shows model name in button) - await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 }) + await expect(page.getByRole('button', { name: /test-model/ })).toBeVisible({ timeout: 10_000 }) - await page.locator('.chat-input').fill('Hello') - await page.locator('.chat-send-btn').click() + await page.getByTestId('chat-input').fill('Hello') + await page.getByTestId('chat-send').click() await expect(page.getByRole('paragraph').filter({ hasText: 'Model failed to load' })).toBeVisible({ timeout: 10_000 }) }) @@ -50,10 +50,10 @@ test.describe('Chat - Error Handling', () => { }) await page.goto('/app/chat') - await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 }) + await expect(page.getByRole('button', { name: /test-model/ })).toBeVisible({ timeout: 10_000 }) - await page.locator('.chat-input').fill('Hello') - await page.locator('.chat-send-btn').click() + await page.getByTestId('chat-input').fill('Hello') + await page.getByTestId('chat-send').click() await expect(page.getByRole('paragraph').filter({ hasText: 'Backend crashed unexpectedly' })).toBeVisible({ timeout: 10_000 }) await expect(page.locator('.chat-error-trace-link')).toBeVisible() @@ -76,10 +76,10 @@ test.describe('Chat - Error Handling', () => { }) await page.goto('/app/chat') - await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 }) + await expect(page.getByRole('button', { name: /test-model/ })).toBeVisible({ timeout: 10_000 }) - await page.locator('.chat-input').fill('Hello') - await page.locator('.chat-send-btn').click() + await page.getByTestId('chat-input').fill('Hello') + await page.getByTestId('chat-send').click() await expect(page.getByRole('paragraph').filter({ hasText: 'Backend crashed mid-stream' })).toBeVisible({ timeout: 10_000 }) }) @@ -96,10 +96,10 @@ test.describe('Chat - Error Handling', () => { }) await page.goto('/app/chat') - await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 }) + await expect(page.getByRole('button', { name: /test-model/ })).toBeVisible({ timeout: 10_000 }) - await page.locator('.chat-input').fill('Hello') - await page.locator('.chat-send-btn').click() + await page.getByTestId('chat-input').fill('Hello') + await page.getByTestId('chat-send').click() await expect(page.getByRole('paragraph').filter({ hasText: 'HTTP 502' })).toBeVisible({ timeout: 10_000 }) }) diff --git a/core/http/react-ui/e2e/chat-fixtures.js b/core/http/react-ui/e2e/chat-fixtures.js new file mode 100644 index 000000000..5fc80b04c --- /dev/null +++ b/core/http/react-ui/e2e/chat-fixtures.js @@ -0,0 +1,146 @@ +// Shared fixtures for the Chat and Talk specs: stubbed API routes, conversations +// written to the browser storage the Chat page reads, and a completion stream +// the spec drives by hand. +import { mockHome, seedChats, CHATS_KEY } from './home-fixtures.js' + +export { CHATS_KEY, seedChats } + +const GB = 1024 * 1024 * 1024 + +export const MODELS = [ + { id: 'qwen3-8b', capabilities: ['FLAG_CHAT'] }, + { id: 'gemma-3-12b', capabilities: ['FLAG_CHAT', 'FLAG_VISION'] }, + { id: 'phi-4-mini', capabilities: ['FLAG_CHAT'] }, +] + +// A bare conversation in the shape useChat stores. +export function chatOf(id, name, model, history, extra = {}, updatedAt = Date.now()) { + return { + id, name, model, history, updatedAt, createdAt: updatedAt - 60_000, + systemPrompt: '', mcpMode: false, mcpServers: [], clientMCPServers: [], + temperature: null, topP: null, topK: null, + tokenUsage: { prompt: 0, completion: 0, total: 0 }, contextSize: null, + ...extra, + } +} + +export const pair = (q, a) => [{ role: 'user', content: q }, { role: 'assistant', content: a }] + +export const GO_BLOCK = 'func main() {\n\tctx, cancel := context.WithCancel(context.Background())\n\tdefer cancel()\n}' + +// Stub what Chat reads. Models are the chat models the list offers; `loaded` +// the ones the server holds in memory. +export async function mockChat(page, { models = MODELS, loaded = ['qwen3-8b', 'phi-4-mini'], resources, features } = {}) { + await mockHome(page, { + loaded: loaded.map(id => ({ id, backend: 'llama-cpp' })), + models: models.map(m => m.id), + chatModels: models.map(m => m.id), + ...(resources ? { resources } : {}), + ...(features ? { features } : {}), + }) + // Capabilities with the vision flag, which mockHome leaves out. + await page.route('**/api/models/capabilities', route => route.fulfill({ json: { data: models } })) +} + +export const RESOURCES_24GB = { + type: 'gpu', + gpus: [{ name: 'RTX 4090', vendor: 'nvidia', total_vram: 24 * GB, used_vram: 7.6 * GB }], + ram: { total: 64 * GB, used: 20 * GB, available: 40 * GB }, + aggregate: { total_memory: 24 * GB, used_memory: 7.6 * GB, gpu_count: 1, usage_percent: 31 }, +} + +// One answer in the OpenAI stream format. +export function sse(text, usage = { prompt_tokens: 3, completion_tokens: 5, total_tokens: 8 }) { + return `data: ${JSON.stringify({ choices: [{ delta: { content: text } }] })}\n\n` + + `data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop' }], usage })}\n\n` + + 'data: [DONE]\n\n' +} + +export async function answerWith(page, text) { + let body = null + await page.route('**/v1/chat/completions', (route) => { + body = route.request().postDataJSON() + route.fulfill({ status: 200, contentType: 'text/event-stream', body: sse(text) }) + }) + return { request: () => body } +} + +// Replace fetch for the completion endpoint with a stream the spec drives: +// await page.evaluate(() => window.__sse.push({ choices: [{ delta: { content: 'Hi' } }] })) +// await page.evaluate(() => window.__sse.end()) +// Abort is honoured, as the real fetch does. +export async function controlStream(page) { + await page.addInitScript(() => { + const orig = window.fetch.bind(window) + const enc = new TextEncoder() + window.__sse = { ctrl: null, calls: 0 } + window.__sse.push = (obj) => window.__sse.ctrl?.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`)) + window.__sse.end = () => { window.__sse.ctrl?.enqueue(enc.encode('data: [DONE]\n\n')); window.__sse.ctrl?.close() } + window.fetch = (url, init) => { + if (!String(url).includes('/v1/chat/completions')) return orig(url, init) + window.__sse.calls++ + const stream = new ReadableStream({ start(c) { window.__sse.ctrl = c } }) + init?.signal?.addEventListener('abort', () => { + try { window.__sse.ctrl.error(new DOMException('aborted', 'AbortError')) } catch { /* already closed */ } + }) + return Promise.resolve(new Response(stream, { status: 200, headers: { 'Content-Type': 'text/event-stream' } })) + } + }) +} + +export async function storedChats(page) { + return page.evaluate((key) => JSON.parse(localStorage.getItem(key) || '{}'), CHATS_KEY) +} + +// Open Chat with these conversations stored. +export async function openChat(page, chats, active = chats[0]?.id) { + await seedChats(page, chats, active) + await page.goto('/app/chat') + // Keys and clicks only work once the page has mounted its handlers. + await page.getByTestId('chat-composer').waitFor() +} + +// A tiny valid PNG, for a message with an image in it. +export const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==' + +// The Talk page's WebRTC, faked: the page connects as it would, and the spec +// plays the server's events with window.__talk.emit(event). +export async function fakeRealtime(page, { pipelines = true, micError = null } = {}) { + await page.route('**/api/pipeline-models', route => route.fulfill({ + json: pipelines + ? [ + { name: 'voice-assistant', voice: 'alloy', vad: 'silero-vad', transcription: 'whisper-large-v3-turbo', llm: 'qwen3-8b', tts: 'kokoro-82m' }, + { name: 'voice-it', voice: 'paola', vad: 'silero-vad', transcription: 'whisper-small', llm: 'gemma-3-12b', tts: 'piper-it' }, + ] + : [], + })) + await page.route('**/v1/realtime/calls', route => route.fulfill({ json: { sdp: 'v=0' } })) + await page.addInitScript(({ micError }) => { + window.__talk = { dc: null, pc: null, sent: [], hold: false } + class FakePC { + constructor() { this.connectionState = 'new'; this.iceGatheringState = 'complete'; this.localDescription = null; window.__talk.pc = this } + addTrack() {} + createDataChannel() { + const dc = { readyState: 'open', send: (m) => window.__talk.sent.push(JSON.parse(m)), close() {}, onmessage: null } + window.__talk.dc = dc + return dc + } + async createOffer() { return { type: 'offer', sdp: 'v=0' } } + async setLocalDescription(d) { this.localDescription = d } + async setRemoteDescription() { + if (window.__talk.hold) return new Promise(() => {}) + this.connectionState = 'connected' + this.onconnectionstatechange && this.onconnectionstatechange() + } + close() { this.connectionState = 'closed' } + getStats() { return Promise.resolve(new Map()) } + } + window.RTCPeerConnection = FakePC + navigator.mediaDevices.getUserMedia = async () => { + if (micError) { const e = new Error('denied'); e.name = micError; throw e } + return new MediaStream() + } + window.__talk.emit = (ev) => window.__talk.dc.onmessage({ data: JSON.stringify(ev) }) + window.__talk.fail = () => { window.__talk.pc.connectionState = 'failed'; window.__talk.pc.onconnectionstatechange() } + }, { micError }) +} diff --git a/core/http/react-ui/e2e/chat-history.spec.js b/core/http/react-ui/e2e/chat-history.spec.js new file mode 100644 index 000000000..05f024ed5 --- /dev/null +++ b/core/http/react-ui/e2e/chat-history.spec.js @@ -0,0 +1,215 @@ +import { test, expect } from './coverage-fixtures.js' +import { mockChat, openChat, storedChats, chatOf, pair } from './chat-fixtures.js' +import { sampleChats } from './home-fixtures.js' + +// The conversations list opens on Ctrl K. It replaces the permanent history +// pane: groups by day like the Home list, search, resume, rename, delete with +// undo, and the same duplicate, copy and export actions as before. + +const menu = (page) => page.getByTestId('chats-menu') +const rows = (page) => menu(page).getByTestId('chats-row') + +async function openMenu(page) { + await expect(page.getByTestId('chats-trigger')).toBeVisible() + await page.keyboard.press('Control+k') + await expect(menu(page)).toBeVisible() + // The search box takes focus a moment after the menu opens; keys need it. + await expect(menu(page).getByRole('combobox')).toBeFocused() +} + +test.describe('Chat conversations menu', () => { + test.beforeEach(async ({ page }) => { + await mockChat(page, { models: [ + { id: 'qwen3-8b-instruct', capabilities: ['FLAG_CHAT'] }, + { id: 'gemma-4-e4b-it-qat-q4_0', capabilities: ['FLAG_CHAT'] }, + { id: 'qwen2.5-vl-7b-instruct', capabilities: ['FLAG_CHAT'] }, + ], loaded: ['qwen3-8b-instruct'] }) + await openChat(page, sampleChats()) + await expect(page.getByTestId('chat-message').first()).toBeVisible() + }) + + test('opens on Ctrl K, closes on Esc and gives focus back', async ({ page }) => { + const composer = page.getByTestId('chat-input') + await composer.focus() + await openMenu(page) + await expect(menu(page).getByRole('combobox')).toBeFocused() + await page.keyboard.press('Escape') + await expect(menu(page)).toHaveCount(0) + await expect(composer).toBeFocused() + // The button opens it too, and Ctrl K closes it again. + await page.getByTestId('chats-trigger').click() + await expect(menu(page)).toBeVisible() + await page.keyboard.press('Control+k') + await expect(menu(page)).toHaveCount(0) + }) + + test('groups the chats by day, newest first, with the model and the time', async ({ page }) => { + await openMenu(page) + await expect(page.getByTestId('chats-day-today').getByRole('heading')).toHaveText('Today') + await expect(page.getByTestId('chats-day-yesterday').getByRole('heading')).toHaveText('Yesterday') + await expect(page.getByTestId('chats-day-week').getByRole('heading')).toHaveText('Earlier this week') + await expect(page.getByTestId('chats-day-today').getByTestId('chats-row')).toHaveCount(2) + await expect(rows(page).first()).toContainText('Rewrite the 3.4 release notes for clarity') + await expect(rows(page).first()).toContainText('qwen3-8b-instruct') + // The line under the name is the last thing said. + await expect(rows(page).first()).toContainText('Anytime.') + // The open chat is marked. + await expect(rows(page).first()).toHaveAttribute('data-current', 'true') + }) + + test('search narrows by name and by what was said, and says when nothing matches', async ({ page }) => { + await openMenu(page) + const search = menu(page).getByRole('combobox') + await search.fill('regex') + await expect(rows(page)).toHaveCount(1) + await expect(rows(page).first()).toContainText('Regex for semver') + // A word that only appears inside a message. + await search.fill('accounting') + await expect(rows(page)).toHaveCount(1) + await expect(rows(page).first()).toContainText('Draft a polite reply about the invoice') + await expect(rows(page).first()).toContainText('accounting') + await search.fill('zzzz') + await expect(rows(page)).toHaveCount(0) + await expect(menu(page)).toContainText('No conversations match your search') + }) + + test('arrow keys move and Enter resumes that conversation', async ({ page }) => { + await openMenu(page) + await page.keyboard.press('ArrowDown') + await expect(rows(page).nth(1)).toHaveAttribute('aria-selected', 'true') + await page.keyboard.press('Enter') + await expect(menu(page)).toHaveCount(0) + await expect(page.getByTestId('chat-title')).toHaveText('Why does llama-cpp stall at a 4096 context') + await expect.poll(async () => (await storedChats(page)).activeChatId).toBe('c-today-b') + }) + + test('clicking a row resumes it', async ({ page }) => { + await openMenu(page) + await rows(page).filter({ hasText: 'Regex for semver' }).click() + await expect(page.getByTestId('chat-title')).toHaveText('Regex for semver with prerelease tags') + }) + + test('F2 renames in place and Enter saves', async ({ page }) => { + await openMenu(page) + await page.keyboard.press('F2') + const box = menu(page).getByRole('textbox', { name: 'Rename' }) + await expect(box).toBeFocused() + await box.fill('Release notes 3.4') + await box.press('Enter') + await expect(rows(page).first()).toContainText('Release notes 3.4') + await expect.poll(async () => (await storedChats(page)).chats.find(c => c.id === 'c-today-a').name).toBe('Release notes 3.4') + // The header shows it too. + await page.keyboard.press('Escape') + await expect(page.getByTestId('chat-title')).toHaveText('Release notes 3.4') + }) + + test('Escape in the rename box cancels and keeps the menu open', async ({ page }) => { + await openMenu(page) + await page.keyboard.press('F2') + await menu(page).getByRole('textbox', { name: 'Rename' }).fill('Nope') + await page.keyboard.press('Escape') + await expect(menu(page)).toBeVisible() + await expect(rows(page).first()).toContainText('Rewrite the 3.4 release notes for clarity') + }) + + test('the title in the header renames the chat with a click', async ({ page }) => { + await page.getByTestId('chat-title').click() + const box = page.getByTestId('chat-title-input') + await expect(box).toBeFocused() + await box.fill('Clearer notes') + await box.press('Enter') + await expect(page.getByTestId('chat-title')).toHaveText('Clearer notes') + await expect.poll(async () => (await storedChats(page)).chats.find(c => c.id === 'c-today-a').name).toBe('Clearer notes') + }) + + test('Delete hides the row and offers an undo; Undo brings it back', async ({ page }) => { + await openMenu(page) + const target = rows(page).filter({ hasText: 'Regex for semver' }) + await target.hover() + await page.keyboard.press('Delete') + await expect(rows(page).filter({ hasText: 'Regex for semver' })).toHaveCount(0) + const toast = page.getByTestId('chat-undo-toast') + await expect(toast).toContainText('Deleted "Regex for semver with prerelease tags"') + // Until the undo time ends the chat is still stored. + expect((await storedChats(page)).chats.map(c => c.id)).toContain('c-week-a') + + await toast.getByRole('button', { name: 'Undo' }).click() + await expect(toast).toHaveCount(0) + await expect(rows(page).filter({ hasText: 'Regex for semver' })).toHaveCount(1) + expect((await storedChats(page)).chats.map(c => c.id)).toContain('c-week-a') + }) + + test('the chat is deleted for good when the undo time ends', async ({ page }) => { + await openMenu(page) + const row = rows(page).filter({ hasText: 'Regex for semver' }) + await row.hover() + await row.getByTitle('Delete chat').click() + const toast = page.getByTestId('chat-undo-toast') + await expect(toast).toBeVisible() + // Dismissing the toast ends the undo time at once. + await toast.getByRole('button', { name: 'Dismiss' }).click() + await expect(toast).toHaveCount(0) + await expect.poll(async () => (await storedChats(page)).chats.map(c => c.id)).not.toContain('c-week-a') + }) + + test('the undo time runs out by itself after six seconds', async ({ page }) => { + await page.clock.install() + await page.reload() + await page.getByTestId('chat-composer').waitFor() + await openMenu(page) + const row = rows(page).filter({ hasText: 'Regex for semver' }) + await row.hover() + await row.getByTitle('Delete chat').click() + await expect(page.getByTestId('chat-undo-toast')).toBeVisible() + await page.clock.runFor(6500) + await expect(page.getByTestId('chat-undo-toast')).toHaveCount(0) + await expect.poll(async () => (await storedChats(page)).chats.map(c => c.id)).not.toContain('c-week-a') + }) + + test('deleting the open chat opens another one, and Undo returns to it', async ({ page }) => { + await openMenu(page) + await page.keyboard.press('Delete') + await expect(page.getByTestId('chat-undo-toast')).toBeVisible() + await expect(page.getByTestId('chat-title')).not.toHaveText('Rewrite the 3.4 release notes for clarity') + await page.getByTestId('chat-undo-toast').getByRole('button', { name: 'Undo' }).click() + await expect(page.getByTestId('chat-title')).toHaveText('Rewrite the 3.4 release notes for clarity') + }) + + test('Duplicate, Copy chat and Export are on every row', async ({ page }) => { + await openMenu(page) + const row = rows(page).first() + await expect(row.getByTitle('Duplicate chat')).toBeVisible() + await expect(row.getByTitle('Copy chat')).toBeVisible() + await expect(row.getByTitle('Export as Markdown')).toBeVisible() + await expect(row.getByTitle('Rename')).toBeVisible() + await expect(row.getByTitle('Delete chat')).toBeVisible() + }) + + test('New chat from the menu opens an empty chat', async ({ page }) => { + await openMenu(page) + await page.getByTestId('chats-new').click() + await expect(menu(page)).toHaveCount(0) + await expect(page.getByTestId('chat-empty')).toBeVisible() + }) + + test('Clear all asks before it removes anything', async ({ page }) => { + await openMenu(page) + await menu(page).getByRole('button', { name: 'Clear all' }).click() + const dialog = page.getByRole('alertdialog') + await expect(dialog).toContainText('Delete all chats?') + await dialog.getByRole('button', { name: 'Delete all' }).click() + await expect.poll(async () => (await storedChats(page)).chats.length).toBe(1) + await expect(page.getByTestId('chat-empty')).toBeVisible() + }) +}) + +test.describe('Chat conversations menu with one chat', () => { + test('has no delete button and no Clear all', async ({ page }) => { + await mockChat(page) + await openChat(page, [chatOf('only', 'The only chat', 'qwen3-8b', pair('hi', 'hello'))]) + await openMenu(page) + await expect(rows(page)).toHaveCount(1) + await expect(rows(page).first().getByTitle('Delete chat')).toHaveCount(0) + await expect(menu(page).getByRole('button', { name: 'Clear all' })).toHaveCount(0) + }) +}) diff --git a/core/http/react-ui/e2e/chat-model-loading.spec.js b/core/http/react-ui/e2e/chat-model-loading.spec.js index e8e564d21..9a48a0cf8 100644 --- a/core/http/react-ui/e2e/chat-model-loading.spec.js +++ b/core/http/react-ui/e2e/chat-model-loading.spec.js @@ -70,15 +70,16 @@ test.describe('Chat - model loading', () => { }) await page.goto('/app/chat') - await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 }) + await expect(page.getByRole('button', { name: /test-model/ })).toBeVisible({ timeout: 10_000 }) - await page.locator('.chat-input').fill('Hello') - await page.locator('.chat-send-btn').click() + await page.getByTestId('chat-input').fill('Hello') + await page.getByTestId('chat-send').click() // The 503 is shown as progress, not as an error. - await expect(page.locator('.chat-staging-progress')).toBeVisible({ timeout: 10_000 }) - await expect(page.locator('.chat-staging-label')).toContainText('nvidia-thor') - await expect(page.locator('.chat-staging-pct')).toContainText('41%') + await expect(page.getByTestId('chat-load')).toBeVisible({ timeout: 10_000 }) + await expect(page.getByTestId('chat-load-phase')).toContainText('nvidia-thor') + await expect(page.getByTestId('chat-load-pct')).toContainText('41%') + await expect(page.getByTestId('chat-load')).toContainText('13.7 of 33.3 GB') // ...and the request retries itself once the load reports ready. await expect(page.getByText('loaded at last')).toBeVisible({ timeout: 25_000 }) diff --git a/core/http/react-ui/e2e/chat-model-switcher.spec.js b/core/http/react-ui/e2e/chat-model-switcher.spec.js new file mode 100644 index 000000000..2360448fc --- /dev/null +++ b/core/http/react-ui/e2e/chat-model-switcher.spec.js @@ -0,0 +1,145 @@ +import { test, expect } from './coverage-fixtures.js' +import { chatOf, pair, mockChat, openChat, storedChats, RESOURCES_24GB } from './chat-fixtures.js' + +// The model chip lists the chat models: loaded ones first, a dot for warm or +// not loaded, an eye for vision, and, where the server can estimate, whether +// the model fits this machine. Nothing is shown that the API does not report. + +const GB = 1024 * 1024 * 1024 +const NEED_GB = { 'qwen3-8b': 5.1, 'gemma-3-12b': 7.6, 'phi-4-mini': 2.5, 'llama-70b': 42.5, 'deepseek-32b': 19.5 } + +const MODELS = [ + { id: 'qwen3-8b', capabilities: ['FLAG_CHAT'] }, + { id: 'gemma-3-12b', capabilities: ['FLAG_CHAT', 'FLAG_VISION'] }, + { id: 'phi-4-mini', capabilities: ['FLAG_CHAT'] }, + { id: 'deepseek-32b', capabilities: ['FLAG_CHAT'] }, + { id: 'llama-70b', capabilities: ['FLAG_CHAT'] }, + { id: 'mystery-model', capabilities: ['FLAG_CHAT'] }, +] + +async function stubEstimates(page) { + const asked = [] + await page.route('**/api/models/vram-estimate', (route) => { + const body = route.request().postDataJSON() + asked.push(body) + const need = NEED_GB[body.model] + // A model the server cannot read answers 200 with no figure. + if (!need) return route.fulfill({ json: { message: 'no estimate for this format' } }) + return route.fulfill({ json: { vram_bytes: need * GB, size_bytes: need * 0.9 * GB } }) + }) + return asked +} + +const chip = (page) => page.getByTestId('home-model-chip') +const row = (page, name) => page.getByRole('option').filter({ has: page.locator('code', { hasText: new RegExp(`^${name}$`) }) }) + +test.describe('Chat model switcher', () => { + test.beforeEach(async ({ page }) => { + await mockChat(page, { models: MODELS, loaded: ['qwen3-8b', 'phi-4-mini'], resources: RESOURCES_24GB }) + }) + + test('groups loaded and installed models and says warm or not loaded', async ({ page }) => { + await stubEstimates(page) + await openChat(page, [chatOf('c1', 'Models', 'qwen3-8b', pair('hi', 'hello'), { contextSize: 8192 })]) + await chip(page).click() + const heads = page.locator('.home-menu__head') + await expect(heads).toHaveText(['Loaded now', 'Installed']) + await expect(row(page, 'qwen3-8b')).toContainText('warm') + await expect(row(page, 'phi-4-mini')).toContainText('warm') + await expect(row(page, 'gemma-3-12b')).toContainText('not loaded') + // Loaded models come first. + const names = await page.getByRole('option').locator('code').allTextContents() + expect(names.slice(0, 2).sort()).toEqual(['phi-4-mini', 'qwen3-8b']) + // The chip's dot follows the loaded state. + await expect(chip(page).locator('.home-dot')).not.toHaveClass(/home-dot--cold/) + }) + + test('marks the models that understand images', async ({ page }) => { + await stubEstimates(page) + await openChat(page, [chatOf('c1', 'Models', 'qwen3-8b', [])]) + await chip(page).click() + await expect(row(page, 'gemma-3-12b').getByRole('img', { name: 'Understands images' })).toBeVisible() + await expect(row(page, 'phi-4-mini').getByRole('img', { name: 'Understands images' })).toHaveCount(0) + }) + + test('reads memory and estimates once the list opens, then shows what fits', async ({ page }) => { + const asked = await stubEstimates(page) + await openChat(page, [chatOf('c1', 'Models', 'qwen3-8b', [], { contextSize: 16384 })]) + // Nothing is asked until the list opens. + await page.waitForTimeout(300) + expect(asked).toHaveLength(0) + + await chip(page).click() + // Warm models say they are ready; a cold one that fits says how much room is left. + await expect(row(page, 'qwen3-8b')).toContainText('ready now') + await expect(row(page, 'gemma-3-12b')).toContainText('GB free') + await expect(row(page, 'gemma-3-12b')).toContainText('7.6 GB') + // 19.5 GB on a 24 GB card with a 22.8 GB limit fits with 3.3 left. + await expect(row(page, 'deepseek-32b')).toContainText('3.3 GB free') + // 42.5 GB does not fit the card; the rest would run on the CPU (RAM is 40 GB). + await expect(row(page, 'llama-70b')).toContainText('runs on the CPU') + // The estimates use this chat's context size. + expect(asked.every(b => b.context_size === 16384)).toBe(true) + // The memory bar closes the list. + await expect(page.getByTestId('chat-model-memory')).toContainText('7.6 of 24 GB used') + }) + + test('says how far over the machine a model is when it cannot run at all', async ({ page }) => { + await page.route('**/api/resources', route => route.fulfill({ json: { + ...RESOURCES_24GB, ram: { total: 16 * GB, used: 10 * GB, available: 6 * GB }, + } })) + await stubEstimates(page) + await openChat(page, [chatOf('c1', 'Models', 'qwen3-8b', [])]) + await chip(page).click() + await expect(row(page, 'llama-70b')).toContainText('more than this machine has') + }) + + test('a model with no estimate shows no fit text and no invented numbers', async ({ page }) => { + await stubEstimates(page) + await openChat(page, [chatOf('c1', 'Models', 'qwen3-8b', [])]) + await chip(page).click() + await expect(row(page, 'deepseek-32b')).toContainText('GB free') + const mystery = row(page, 'mystery-model') + await expect(mystery).toContainText('not loaded') + await expect(mystery).not.toContainText('GB') + await expect(mystery).not.toContainText('free') + }) + + test('picking a model changes this chat and remembers it', async ({ page }) => { + await stubEstimates(page) + await openChat(page, [chatOf('c1', 'Models', 'qwen3-8b', [])]) + await chip(page).click() + await row(page, 'gemma-3-12b').click() + await expect(chip(page)).toContainText('gemma-3-12b') + await expect(page.getByRole('option')).toHaveCount(0) + await expect.poll(async () => (await storedChats(page)).chats[0].model).toBe('gemma-3-12b') + // Cold now: the dot is hollow. + await expect(chip(page).locator('.home-dot')).toHaveClass(/home-dot--cold/) + }) + + test('/model opens the list and Esc closes it', async ({ page }) => { + await stubEstimates(page) + await openChat(page, [chatOf('c1', 'Models', 'qwen3-8b', pair('hi', 'hello'))]) + await page.getByTestId('chat-input').fill('/model') + await page.getByTestId('chat-input').press('Enter') + await expect(page.getByRole('listbox', { name: 'Model' })).toBeVisible() + await page.keyboard.press('ArrowDown') + await page.keyboard.press('Escape') + await expect(page.getByRole('listbox', { name: 'Model' })).toHaveCount(0) + await expect(chip(page)).toBeFocused() + }) + + test('on a phone the list is a sheet from the bottom', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await stubEstimates(page) + await openChat(page, [chatOf('c1', 'Models', 'qwen3-8b', pair('hi', 'hello'))]) + await chip(page).click() + const menu = page.locator('.home-menu--models') + await expect(menu).toBeVisible() + const box = await menu.boundingBox() + expect(box.x).toBeGreaterThanOrEqual(0) + expect(box.x + box.width).toBeLessThanOrEqual(390) + expect(box.y + box.height).toBeLessThanOrEqual(844) + expect(await menu.evaluate(el => getComputedStyle(el).position)).toBe('fixed') + }) +}) diff --git a/core/http/react-ui/e2e/chat-pdf-attachment.spec.js b/core/http/react-ui/e2e/chat-pdf-attachment.spec.js index eaf1d0fb1..1bb6bdd23 100644 --- a/core/http/react-ui/e2e/chat-pdf-attachment.spec.js +++ b/core/http/react-ui/e2e/chat-pdf-attachment.spec.js @@ -32,7 +32,7 @@ async function openChat(page) { }) }) await page.goto('/app/chat') - await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 }) + await expect(page.getByRole('button', { name: /test-model/ })).toBeVisible({ timeout: 10_000 }) } test.describe('Chat - PDF attachments', () => { @@ -44,15 +44,15 @@ test.describe('Chat - PDF attachments', () => { }) await openChat(page) - await page.locator('input[type=file]').setInputFiles({ + await page.locator('input[type=file][accept*="pdf"]').setInputFiles({ name: 'report.pdf', mimeType: 'application/pdf', buffer: buildPdf('Quarterly revenue grew 42 percent'), }) - await expect(page.locator('.chat-file-name', { hasText: 'report.pdf' })).toBeVisible() + await expect(page.locator('.home-file-tag', { hasText: 'report.pdf' })).toBeVisible() - await page.locator('.chat-input').fill('Summarize') - await page.locator('.chat-send-btn').click() + await page.getByTestId('chat-input').fill('Summarize') + await page.getByTestId('chat-send').click() await expect.poll(() => requestBody).toContain('Quarterly revenue grew 42 percent') expect(requestBody).toContain('File: report.pdf') @@ -62,14 +62,14 @@ test.describe('Chat - PDF attachments', () => { test('rejects a PDF that cannot be parsed instead of attaching garbage', async ({ page }) => { await openChat(page) - await page.locator('input[type=file]').setInputFiles({ + await page.locator('input[type=file][accept*="pdf"]').setInputFiles({ name: 'broken.pdf', mimeType: 'application/pdf', buffer: Buffer.from('%PDF-1.4 this is not a real document'), }) await expect(page.getByText('Could not read text from broken.pdf')).toBeVisible({ timeout: 10_000 }) - await expect(page.locator('.chat-file-name', { hasText: 'broken.pdf' })).toHaveCount(0) + await expect(page.locator('.home-file-tag', { hasText: 'broken.pdf' })).toHaveCount(0) }) }) diff --git a/core/http/react-ui/e2e/chat-polling-selection.spec.js b/core/http/react-ui/e2e/chat-polling-selection.spec.js index 62620d0e3..ae40c74f7 100644 --- a/core/http/react-ui/e2e/chat-polling-selection.spec.js +++ b/core/http/react-ui/e2e/chat-polling-selection.spec.js @@ -52,12 +52,12 @@ test.describe('Chat - /api/operations polling (#9904)', () => { const { getOperationsHits } = await setupChatPage(page) await page.goto('/app/chat') - await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 }) + await expect(page.getByRole('button', { name: /test-model/ })).toBeVisible({ timeout: 10_000 }) - await page.locator('.chat-input').fill('Hi') - await page.locator('.chat-send-btn').click() + await page.getByTestId('chat-input').fill('Hi') + await page.getByTestId('chat-send').click() - const assistantContent = page.locator('.chat-message-assistant .chat-message-content').first() + const assistantContent = page.locator('[data-role="assistant"] .cx-prose').first() await expect(assistantContent).toContainText('Hello world', { timeout: 10_000 }) // Sanity check: the polling we're regressing against is actually firing. @@ -114,12 +114,12 @@ test.describe('Chat - copy button (#9904)', () => { }) await page.goto('/app/chat') - await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 }) + await expect(page.getByRole('button', { name: /test-model/ })).toBeVisible({ timeout: 10_000 }) - await page.locator('.chat-input').fill('Hi') - await page.locator('.chat-send-btn').click() + await page.getByTestId('chat-input').fill('Hi') + await page.getByTestId('chat-send').click() - const assistantBubble = page.locator('.chat-message-assistant .chat-message-bubble').first() + const assistantBubble = page.locator('[data-role="assistant"]').first() await expect(assistantBubble).toContainText('Hello world', { timeout: 10_000 }) // Spy on document.execCommand so we can confirm the fallback path ran. @@ -135,7 +135,7 @@ test.describe('Chat - copy button (#9904)', () => { } }) - await assistantBubble.locator('.chat-message-actions button').first().click() + await assistantBubble.getByTestId('message-actions').locator('button').first().click() const execCommandCalls = await page.evaluate(() => window.__execCommandCalls) expect(execCommandCalls).toContain('copy') diff --git a/core/http/react-ui/e2e/chat-settings.spec.js b/core/http/react-ui/e2e/chat-settings.spec.js new file mode 100644 index 000000000..fd9a78a46 --- /dev/null +++ b/core/http/react-ui/e2e/chat-settings.spec.js @@ -0,0 +1,170 @@ +import { test, expect } from './coverage-fixtures.js' +import { chatOf, pair, mockChat, openChat, storedChats, answerWith } from './chat-fixtures.js' + +// Chat settings are a sheet: the system prompt, sampling, the context size, +// the behaviour switches and, for admins, the model's facts. They apply from +// the next message. + +const sheet = (page) => page.getByTestId('chat-settings') + +async function open(page) { + await page.getByTestId('chat-settings-button').click() + await expect(sheet(page)).toBeVisible() +} + +test.describe('Chat settings sheet', () => { + test.beforeEach(async ({ page }) => { + await mockChat(page) + await page.route('**/api/models/config-json/qwen3-8b', route => route.fulfill({ json: { + name: 'qwen3-8b', backend: 'llama-cpp', context_size: 8192, threads: 8, gpu_layers: 99, + parameters: { model: 'qwen3-8b-q4_k_m.gguf' }, template: { chat_message: 'x' }, + } })) + }) + + test('opens as a kit sheet, says when it applies, and closes on Esc, the veil and the button', async ({ page }) => { + await openChat(page, [chatOf('c1', 'Settings', 'qwen3-8b', pair('hi', 'hello'))]) + await open(page) + await expect(sheet(page)).toHaveClass(/dk-sheet/) + await expect(sheet(page)).toContainText('Applies from the next message.') + await expect(sheet(page).getByRole('button', { name: 'Close' })).toBeFocused() + + await page.keyboard.press('Escape') + await expect(sheet(page)).toHaveCount(0) + // Focus returns to the button that opened it. + await expect(page.getByTestId('chat-settings-button')).toBeFocused() + + await open(page) + await page.locator('.cx-sheet-veil').click({ position: { x: 20, y: 300 } }) + await expect(sheet(page)).toHaveCount(0) + + await open(page) + await sheet(page).getByRole('button', { name: 'Close' }).click() + await expect(sheet(page)).toHaveCount(0) + }) + + test('each sampling value says "model default" until changed, and Reset puts it back', async ({ page }) => { + await openChat(page, [chatOf('c1', 'Settings', 'qwen3-8b', pair('hi', 'hello'))]) + await open(page) + const temp = sheet(page).locator('.cx-field', { hasText: 'Temperature' }) + await expect(temp.locator('.cx-field__value')).toHaveText('model default') + await expect(temp.getByRole('button', { name: 'Reset' })).toHaveCount(0) + + await temp.getByRole('slider').fill('0.4') + await expect(temp.locator('.cx-field__value')).toHaveText('0.4') + await expect.poll(async () => (await storedChats(page)).chats[0].temperature).toBe(0.4) + + await temp.getByRole('button', { name: 'Reset' }).click() + await expect(temp.locator('.cx-field__value')).toHaveText('model default') + await expect.poll(async () => (await storedChats(page)).chats[0].temperature).toBeNull() + + // Top P and Top K are there too and change their own fields. + await sheet(page).locator('.cx-field', { hasText: 'Top P' }).getByRole('slider').fill('0.5') + await sheet(page).locator('.cx-field', { hasText: 'Top K' }).getByRole('slider').fill('20') + await expect.poll(async () => { + const c = (await storedChats(page)).chats[0] + return [c.topP, c.topK] + }).toEqual([0.5, 20]) + }) + + test('the system prompt is stored and goes out before the next message', async ({ page }) => { + const answer = await answerWith(page, 'ok') + await openChat(page, [chatOf('c1', 'Settings', 'qwen3-8b', [])]) + await open(page) + await sheet(page).getByTestId('chat-system-prompt').fill('Answer in one line.') + await expect.poll(async () => (await storedChats(page)).chats[0].systemPrompt).toBe('Answer in one line.') + await page.keyboard.press('Escape') + + await page.getByTestId('chat-input').fill('hello') + await page.keyboard.press('Enter') + await expect(page.locator('[data-role="assistant"]')).toContainText('ok') + expect(answer.request().messages[0]).toEqual({ role: 'system', content: 'Answer in one line.' }) + }) + + test('the context size has quick sizes, drives the header meter and is not sent', async ({ page }) => { + const answer = await answerWith(page, 'ok') + await openChat(page, [chatOf('c1', 'Settings', 'qwen3-8b', pair('hi', 'hello'), { tokenUsage: { prompt: 1000, completion: 96, total: 1096 } })]) + // The admin's model configuration fills the size in: 8192. + await expect.poll(async () => (await storedChats(page)).chats[0].contextSize).toBe(8192) + await open(page) + const size = sheet(page).getByRole('group', { name: 'Context Size' }) + await expect(size.getByRole('button', { name: '8k' })).toHaveAttribute('aria-pressed', 'true') + await expect(sheet(page)).toContainText('Using 1096 of 8192 tokens (13%)') + await size.getByRole('button', { name: '4k' }).click() + await expect(sheet(page)).toContainText('(27%)') + await page.keyboard.press('Escape') + await expect(page.getByTestId('chat-context')).toContainText('27%') + + await page.getByTestId('chat-input').fill('go') + await page.keyboard.press('Enter') + await expect(page.locator('[data-role="assistant"]').last()).toContainText('ok') + expect(JSON.stringify(answer.request())).not.toContain('4096') + }) + + test('Manage mode turns on from the sheet, marks the header and is sent with the message', async ({ page }) => { + const answer = await answerWith(page, 'ok') + await openChat(page, [chatOf('c1', 'Settings', 'qwen3-8b', [])]) + await open(page) + const manage = sheet(page).getByRole('switch', { name: 'Manage mode' }) + await expect(manage).toHaveAttribute('aria-checked', 'false') + await manage.click() + await expect(manage).toHaveAttribute('aria-checked', 'true') + await page.keyboard.press('Escape') + await expect(page.getByTestId('chat-manage-badge')).toBeVisible() + await page.getByTestId('chat-input').fill('what is installed') + await page.keyboard.press('Enter') + await expect(page.locator('[data-role="assistant"]')).toContainText('ok') + expect(answer.request().metadata).toMatchObject({ localai_assistant: 'true' }) + }) + + test('Focus mode is a switch that is remembered', async ({ page }) => { + await openChat(page, [chatOf('c1', 'Settings', 'qwen3-8b', pair('hi', 'hello'))]) + await open(page) + const focus = sheet(page).getByRole('switch', { name: 'Focus mode' }) + await expect(focus).toHaveAttribute('aria-checked', 'true') + await focus.click() + await expect(focus).toHaveAttribute('aria-checked', 'false') + expect(await page.evaluate(() => localStorage.getItem('localai_chat_focus_mode'))).toBe('false') + }) + + test('admins see the model facts and can open its config', async ({ page }) => { + await openChat(page, [chatOf('c1', 'Settings', 'qwen3-8b', pair('hi', 'hello'))]) + await open(page) + const info = page.getByTestId('chat-model-info') + await expect(info).toContainText('llama-cpp') + await expect(info).toContainText('qwen3-8b-q4_k_m.gguf') + await expect(info).toContainText('99') + await info.getByRole('button', { name: 'Edit config' }).click() + await expect(page).toHaveURL(/\/app\/model-editor\/qwen3-8b/) + }) + + test('Clear conversation asks for a confirmation, then empties the chat but keeps its settings', async ({ page }) => { + await openChat(page, [chatOf('c1', 'Settings', 'qwen3-8b', pair('hi', 'hello'), { temperature: 0.4 })]) + await open(page) + await sheet(page).getByRole('button', { name: 'Clear chat history' }).click() + await expect(sheet(page)).toHaveCount(0) + const dialog = page.getByRole('alertdialog') + await expect(dialog).toContainText('Clear this conversation') + await dialog.getByRole('button', { name: 'Cancel' }).click() + await expect(page.getByTestId('chat-message')).toHaveCount(2) + + await open(page) + await sheet(page).getByRole('button', { name: 'Clear chat history' }).click() + await page.getByRole('alertdialog').getByRole('button', { name: 'Clear conversation' }).click() + await expect(page.getByTestId('chat-empty')).toBeVisible() + await expect.poll(async () => { + const c = (await storedChats(page)).chats[0] + return [c.history.length, c.temperature] + }).toEqual([0, 0.4]) + }) + + test('on a phone the sheet rises from the bottom', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await openChat(page, [chatOf('c1', 'Settings', 'qwen3-8b', pair('hi', 'hello'))]) + await page.getByTestId('chat-settings-button').click() + const box = await sheet(page).boundingBox() + expect(box.width).toBeLessThanOrEqual(390) + expect(box.y + box.height).toBeGreaterThanOrEqual(843) + expect(box.y).toBeGreaterThan(20) + await expect(sheet(page).locator('.dk-sheet-grip')).toBeVisible() + }) +}) diff --git a/core/http/react-ui/e2e/chat-states.spec.js b/core/http/react-ui/e2e/chat-states.spec.js new file mode 100644 index 000000000..e628315d0 --- /dev/null +++ b/core/http/react-ui/e2e/chat-states.spec.js @@ -0,0 +1,200 @@ +import { test, expect } from './coverage-fixtures.js' +import { + chatOf, pair, mockChat, openChat, storedChats, controlStream, answerWith, +} from './chat-fixtures.js' +import { sampleChats } from './home-fixtures.js' + +// The states around the thread: an empty chat, no model, a reply waiting for a +// model, the phone layout and reduced motion. + +test.describe('Empty chat', () => { + test.beforeEach(async ({ page }) => { + await mockChat(page) + }) + + test('says what the page is for, offers starters and says whether the model is ready', async ({ page }) => { + await openChat(page, [chatOf('n1', 'New Chat', 'qwen3-8b', [])]) + await expect(page.getByTestId('chat-empty').getByRole('heading')).toHaveText('What are we working on?') + await expect(page.getByTestId('chat-starters').getByRole('button')).toHaveCount(4) + await expect(page.getByTestId('chat-ready')).toContainText('qwen3-8b is loaded and ready.') + // The composer sits under the line, not at the bottom of the page. + const head = await page.getByTestId('chat-empty').boundingBox() + const bar = await page.getByTestId('chat-composer').boundingBox() + expect(bar.y).toBeGreaterThan(head.y) + expect(bar.y).toBeLessThan(head.y + 200) + }) + + test('a model that is not loaded says the first reply loads it', async ({ page }) => { + await openChat(page, [chatOf('n1', 'New Chat', 'gemma-3-12b', [])]) + await expect(page.getByTestId('chat-ready')).toContainText('gemma-3-12b is not loaded yet. The first reply loads it.') + }) + + test('a starter fills the box and focuses it', async ({ page }) => { + await openChat(page, [chatOf('n1', 'New Chat', 'qwen3-8b', [])]) + await page.getByTestId('chat-starters').getByRole('button', { name: 'Help me write code' }).click() + await expect(page.getByTestId('chat-input')).toHaveValue('Help me write code') + await expect(page.getByTestId('chat-input')).toBeFocused() + }) + + test('lists the conversations like Home does, and a row resumes one', async ({ page }) => { + const chats = sampleChats() + await openChat(page, [chatOf('n1', 'New Chat', 'qwen3-8b', []), ...chats], 'n1') + const list = page.getByTestId('chat-under') + await expect(list.getByTestId('home-day-today')).toBeVisible() + await expect(list.getByTestId('home-day-yesterday')).toBeVisible() + await expect(list.getByTestId('home-conversation')).toHaveCount(5) + await list.getByTestId('home-conversation').filter({ hasText: 'Regex for semver' }).getByRole('button').first().click() + await expect(page.getByTestId('chat-title')).toHaveText('Regex for semver with prerelease tags') + await expect(page.getByTestId('chat-empty')).toHaveCount(0) + }) + + test('deleting from the list offers an undo', async ({ page }) => { + await openChat(page, [chatOf('n1', 'New Chat', 'qwen3-8b', []), ...sampleChats()], 'n1') + const row = page.getByTestId('chat-under').getByTestId('home-conversation').filter({ hasText: 'Draft a polite reply' }) + await row.hover() + await row.getByRole('button', { name: /Delete conversation/ }).click() + await expect(page.getByTestId('chat-undo-toast')).toContainText('Deleted "Draft a polite reply about the invoice"') + await page.getByTestId('chat-undo-toast').getByRole('button', { name: 'Undo' }).click() + await expect(page.getByTestId('chat-under').getByTestId('home-conversation')).toHaveCount(5) + }) + + test('the first message moves the composer to the bottom without losing the box', async ({ page }) => { + await answerWith(page, 'hello there') + await openChat(page, [chatOf('n1', 'New Chat', 'qwen3-8b', [])]) + await page.getByTestId('chat-input').fill('hi') + await page.keyboard.press('Enter') + await expect(page.locator('[data-role="assistant"]')).toContainText('hello there') + await expect(page.getByTestId('chat-empty')).toHaveCount(0) + const bar = await page.getByTestId('chat-composer').boundingBox() + expect(bar.y + bar.height).toBeGreaterThan(page.viewportSize().height - 80) + }) +}) + +test.describe('No chat model', () => { + test('shows an install card, keeps the composer and what was typed, and cannot send', async ({ page }) => { + await mockChat(page, { models: [], loaded: [] }) + await openChat(page, [chatOf('n1', 'New Chat', '', [])]) + const card = page.getByTestId('chat-no-model') + await expect(card).toBeVisible() + await expect(card.getByRole('heading')).toHaveText('Chat needs a language model') + await expect(card.getByRole('button', { name: 'Browse the gallery' })).toBeVisible() + await expect(card.getByRole('button', { name: 'Import a model' })).toBeVisible() + await expect(page.getByTestId('chat-input')).toHaveAttribute('placeholder', 'Install a model to start chatting') + await expect(page.getByTestId('home-model-chip')).toContainText('No chat model') + await page.getByTestId('chat-input').fill('what I wanted to ask') + await expect(page.getByTestId('chat-send')).toBeDisabled() + await expect(page.getByTestId('chat-input')).toHaveValue('what I wanted to ask') + }) + + test('the gallery button opens the Models page', async ({ page }) => { + await mockChat(page, { models: [], loaded: [] }) + await openChat(page, [chatOf('n1', 'New Chat', '', [])]) + await page.getByTestId('chat-no-model').getByRole('button', { name: 'Browse the gallery' }).click() + await expect(page).toHaveURL(/\/app\/models/) + }) +}) + +test.describe('A reply that waits for a model', () => { + test('a model that is not loaded gets a plain note, with no invented phases', async ({ page }) => { + await mockChat(page) + await controlStream(page) + await openChat(page, [chatOf('c1', 'Cold', 'gemma-3-12b', pair('hi', 'hello'))]) + await page.getByTestId('chat-input').fill('go') + await page.keyboard.press('Enter') + const card = page.getByTestId('chat-load') + await expect(card).toBeVisible() + await expect(card).toContainText('Loading') + await expect(card).toContainText('gemma-3-12b') + await expect(card).toContainText('This model is not loaded yet, so the first reply takes longer.') + await expect(card.getByTestId('chat-load-phase')).toHaveCount(0) + await expect(card.getByRole('progressbar')).toHaveCount(0) + // The first token replaces the card. + await page.evaluate(() => window.__sse.push({ choices: [{ delta: { content: 'Hello' } }] })) + await expect(page.getByTestId('chat-load')).toHaveCount(0) + await expect(page.getByTestId('chat-streaming')).toContainText('Hello') + }) + + test('a loaded model waits behind quiet dots, not a card', async ({ page }) => { + await mockChat(page) + await controlStream(page) + await openChat(page, [chatOf('c1', 'Warm', 'qwen3-8b', pair('hi', 'hello'))]) + await page.getByTestId('chat-input').fill('go') + await page.keyboard.press('Enter') + await expect(page.getByTestId('chat-streaming').locator('.cx-dots')).toBeVisible() + await expect(page.getByTestId('chat-load')).toHaveCount(0) + }) + + test('a staging operation shows its progress when no load job names the phase', async ({ page }) => { + await mockChat(page) + await page.route('**/api/operations', route => route.fulfill({ json: { operations: [{ + id: 'op1', name: 'gemma-3-12b', taskType: 'staging', nodeName: 'node-2', progress: 55, message: 'file 1 of 2', + }] } })) + await controlStream(page) + await openChat(page, [chatOf('c1', 'Stage', 'gemma-3-12b', [])]) + await page.getByTestId('chat-input').fill('go') + await page.keyboard.press('Enter') + const card = page.getByTestId('chat-load') + await expect(card.getByTestId('chat-load-phase')).toContainText('node-2') + await expect(card.getByTestId('chat-load-pct')).toHaveText('55%') + await expect(card.getByTestId('chat-load-detail')).toHaveText('file 1 of 2') + }) +}) + +test.describe('Chat on a phone', () => { + test.use({ viewport: { width: 390, height: 844 } }) + + test('fits the width, keeps the composer in view and keeps message actions visible', async ({ page }) => { + await mockChat(page) + await openChat(page, [chatOf('c1', 'Phone', 'qwen3-8b', [...pair('one', 'first'), ...pair('two', 'second')], { contextSize: 8192, tokenUsage: { prompt: 10, completion: 5, total: 15 } })]) + await expect(page.getByTestId('chat-message')).toHaveCount(4) + const scroll = await page.evaluate(() => ({ w: document.documentElement.scrollWidth, v: window.innerWidth })) + expect(scroll.w).toBeLessThanOrEqual(scroll.v) + await expect(page.getByTestId('chat-input')).toBeInViewport() + // The header drops its words and keeps the buttons. + await expect(page.locator('.cx-hist__label')).toBeHidden() + await expect(page.getByTestId('chat-settings-button')).toBeVisible() + // Actions need no hover on a touch screen. + const opacity = await page.getByTestId('message-actions').first().evaluate(el => getComputedStyle(el).opacity) + expect(opacity).toBe('1') + // On a turn that is not the last one, only the first two buttons stay. + const visible = await page.getByTestId('message-actions').nth(2).locator('button').evaluateAll(els => els.filter(e => e.offsetParent !== null).length) + expect(visible).toBe(2) + }) + + test('the conversations menu fits the screen', async ({ page }) => { + await mockChat(page) + await openChat(page, sampleChats()) + await page.getByTestId('chats-trigger').click() + const box = await page.getByTestId('chats-menu').boundingBox() + expect(box.x).toBeGreaterThanOrEqual(0) + expect(box.x + box.width).toBeLessThanOrEqual(390) + expect(box.y + box.height).toBeLessThanOrEqual(844) + }) + + test('the empty chat fits too', async ({ page }) => { + await mockChat(page) + await openChat(page, [chatOf('n1', 'New Chat', 'qwen3-8b', [])]) + await expect(page.getByTestId('chat-empty')).toBeVisible() + const scroll = await page.evaluate(() => ({ w: document.documentElement.scrollWidth, v: window.innerWidth })) + expect(scroll.w).toBeLessThanOrEqual(scroll.v) + await expect(page.getByTestId('chat-composer')).toBeInViewport() + }) +}) + +test.describe('Chat with reduced motion', () => { + test('nothing in the thread animates', async ({ page }) => { + // The fixture option test.use({ reducedMotion }) does not reach our extended page. + await page.emulateMedia({ reducedMotion: 'reduce' }) + await mockChat(page) + await controlStream(page) + await openChat(page, [chatOf('c1', 'Calm', 'qwen3-8b', pair('hi', 'hello'))]) + const msgAnim = await page.getByTestId('chat-message').first().evaluate(el => getComputedStyle(el).animationName) + expect(msgAnim).toBe('none') + await page.getByTestId('chat-input').fill('go') + await page.keyboard.press('Enter') + await page.evaluate(() => window.__sse.push({ choices: [{ delta: { reasoning: 'thinking it over' } }] })) + const shimmer = await page.getByTestId('chat-activity').locator('.cx-shimmer, .cx-fold__head span').first().evaluate(el => getComputedStyle(el).animationName) + expect(shimmer).toBe('none') + expect((await storedChats(page)).chats).toHaveLength(1) + }) +}) diff --git a/core/http/react-ui/e2e/chat-thread.spec.js b/core/http/react-ui/e2e/chat-thread.spec.js new file mode 100644 index 000000000..1361d6133 --- /dev/null +++ b/core/http/react-ui/e2e/chat-thread.spec.js @@ -0,0 +1,237 @@ +import { test, expect } from './coverage-fixtures.js' +import { + chatOf, pair, mockChat, openChat, storedChats, controlStream, answerWith, sse, PNG, GO_BLOCK, +} from './chat-fixtures.js' + +// The thread's parts: the one-line activity fold, code blocks, attachments, +// per-message actions and their keys, a failed reply and a streaming reply. + +const msg = (page, role) => page.locator(`[data-testid="chat-message"][data-role="${role}"]`) + +const ACTIVITY = [ + { role: 'user', content: 'Which file has the leak?' }, + { role: 'thinking', content: 'The handler starts one reader per request.' }, + { role: 'tool_call', content: JSON.stringify({ type: 'tool_call', name: 'read_file', arguments: { path: 'stream_handler.go' } }) }, + { role: 'tool_result', content: JSON.stringify({ type: 'tool_result', name: 'read_file', result: '148 lines' }) }, + { role: 'assistant', content: 'It is stream_handler.go.' }, +] + +test.describe('Chat thread', () => { + test.beforeEach(async ({ page }) => { + await mockChat(page) + }) + + test('reasoning and tool calls fold into one quiet line that opens inline', async ({ page }) => { + await openChat(page, [chatOf('c1', 'Activity', 'qwen3-8b', ACTIVITY)]) + const fold = page.getByTestId('chat-activity') + const head = fold.getByRole('button') + await expect(head).toHaveText('Thought · read_file') + await expect(head).toHaveAttribute('aria-expanded', 'false') + // Closed: the steps are not in the page at all. + await expect(page.locator('.cx-steps')).toHaveCount(0) + + await head.click() + await expect(head).toHaveAttribute('aria-expanded', 'true') + const steps = page.locator('.cx-steps .cx-step') + await expect(steps).toHaveCount(3) + await expect(steps.nth(0)).toContainText('The handler starts one reader per request.') + await expect(steps.nth(1)).toContainText('Tool call') + await expect(steps.nth(1)).toContainText('stream_handler.go') + await expect(steps.nth(2)).toContainText('148 lines') + + await head.click() + await expect(page.locator('.cx-steps')).toHaveCount(0) + }) + + test('the activity line sits inside the answer it belongs to', async ({ page }) => { + await openChat(page, [chatOf('c1', 'Activity', 'qwen3-8b', ACTIVITY)]) + // One assistant turn, and the fold is part of it, between the name and the text. + await expect(msg(page, 'assistant')).toHaveCount(1) + await expect(msg(page, 'assistant').getByTestId('chat-activity')).toBeVisible() + await expect(msg(page, 'assistant').locator('.cx-prose')).toContainText('It is stream_handler.go.') + }) + + test('a code block has Copy and Canvas buttons', async ({ page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']) + await openChat(page, [chatOf('c1', 'Code', 'qwen3-8b', pair('Show me', 'Here:\n\n```go\n' + GO_BLOCK + '\n```\n\nDone.'))]) + const block = page.locator('.code-block') + await expect(block.locator('.code-block__lang')).toHaveText('go') + const copy = block.getByRole('button', { name: 'Copy code' }) + await expect(copy).toContainText('Copy') + await expect(block.getByRole('button', { name: 'Canvas' })).toBeVisible() + await copy.click() + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toContain('context.WithCancel') + }) + + test('an image is a thumbnail that opens in the viewer, and a file is a chip', async ({ page }) => { + await openChat(page, [chatOf('c1', 'Attachments', 'gemma-3-12b', [ + { + role: 'user', + content: [{ type: 'text', text: 'What is in this?' }, { type: 'image_url', image_url: { url: PNG } }], + files: [{ name: 'shot.png', type: 'image' }, { name: 'notes.txt', type: 'file', content: 'x' }], + }, + { role: 'assistant', content: 'A single pixel.' }, + ])]) + const user = msg(page, 'user') + await expect(user.locator('.cx-thumb img')).toBeVisible() + // The image is shown once: as a thumbnail, not also as a chip. + await expect(user.locator('.cx-chip')).toHaveCount(1) + await expect(user.locator('.cx-chip')).toContainText('notes.txt') + + await user.locator('.cx-thumb').click() + await expect(page.locator('.lightbox')).toBeVisible() + await page.keyboard.press('Escape') + await expect(page.locator('.lightbox')).toHaveCount(0) + }) + + test('message actions show on hover, on focus and on the last turn', async ({ page }) => { + await openChat(page, [chatOf('c1', 'Actions', 'qwen3-8b', [...pair('one', 'first'), ...pair('two', 'second')])]) + const opacity = (loc) => loc.getByTestId('message-actions').evaluate(el => getComputedStyle(el).opacity) + const first = msg(page, 'user').first() + // Away from the pointer the first turn's actions are hidden; the last turn keeps them. + await page.mouse.move(5, 5) + await expect.poll(() => opacity(first)).toBe('0') + await expect.poll(() => opacity(msg(page, 'assistant').last())).toBe('1') + await first.hover() + await expect.poll(() => opacity(first)).toBe('1') + await page.mouse.move(5, 5) + await first.focus() + await expect.poll(() => opacity(first)).toBe('1') + }) + + test('the buttons are Copy, Edit, Regenerate and Branch on an answer, and only Copy and Edit on yours', async ({ page }) => { + await openChat(page, [chatOf('c1', 'Actions', 'qwen3-8b', pair('one', 'first'))]) + await expect(msg(page, 'user').getByTestId('message-actions').locator('button')).toHaveCount(2) + await expect(msg(page, 'assistant').getByTestId('message-actions').getByTitle('Regenerate')).toBeVisible() + await expect(msg(page, 'assistant').getByTestId('message-actions').getByTitle('Branch from here')).toBeVisible() + }) + + test('arrow keys move between messages and C, E and B act on the focused one', async ({ page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']) + await openChat(page, [chatOf('c1', 'Keys', 'qwen3-8b', [...pair('first question', 'first answer'), ...pair('second question', 'second answer')])]) + const all = page.getByTestId('chat-message') + await all.first().focus() + await page.keyboard.press('ArrowDown') + await expect(all.nth(1)).toBeFocused() + await page.keyboard.press('ArrowUp') + await expect(all.first()).toBeFocused() + + // C copies the focused turn. + await page.keyboard.press('c') + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('first question') + + // E opens the editor on your own turn; Esc leaves it as it was. + await page.keyboard.press('e') + await expect(all.first().getByRole('textbox')).toHaveValue('first question') + await page.keyboard.press('Escape') + await expect(all.first().getByRole('textbox')).toHaveCount(0) + + // B branches from an answer into a new chat. + await all.nth(1).focus() + await page.keyboard.press('b') + await expect(page.getByTestId('chat-title')).toHaveText('Keys (fork)') + await expect(msg(page, 'assistant')).toHaveCount(1) + }) + + test('R regenerates the focused answer', async ({ page }) => { + const answer = await answerWith(page, 'a fresh answer') + await openChat(page, [chatOf('c1', 'Regen', 'qwen3-8b', pair('the question', 'the old answer'))]) + await msg(page, 'assistant').focus() + await page.keyboard.press('r') + await expect(msg(page, 'assistant')).toContainText('a fresh answer') + expect(JSON.stringify(answer.request().messages)).toContain('the question') + }) + + test('Edit and Regenerate are not offered while a reply is streaming', async ({ page }) => { + await controlStream(page) + await openChat(page, [chatOf('c1', 'Busy', 'qwen3-8b', pair('one', 'first'))]) + await page.getByTestId('chat-input').fill('two') + await page.keyboard.press('Enter') + await expect(page.getByTestId('chat-streaming')).toBeVisible() + await expect(msg(page, 'assistant').first().getByTitle('Regenerate')).toHaveCount(0) + await expect(msg(page, 'assistant').first().getByTitle('Edit')).toHaveCount(0) + }) + + test('a failed reply keeps the text written so far, says why, and Retry asks again', async ({ page }) => { + let calls = 0 + await page.route('**/v1/chat/completions', (route) => { + calls++ + const body = calls === 1 + ? `data: ${JSON.stringify({ choices: [{ delta: { content: 'Half an answer' } }] })}\n\n` + + `data: ${JSON.stringify({ error: { message: 'out of memory after 214 tokens' } })}\n\n` + + 'data: [DONE]\n\n' + : sse('A whole answer') + route.fulfill({ status: 200, contentType: 'text/event-stream', body }) + }) + await openChat(page, [chatOf('c1', 'Fail', 'qwen3-8b', [])]) + await page.getByTestId('chat-input').fill('Tell me') + await page.keyboard.press('Enter') + + const card = page.getByTestId('chat-error') + await expect(card).toBeVisible() + await expect(card.getByRole('heading')).toHaveText('The reply failed') + await expect(card).toContainText('out of memory after 214 tokens') + await expect(card).toContainText('What was written so far is kept above.') + await expect(msg(page, 'assistant')).toContainText('Half an answer') + // One action to take, and the way to the traces. + await expect(card.getByRole('button')).toHaveCount(1) + await expect(card.getByRole('link', { name: /traces/i })).toBeVisible() + await card.locator('details summary').click() + await expect(card.locator('pre')).toContainText('out of memory') + + await card.getByRole('button', { name: 'Retry' }).click() + await expect(msg(page, 'assistant')).toContainText('A whole answer') + await expect(page.getByTestId('chat-error')).toHaveCount(0) + expect(calls).toBe(2) + }) + + test('a reply that quotes "Error:" in a sentence is not an error', async ({ page }) => { + await openChat(page, [chatOf('c1', 'Quote', 'qwen3-8b', pair('what does it print', 'It prints Error: file not found when the path is wrong.'))]) + await expect(msg(page, 'assistant')).toContainText('Error: file not found') + await expect(page.getByTestId('chat-error')).toHaveCount(0) + }) + + test('while a reply streams, Stop ends it and Esc does the same', async ({ page }) => { + await controlStream(page) + await openChat(page, [chatOf('c1', 'Stop', 'qwen3-8b', [])]) + const input = page.getByTestId('chat-input') + + await input.fill('first') + await page.keyboard.press('Enter') + await expect(page.getByTestId('chat-stop')).toBeVisible() + await page.evaluate(() => window.__sse.push({ choices: [{ delta: { content: 'Partial' } }] })) + await expect(page.getByTestId('chat-streaming')).toContainText('Partial') + await page.getByTestId('chat-stop').click() + await expect(page.getByTestId('chat-stop')).toHaveCount(0) + await expect(page.getByTestId('chat-send')).toBeVisible() + + await input.fill('second') + await page.keyboard.press('Enter') + await expect(page.getByTestId('chat-stop')).toBeVisible() + await page.keyboard.press('Escape') + await expect(page.getByTestId('chat-stop')).toHaveCount(0) + await expect.poll(() => page.evaluate(() => window.__sse.calls)).toBe(2) + }) + + test('the reply streams under the model name, with its reasoning open until the answer starts', async ({ page }) => { + await controlStream(page) + await openChat(page, [chatOf('c1', 'Stream', 'qwen3-8b', [])]) + await page.getByTestId('chat-input').fill('go') + await page.keyboard.press('Enter') + const turn = page.getByTestId('chat-streaming') + await expect(turn.locator('.cx-who b')).toHaveText('qwen3-8b') + + await page.evaluate(() => window.__sse.push({ choices: [{ delta: { reasoning: 'Weighing the options.' } }] })) + await expect(turn.getByRole('button', { name: /Thinking/ })).toHaveAttribute('aria-expanded', 'true') + await expect(turn).toContainText('Weighing the options.') + + await page.evaluate(() => window.__sse.push({ choices: [{ delta: { content: 'The answer.' } }] })) + await expect(turn.locator('.cx-prose')).toContainText('The answer.') + // Once the answer is coming, the reasoning folds to one line. + await expect(turn.getByRole('button', { name: /Thought/ })).toHaveAttribute('aria-expanded', 'false') + + await page.evaluate(() => window.__sse.end()) + await expect(page.getByTestId('chat-streaming')).toHaveCount(0) + await expect.poll(async () => (await storedChats(page)).chats[0].history.map(m => m.role)).toEqual(['user', 'thinking', 'assistant']) + }) +}) diff --git a/core/http/react-ui/e2e/chat-transcript.spec.js b/core/http/react-ui/e2e/chat-transcript.spec.js index 8164e8652..b095c24f7 100644 --- a/core/http/react-ui/e2e/chat-transcript.spec.js +++ b/core/http/react-ui/e2e/chat-transcript.spec.js @@ -1,55 +1,56 @@ import { test, expect } from './coverage-fixtures.js' +import { chatOf, pair, mockChat, openChat } from './chat-fixtures.js' -// Chat reads as a transcript rather than a bubble thread (mock 04). +// The thread: your turns are raised blocks on the right, the model's turns are +// plain prose under its name, both at one 760 px measure. Both turns still say +// who is speaking in words, so the thread reads aloud and prints well. -const CHAT = { - chats: [{ - id: 'c1', name: 'Transcript', model: 'mock-model', - history: [ - { role: 'user', content: 'Which backends do I have?' }, - { role: 'assistant', content: 'Seven are installed.' }, - ], - }], - activeChatId: 'c1', -} +const THREAD = chatOf('c1', 'Transcript', 'qwen3-8b', pair('Which backends do I have?', 'Seven are installed.')) -test.describe('Chat transcript', () => { +test.describe('Chat thread layout', () => { test.beforeEach(async ({ page }) => { - await page.addInitScript(chat => { - localStorage.setItem('localai_chats_data', JSON.stringify(chat)) - }, CHAT) - await page.goto('/app/chat') + await mockChat(page) + await openChat(page, [THREAD]) + await expect(page.getByTestId('chat-message').first()).toBeVisible() }) - test('neither role is a filled, rounded bubble', async ({ page }) => { - const user = page.locator('.chat-message-user .chat-message-content').first() - await expect(user).toBeVisible() + test('your turn is a raised block, the reply is plain prose', async ({ page }) => { + const user = page.locator('[data-role="user"] .cx-bubble') const cs = await user.evaluate(el => { const s = getComputedStyle(el) - return { radius: s.borderTopLeftRadius, shadow: s.boxShadow } + return { radius: parseFloat(s.borderTopLeftRadius), bg: s.backgroundColor, shadow: s.boxShadow } }) - // A rounded filled bubble carries the speaker in shape and side; a - // transcript carries it in words, which survives being read aloud. - expect(cs.radius).toBe('0px') - expect(cs.shadow).toBe('none') + expect(cs.radius).toBeGreaterThan(8) + expect(cs.bg).not.toBe('rgba(0, 0, 0, 0)') + expect(cs.shadow).not.toBe('none') + + const prose = await page.locator('[data-role="assistant"] .cx-prose').evaluate(el => { + const s = getComputedStyle(el) + return { bg: s.backgroundColor, border: s.borderLeftWidth, shadow: s.boxShadow } + }) + // No fill, no rail, no shadow: the reply is text on the page. + expect(prose.bg).toBe('rgba(0, 0, 0, 0)') + expect(prose.border).toBe('0px') + expect(prose.shadow).toBe('none') }) - test('both turns run full width in one column, not left and right', async ({ page }) => { - const user = page.locator('.chat-message-user').first() - const assistant = page.locator('.chat-message-assistant').first() - const [u, a] = [await user.boundingBox(), await assistant.boundingBox()] - expect(Math.abs(u.x - a.x)).toBeLessThan(2) + test('your turn sits on the right and the reply on the left of one 760 px column', async ({ page }) => { + const thread = await page.getByTestId('chat-thread').boundingBox() + expect(thread.width).toBeLessThanOrEqual(760) + const user = await page.locator('[data-role="user"] .cx-bubble').boundingBox() + const reply = await page.locator('[data-role="assistant"] .cx-prose').boundingBox() + expect(Math.abs(user.x + user.width - (thread.x + thread.width))).toBeLessThan(2) + expect(Math.abs(reply.x - thread.x)).toBeLessThan(2) }) test('every turn says who is speaking', async ({ page }) => { - await expect(page.locator('.chat-message-user .chat-message-model')).toHaveText('You') - await expect(page.locator('.chat-message-assistant .chat-message-model').first()) - .toHaveText('mock-model') + await expect(page.locator('[data-role="assistant"] .cx-who b')).toHaveText('qwen3-8b') + // Your turn carries the word for assistive technology and for reading aloud. + await expect(page.locator('[data-role="user"]')).toHaveAttribute('aria-label', 'You') }) - test('turns are separated by a rule', async ({ page }) => { - const border = await page.locator('.chat-message').first() - .evaluate(el => getComputedStyle(el).borderBottomStyle) - expect(border).toBe('solid') + test('the reply names whether its model is loaded', async ({ page }) => { + // qwen3-8b is in the stubbed loaded list, so its dot is filled. + await expect(page.locator('[data-role="assistant"] .cx-who .home-dot')).not.toHaveClass(/home-dot--cold/) }) }) diff --git a/core/http/react-ui/e2e/chrome-audit.spec.js b/core/http/react-ui/e2e/chrome-audit.spec.js index 43dd8525a..fcebdfc34 100644 --- a/core/http/react-ui/e2e/chrome-audit.spec.js +++ b/core/http/react-ui/e2e/chrome-audit.spec.js @@ -1,9 +1,9 @@ import { test, expect } from './coverage-fixtures.js' // A standing guard against the two defects an earlier automated edit left -// scattered through the pages: icons stripped of their fa-* class (which render -// nothing at all), and controls left with the user agent's own chrome, which is -// a pale grey button on a dark ground. +// scattered through the pages: icons that render nothing at all (here, an svg +// whose sprite target is missing or that has no size), and controls left with +// the user agent's own chrome, which is a pale grey button on a dark ground. const ROUTES = [ '/app', '/app/chat', '/app/models', '/app/studio', '/app/talk', '/app/agents', '/app/skills', '/app/collections', '/app/agent-jobs', @@ -37,10 +37,16 @@ test('no page renders a dead icon or a default-chrome control', async ({ page }) out.push(`default-chrome: "${(el.textContent || '').trim().slice(0, 24)}" [${el.className}]`) } } - for (const i of document.querySelectorAll('i')) { - if (!/\bfa-/.test((i.className || '').toString())) { - out.push(`dead-icon: [${i.className}]`) - } + for (const svg of document.querySelectorAll('svg[data-icon]')) { + const use = svg.querySelector('use') + const target = use && document.querySelector(use.getAttribute('href')) + const box = svg.getBoundingClientRect() + if (use && !target) out.push(`dead-icon: no sprite symbol for [${svg.dataset.icon}]`) + if (box.width > 0 && (box.width < 4 || box.height < 4)) out.push(`dead-icon: [${svg.dataset.icon}] is ${box.width}x${box.height}`) + } + // A leftover icon-font element draws nothing now that the font is gone. + for (const i of document.querySelectorAll('i[class*="fa-"], .fas, .far, .fab')) { + out.push(`dead-icon: font class left on [${i.className}]`) } return [...new Set(out)] }) diff --git a/core/http/react-ui/e2e/class-hygiene.spec.js b/core/http/react-ui/e2e/class-hygiene.spec.js index f9a1a9625..6da868edf 100644 --- a/core/http/react-ui/e2e/class-hygiene.spec.js +++ b/core/http/react-ui/e2e/class-hygiene.spec.js @@ -3,18 +3,11 @@ import { readFileSync, readdirSync, statSync } from 'node:fs' import { join, dirname, relative } from 'node:path' import { fileURLToPath } from 'node:url' -// Font Awesome's `fas` / `far` / `fab` classes do not draw an icon on their -// own — they set `font-family: "Font Awesome 6 Free"` and a weight on whatever -// element carries them, and the matching `fa-*` class supplies the glyph -// through ::before. Put them on a