diff --git a/core/http/react-ui/e2e/account-page.spec.js b/core/http/react-ui/e2e/account-page.spec.js index 44badcd7aa..362c9f2da5 100644 --- a/core/http/react-ui/e2e/account-page.spec.js +++ b/core/http/react-ui/e2e/account-page.spec.js @@ -11,7 +11,7 @@ test.describe('Account', () => { test('has its 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', 'About you and the assistant', 'Appearance']) + expect(tabs).toEqual(['Profile', 'Security', 'API keys', 'Usage', 'About you and the assistant', 'Memory', 'Appearance']) await expect(page.getByRole('tab', { name: 'Profile' })).toHaveAttribute('aria-selected', 'true') await page.getByRole('tab', { name: 'Usage' }).click() await expect(page).toHaveURL(/tab=usage/) @@ -101,7 +101,7 @@ test.describe('Account: other people and states', () => { await page.route('**/api/auth/status', route => route.fulfill({ json: { authEnabled: false, staticApiKeyRequired: false, providers: [] } })) await page.goto('/app/account') await expect(page.getByTestId('account-about')).toBeVisible() - expect(await page.getByRole('tab').allTextContents()).toEqual(['About you and the assistant', 'Appearance']) + expect(await page.getByRole('tab').allTextContents()).toEqual(['About you and the assistant', 'Memory', 'Appearance']) await expect(page.getByTestId('sidebar-your-settings')).toBeVisible() }) diff --git a/core/http/react-ui/e2e/chats-change-proposal-card.spec.js b/core/http/react-ui/e2e/chats-change-proposal-card.spec.js new file mode 100644 index 0000000000..37aba382e7 --- /dev/null +++ b/core/http/react-ui/e2e/chats-change-proposal-card.spec.js @@ -0,0 +1,238 @@ +import { test, expect } from './coverage-fixtures.js' +import { mockChat } from './chat-fixtures.js' + +// The card of a change that a model proposed (propose_change): what it would +// write as a diff, Confirm, Edit then confirm, Dismiss, then a receipt with +// Undo, and the states expired, stale and failed. The API is stubbed by a +// small model of the server. That the server enforces the rules is tested in +// Go; here the card is checked for what the person sees and sends. + +const AT = '2026-11-03T09:00:00Z' +const CURRENT = '- home: Rome\n- job: dev\n- pet: dog\n' +const PROPOSED = '- home: Milan\n- job: dev\n- pet: dog\n- tea: green\n' + +const proposal = (extra = {}) => ({ + v: 1, id: 'cp1', owner: 'alice', kind: 'memory', scope: 'user', target: 'Memory/index.md', + summary: 'You live in Milan, not Rome', content: PROPOSED, base_hash: 'abc', source: { chat: 'c1' }, status: 'pending', + created: AT, expires: new Date(Date.now() + 20 * 3600_000 + 5 * 60_000).toISOString(), ...extra, +}) + +const history = [ + { seq: 1, turn: 1, role: 'user', text: 'I moved to Milan.', at: AT }, + { seq: 2, turn: 1, role: 'tool', text: 'propose_change: You live in Milan, not Rome\nStored.', card: { kind: 'change_proposal', id: 'cp1' }, at: AT }, + { seq: 3, turn: 1, role: 'assistant', text: 'I proposed a change to your notes. Confirm it below.', at: AT }, +] + +// The server, in memory: one proposal and what each call does to it. +async function serve(page, { p = proposal(), file = CURRENT, confirmFails = 0, confirmStatus = 200 } = {}) { + const s = { p, calls: [], bodies: [], sent: [], failsLeft: confirmFails } + await mockChat(page) + const detail = { id: 'c1', title: 'Notes', model: 'qwen3-8b', kind: 'chat', created: AT, updated: AT, running: false, last_turn: 'done', pending: [] } + await page.route(/\/api\/chats\/c1\/messages(\?.*)?$/, (route) => { + if (route.request().method() === 'POST') { s.sent.push(route.request().postDataJSON()); return route.fulfill({ json: { started: true } }) } + return route.fulfill({ json: { messages: history } }) + }) + await page.route('**/api/chats/c1', route => route.fulfill({ json: detail })) + await page.route(/\/api\/chats(\?.*)?$/, route => route.fulfill({ json: { conversations: [{ ...detail, pending: undefined }], persistent: true, files: true } })) + await page.route('**/api/chats/agents*', route => route.fulfill({ json: { agents: [] } })) + await page.route('**/api/chats/files/content*', route => route.fulfill(file === null ? { status: 404, json: { error: { message: 'not found' } } } : { status: 200, contentType: 'text/plain', body: file })) + await page.route(/\/api\/chats\/changes(\?.*)?$/, route => route.fulfill({ json: s.p.status === 'pending' ? [s.p] : [] })) + await page.route(/\/api\/chats\/changes\/cp1(\/[a-z]+)?$/, (route) => { + const req = route.request() + const verb = new URL(req.url()).pathname.split('/').pop() + const tail = verb === 'cp1' ? '' : verb + s.calls.push(`${req.method()} ${tail}`.trim()) + if (req.method() === 'GET') return route.fulfill({ json: s.p }) + if (tail === 'confirm') { + s.bodies.push(req.postDataJSON()) + if (s.failsLeft > 0) { s.failsLeft--; return route.fulfill({ status: 500, json: { error: { message: 'the request failed' } } }) } + if (confirmStatus === 409) { s.p = { ...s.p, status: 'stale' }; return route.fulfill({ status: 409, json: { error: { message: 'studio: the file changed since this was proposed' } } }) } + s.p = { ...s.p, status: 'applied', rev: 7, answered: AT } + } else if (tail === 'dismiss') s.p = { ...s.p, status: 'dismissed', answered: AT } + else if (tail === 'undo') s.p = { ...s.p, status: 'reverted', revert_rev: 8 } + return route.fulfill({ json: s.p }) + }) + return s +} + +const card = (page) => page.getByTestId('change-proposal') + +async function open(page, opts) { + const s = await serve(page, opts) + await page.goto('/app/chats/c1') + await expect(page.getByTestId('chat-message').nth(1)).toBeVisible() + await expect(card(page)).not.toHaveAttribute('data-state', 'loading') + return s +} + +async function shoot(page, name) { + for (const theme of ['light', 'dark']) { + await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme) + await page.waitForTimeout(500) + await card(page).screenshot({ path: `test-results/change-proposal-${name}-${theme}.png` }) + } + await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'light')) +} + +test('a pending change shows its kind, target, source, expiry and a line diff, and nothing is sent yet', async ({ page }) => { + const s = await open(page) + await expect(card(page)).toHaveAttribute('data-state', 'pending') + await expect(card(page)).toContainText('You live in Milan, not Rome') + await expect(card(page)).toContainText('Nothing is saved until you confirm.') + await expect(card(page).locator('[data-row="kind"]')).toContainText('Memory · All your chats') + await expect(card(page).locator('[data-row="target"] code')).toHaveText('Memory/index.md') + await expect(card(page).locator('[data-row="source"] dd')).toHaveText('A chat') + await expect(card(page).locator('[data-row="expires"]')).toContainText(/20 h \d+ min/) + const diff = card(page).getByTestId('change-diff') + await expect(diff.getByTestId('change-diff-stat')).toContainText('2 lines added') + await expect(diff.getByTestId('change-diff-stat')).toContainText('1 line removed') + await expect(diff.locator('[data-op="del"]')).toHaveText(/Removed:\s*- home: Rome/) + await expect(diff.locator('[data-op="add"]')).toHaveCount(2) + await expect(diff.locator('[data-op="add"]').first()).toHaveText(/Added:\s*- home: Milan/) + // The card sits below the text of its message. + const text = await page.locator('[data-testid="chat-message"][data-role="assistant"] .cx-prose, [data-testid="chat-message"][data-role="assistant"] .prose').first().boundingBox() + const box = await card(page).boundingBox() + expect(box.y).toBeGreaterThanOrEqual(text.y + text.height - 1) + expect(s.calls).toEqual(['GET']) + await shoot(page, 'pending') +}) + +test('Confirm applies the change, shows a receipt, and Undo takes it back', async ({ page }) => { + const s = await open(page) + await card(page).getByRole('button', { name: 'Confirm', exact: true }).click() + await expect(card(page)).toHaveAttribute('data-state', 'applied') + await expect(card(page)).toContainText('Applied:') + await expect(card(page)).toContainText('You live in Milan, not Rome') + await expect(page.getByRole('status').filter({ hasText: 'Change applied.' })).toHaveCount(1) + expect(s.bodies).toEqual([{}]) + await shoot(page, 'applied') + + await page.reload() + await expect(card(page)).toHaveAttribute('data-state', 'applied') + await card(page).getByRole('button', { name: 'Undo' }).click() + await expect(card(page)).toHaveAttribute('data-state', 'reverted') + await expect(card(page)).toContainText('Undone:') + await expect(card(page).getByRole('button', { name: 'Undo' })).toHaveCount(0) + expect(s.calls).toEqual(expect.arrayContaining(['POST confirm', 'POST undo'])) +}) + +test('Edit then confirm sends the edited text, which the person can correct first', async ({ page }) => { + const s = await open(page) + await card(page).getByRole('button', { name: 'Edit then confirm' }).click() + const area = card(page).getByLabel('Text to save in Memory/index.md') + await expect(area).toBeFocused() + await expect(area).toHaveValue(PROPOSED) + await expect(card(page).getByTestId('change-diff')).toHaveCount(0) + await area.fill('- home: Milan\n- tea: black\n') + await shoot(page, 'editing') + await card(page).getByRole('button', { name: 'Confirm with my text' }).click() + await expect(card(page)).toHaveAttribute('data-state', 'applied') + expect(s.bodies).toEqual([{ content: '- home: Milan\n- tea: black\n' }]) +}) + +test('Escape and Cancel leave the editor and keep the proposal waiting', async ({ page }) => { + const s = await open(page) + await card(page).getByRole('button', { name: 'Edit then confirm' }).click() + await card(page).getByLabel('Text to save in Memory/index.md').press('Escape') + await expect(card(page).getByRole('button', { name: 'Confirm', exact: true })).toBeVisible() + await expect(card(page)).toHaveAttribute('data-state', 'pending') + expect(s.calls).toEqual(['GET']) +}) + +test('Dismiss leaves a quiet line and writes nothing', async ({ page }) => { + const s = await open(page) + await card(page).getByRole('button', { name: 'Dismiss' }).click() + await expect(card(page)).toHaveAttribute('data-state', 'dismissed') + await expect(card(page)).toContainText('Dismissed:') + expect(s.calls).toContain('POST dismiss') + expect(s.calls).not.toContain('POST confirm') +}) + +test('the buttons work from the keyboard', async ({ page }) => { + await open(page) + await card(page).getByRole('button', { name: 'Confirm', exact: true }).focus() + await page.keyboard.press('Tab') + await expect(card(page).getByRole('button', { name: 'Edit then confirm' })).toBeFocused() + await page.keyboard.press('Tab') + await expect(card(page).getByRole('button', { name: 'Dismiss' })).toBeFocused() + await page.keyboard.press('Shift+Tab') + await page.keyboard.press('Shift+Tab') + await page.keyboard.press('Enter') + await expect(card(page)).toHaveAttribute('data-state', 'applied') +}) + +test('an expired proposal says so and offers to ask again, which sends a message to the chat', async ({ page }) => { + const s = await open(page, { p: proposal({ status: 'expired', expires: AT }) }) + await expect(card(page)).toHaveAttribute('data-state', 'expired') + await expect(card(page)).toContainText('Expired:') + await expect(card(page).getByTestId('change-why')).toContainText('waited more than a day') + await expect(card(page).getByRole('button', { name: 'Confirm', exact: true })).toHaveCount(0) + await shoot(page, 'expired') + await card(page).getByRole('button', { name: 'Ask again' }).click() + await expect.poll(() => s.sent.length).toBe(1) + expect(s.sent[0].text).toContain('propose this change again') +}) + +test('a pending proposal whose time ran out shows as expired without waiting for the server', async ({ page }) => { + await open(page, { p: proposal({ expires: new Date(Date.now() - 1000).toISOString() }) }) + await expect(card(page)).toHaveAttribute('data-state', 'expired') +}) + +test('a file that changed since the proposal is stale: the card says nothing was saved and offers to ask again', async ({ page }) => { + const s = await open(page, { confirmStatus: 409 }) + await card(page).getByRole('button', { name: 'Confirm', exact: true }).click() + await expect(card(page)).toHaveAttribute('data-state', 'stale') + await expect(card(page)).toContainText('Not applied:') + await expect(card(page).getByTestId('change-why')).toContainText('file changed after this was proposed') + await shoot(page, 'stale') + await card(page).getByRole('button', { name: 'Ask again' }).click() + await expect.poll(() => s.sent.length).toBe(1) + expect(s.sent[0].text).toContain('Memory/index.md') +}) + +test('a write that failed puts the proposal back to waiting, with the reason', async ({ page }) => { + const s = await open(page, { confirmFails: 1 }) + await card(page).getByRole('button', { name: 'Confirm', exact: true }).click() + await expect(card(page).getByTestId('change-error')).toContainText('Could not save the change') + await expect(card(page).getByTestId('change-error')).toContainText('still waiting') + await expect(card(page)).toHaveAttribute('data-state', 'pending') + await shoot(page, 'failed') + await card(page).getByRole('button', { name: 'Confirm', exact: true }).click() + await expect(card(page)).toHaveAttribute('data-state', 'applied') + expect(s.calls.filter(c => c === 'POST confirm')).toHaveLength(2) +}) + +test('a new file is all added, and a kind or source the card does not know is drawn generically', async ({ page }) => { + await open(page, { + file: null, + p: proposal({ kind: 'widget', scope: 'channel', target: 'Memory/channels/room/index.md', summary: 'Add the team rule', source: { kind: 'connector', id: 'team-bot', chat: 'c1', sender: 'Ada' } }), + }) + await expect(card(page)).toHaveAttribute('data-kind', 'widget') + await expect(card(page).locator('[data-row="kind"]')).toContainText('widget · This conversation') + await expect(card(page).locator('[data-row="source"] dd')).toHaveText('Connector team-bot, from Ada') + await expect(card(page).getByTestId('change-diff-stat')).toContainText('New file') + await expect(card(page).getByTestId('change-diff').locator('[data-op="del"]')).toHaveCount(0) +}) + +test('a profile change names its agent', async ({ page }) => { + await open(page, { p: proposal({ kind: 'profile', scope: 'agent', agent: 'ag1', target: 'LocalAI/agents/ag1/profile.md', summary: 'Be brief' }) }) + await expect(card(page).locator('[data-row="kind"]')).toContainText('Profile · Agent ag1') +}) + +test('on a phone the card fits and keeps its buttons in reach', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 800 }) + await open(page) + const box = await card(page).boundingBox() + expect(box.x).toBeGreaterThanOrEqual(0) + expect(box.x + box.width).toBeLessThanOrEqual(390) + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) + expect(overflow).toBeLessThanOrEqual(0) + // The whole screen, to see the card between the header and the composer. + await card(page).scrollIntoViewIfNeeded() + for (const theme of ['light', 'dark']) { + await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme) + await page.waitForTimeout(500) + await page.screenshot({ path: `test-results/change-proposal-phone-screen-${theme}.png` }) + } + await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'light')) +}) diff --git a/core/http/react-ui/e2e/chats-proposals-inbox.spec.js b/core/http/react-ui/e2e/chats-proposals-inbox.spec.js new file mode 100644 index 0000000000..62e30a58cd --- /dev/null +++ b/core/http/react-ui/e2e/chats-proposals-inbox.spec.js @@ -0,0 +1,105 @@ +import { test, expect } from './coverage-fixtures.js' +import { inboxButton, makeInbox, stubInbox } from './inbox-fixtures.js' + +// A screenshot of the page in both themes, for review; the files go to test-results. +async function shot(page, name, options = {}) { + for (const theme of ['light', 'dark']) { + await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme) + // The theme fades in; let it finish so the picture shows the final colours. + await page.waitForTimeout(500) + await page.screenshot({ path: `test-results/${name}-${theme}.png`, ...options }) + } + await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'light')) +} + + +// The changes that wait for the person when they are not in the chat: a count +// on the inbox button, a row in the inbox panel that opens the Proposals page, +// and the same card there for each change. The API is stubbed. + +const AT = '2026-11-03T09:00:00Z' +const later = () => new Date(Date.now() + 5 * 3600_000).toISOString() +const make = (id, extra = {}) => ({ + v: 1, id, owner: 'alice', kind: 'memory', scope: 'user', target: `Memory/${id}.md`, summary: `Note ${id}`, content: `- fact ${id}\n`, + source: { kind: 'connector', id: 'team-bot', chat: 'x1' }, status: 'pending', created: AT, expires: later(), ...extra, +}) + +async function serve(page, list) { + const s = { list, calls: [] } + await stubInbox(page, makeInbox(0)) + await page.route('**/api/chats/files/content*', route => route.fulfill({ status: 404, json: { error: { message: 'not found' } } })) + await page.route(/\/api\/chats\/changes(\?.*)?$/, route => route.fulfill({ json: s.list.filter(p => p.status === 'pending') })) + await page.route(/\/api\/chats\/changes\/([a-z0-9]+)(\/[a-z]+)?$/, (route) => { + const req = route.request() + const parts = new URL(req.url()).pathname.split('/') + const verb = parts.length > 5 ? parts[5] : '' + const p = s.list.find(x => x.id === parts[4]) + s.calls.push(`${req.method()} ${parts[4]} ${verb}`.trim()) + if (!p) return route.fulfill({ status: 404, json: { error: { message: 'not found' } } }) + if (verb === 'confirm') Object.assign(p, { status: 'applied', rev: 3 }) + else if (verb === 'dismiss') p.status = 'dismissed' + return route.fulfill({ json: p }) + }) + return s +} + +test('the inbox button counts the waiting changes and the panel opens the Proposals page', async ({ page }) => { + await serve(page, [make('a1'), make('b2')]) + await page.goto('/app/chats/c1') + await expect(page.getByTestId('inbox-count')).toHaveText('2') + await expect(inboxButton(page)).toHaveAccessibleName('Notifications, 2 unread') + await inboxButton(page).click() + const row = page.getByTestId('inbox-proposals') + await expect(row).toContainText('2 changes wait for you') + await shot(page, 'proposals-inbox-row') + await row.click() + await expect(page).toHaveURL(/\/app\/chats\/proposals$/) + await expect(page.getByRole('heading', { level: 1, name: 'Proposals' })).toBeVisible() + await expect(page.getByTestId('change-proposal')).toHaveCount(2) + await expect(page.getByTestId('change-proposal').first()).toContainText('Connector team-bot') +}) + +test('an answered change keeps its receipt on the page and leaves the count', async ({ page }) => { + const s = await serve(page, [make('a1'), make('b2')]) + await page.goto('/app/chats/proposals') + const cards = page.getByTestId('change-proposal') + await expect(cards).toHaveCount(2) + await cards.first().getByRole('button', { name: 'Confirm', exact: true }).click() + await expect(cards.first()).toHaveAttribute('data-state', 'applied') + await expect(cards).toHaveCount(2) + await cards.nth(1).getByRole('button', { name: 'Dismiss' }).click() + await expect(cards.nth(1)).toHaveAttribute('data-state', 'dismissed') + expect(s.calls).toEqual(expect.arrayContaining(['POST a1 confirm', 'POST b2 dismiss'])) + await shot(page, 'proposals-page') +}) + +test('nothing waiting: no row in the inbox, no count, and the page says so', async ({ page }) => { + await serve(page, []) + await page.goto('/app/chats/c1') + await inboxButton(page).click() + await page.getByRole('dialog', { name: 'Notifications' }).waitFor() + await expect(page.getByTestId('inbox-proposals')).toHaveCount(0) + await expect(page.getByTestId('inbox-count')).toHaveCount(0) + await page.goto('/app/chats/proposals') + await expect(page.getByTestId('proposals-empty')).toContainText('Nothing is waiting') +}) + +test('a server that keeps no proposals leaves the inbox as it was', async ({ page }) => { + await stubInbox(page, makeInbox(0)) + await page.route(/\/api\/chats\/changes(\?.*)?$/, route => route.fulfill({ status: 409, json: { error: { type: 'files_off', message: 'off' } } })) + await page.goto('/app/chats/c1') + await inboxButton(page).click() + await page.getByRole('dialog', { name: 'Notifications' }).waitFor() + await expect(page.getByTestId('inbox-proposals')).toHaveCount(0) +}) + +test('on a phone the inbox row fits the sheet', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 800 }) + await serve(page, [make('a1')]) + await page.goto('/app/chats/c1') + await inboxButton(page).click() + await expect(page.getByTestId('inbox-proposals')).toContainText('1 change waits for you') + await shot(page, 'proposals-inbox-row-phone') + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) + expect(overflow).toBeLessThanOrEqual(0) +}) diff --git a/core/http/react-ui/e2e/settings-memory.spec.js b/core/http/react-ui/e2e/settings-memory.spec.js new file mode 100644 index 0000000000..02afbad5e0 --- /dev/null +++ b/core/http/react-ui/e2e/settings-memory.spec.js @@ -0,0 +1,188 @@ +import { test, expect } from './coverage-fixtures.js' +import { mockChat } from './chat-fixtures.js' + +// A screenshot of the page in both themes, for review; the files go to test-results. +async function shot(page, name, options = {}) { + for (const theme of ['light', 'dark']) { + await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme) + // The theme fades in; let it finish so the picture shows the final colours. + await page.waitForTimeout(500) + await page.screenshot({ path: `test-results/${name}-${theme}.png`, ...options }) + } + await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'light')) +} + + +// Settings > Memory (a tab of Your settings): the context files the host puts +// in the prompt, with their sizes against the caps, and read, edit, delete and +// clear all. The API is stubbed by a small model of the files. + +const LIMITS = { index_lines: 40, index_bytes: 2048, profile_bytes: 4096, turn_bytes: 6144 } +const file = (path, size, extra = {}) => ({ path, name: path.split('/').pop(), size, ...extra }) + +function fixture() { + return { + texts: { + 'Memory/index.md': '- home: Rome -> home.md\n- job: dev -> job.md\n', + 'Memory/home.md': 'Rome, since 2020.\n', + 'Memory/job.md': 'Developer.\n', + 'Memory/agents/bot/index.md': '- tone: short\n', + 'LocalAI/agents/bot/profile.md': 'Be brief.\n', + }, + removed: [], + writes: [], + } +} + +function listing(s) { + const has = p => p in s.texts + const f = (p, extra) => (has(p) ? [file(p, s.texts[p].length, extra)] : []) + const idx = (p) => ({ index: true, lines: s.texts[p].split('\n').filter(l => l.trim()).length }) + const groups = [] + const user = [...(has('Memory/index.md') ? [file('Memory/index.md', s.texts['Memory/index.md'].length, idx('Memory/index.md'))] : []), ...f('Memory/home.md'), ...f('Memory/job.md')] + if (user.length) groups.push({ source: 'notes-user', scope: 'user', max_bytes: 2048, max_lines: 40, files: user }) + const agent = has('Memory/agents/bot/index.md') ? [file('Memory/agents/bot/index.md', s.texts['Memory/agents/bot/index.md'].length, idx('Memory/agents/bot/index.md'))] : [] + if (agent.length) groups.push({ source: 'notes-agent', scope: 'agent', key: 'bot', max_bytes: 2048, max_lines: 40, files: agent }) + const prof = has('LocalAI/agents/bot/profile.md') ? [file('LocalAI/agents/bot/profile.md', s.texts['LocalAI/agents/bot/profile.md'].length, { index: true, lines: 1 })] : [] + if (prof.length) groups.push({ source: 'profile', scope: 'agent', key: 'bot', max_bytes: 4096, files: prof }) + return { limits: LIMITS, groups } +} + +async function serve(page, s = fixture()) { + await mockChat(page) + await page.route('**/api/chats/context-files', route => route.fulfill({ json: listing(s) })) + await page.route('**/api/chats/files/content*', (route) => { + const req = route.request() + const path = decodeURIComponent(new URL(req.url()).searchParams.get('path')).replace(/^\//, '') + if (req.method() === 'PUT') { + s.texts[path] = req.postData() + s.writes.push({ path, query: new URL(req.url()).search }) + return route.fulfill({ json: { path: `/${path}`, name: path, kind: 'file', size: s.texts[path].length, version: 2, rev: 9 } }) + } + return route.fulfill({ status: 200, contentType: 'text/plain', body: s.texts[path] ?? '' }) + }) + await page.route(/\/api\/chats\/files\?.*$/, (route) => { + const req = route.request() + if (req.method() !== 'DELETE') return route.fallback() + const path = decodeURIComponent(new URL(req.url()).searchParams.get('path')).replace(/^\//, '') + delete s.texts[path] + s.removed.push(path) + return route.fulfill({ json: { rev: 11 } }) + }) + return s +} + +async function open(page, s) { + const state = await serve(page, s) + await page.goto('/app/account?tab=memory') + await expect(page.getByTestId('memory-tab')).toBeVisible() + return state +} + +const group = (page, id) => page.locator(`[data-group="${id}"]`) + +test('lists the notes of the user, of an agent and its profile with their sizes against the caps', async ({ page }) => { + await open(page) + await expect(page.getByRole('tab', { name: 'Memory' })).toHaveAttribute('aria-selected', 'true') + await expect(page.getByTestId('memory-limits')).toContainText('at most 40 lines and 2.0 KiB') + await expect(page.getByTestId('memory-limits')).toContainText('6.0 KiB of notes') + const user = group(page, 'notes-user:') + await expect(user.getByRole('heading', { name: 'Your notes' })).toBeVisible() + await expect(user.getByTestId('memory-use')).toContainText('45 B of 2.0 KiB · 2 of 40 lines') + await expect(user.getByTestId('memory-file')).toHaveCount(3) + await expect(user.getByTestId('memory-file').first()).toContainText('index.md') + await expect(user.getByTestId('memory-file').first()).toContainText('Index') + await expect(group(page, 'notes-agent:bot').getByRole('heading')).toHaveText('Notes of agent bot') + const profile = group(page, 'profile:bot') + await expect(profile.getByRole('heading')).toHaveText('Profile of agent bot') + await expect(profile.getByTestId('memory-use')).toContainText('10 B of 4.0 KiB') + await expect(profile.getByTestId('memory-use')).not.toContainText('lines') + await shot(page, 'memory-page', { fullPage: true }) +}) + +test('a file opens to read, and the owner edits it directly; the index is held to its caps', async ({ page }) => { + const s = await open(page) + const idx = group(page, 'notes-user:').getByTestId('memory-file').first() + await idx.locator('.mt-file__name').click() + await expect(idx.getByTestId('memory-text')).toContainText('home: Rome') + await idx.getByTestId('memory-edit').click() + const area = idx.getByTestId('memory-edit-text') + await expect(area).toBeFocused() + await area.fill('- home: Milan -> home.md\n') + await expect(idx.getByTestId('memory-edit-size')).toContainText('1 of 40 lines') + await area.fill(Array.from({ length: 41 }, (_, i) => `- n${i}`).join('\n')) + await expect(idx.getByTestId('memory-edit-size')).toContainText('Too long') + await expect(idx.getByRole('button', { name: 'Save' })).toBeDisabled() + await shot(page, 'memory-page-too-long', { fullPage: true }) + await area.fill('- home: Milan -> home.md\n') + await idx.getByRole('button', { name: 'Save' }).click() + await expect(idx.getByTestId('memory-text')).toContainText('home: Milan') + expect(s.writes).toHaveLength(1) + expect(s.writes[0].path).toBe('Memory/index.md') + expect(s.writes[0].query).toContain('overwrite=1') + expect(s.texts['Memory/index.md']).toBe('- home: Milan -> home.md\n') +}) + +test('Escape leaves the editor without saving', async ({ page }) => { + const s = await open(page) + const f = group(page, 'notes-user:').getByTestId('memory-file').nth(1) + await f.locator('.mt-file__name').click() + await f.getByTestId('memory-edit').click() + await f.getByTestId('memory-edit-text').press('Escape') + await expect(f.getByTestId('memory-text')).toBeVisible() + expect(s.writes).toHaveLength(0) +}) + +test('Delete asks first, then moves the file to the Trash', async ({ page }) => { + const s = await open(page) + const f = group(page, 'notes-user:').getByTestId('memory-file').nth(2) + await f.getByTestId('memory-delete').click() + await expect(f.getByRole('group', { name: 'Delete job.md?' })).toBeVisible() + await f.getByRole('button', { name: 'Cancel' }).click() + expect(s.removed).toEqual([]) + await f.getByTestId('memory-delete').click() + await f.getByTestId('memory-delete-yes').click() + await expect(group(page, 'notes-user:').getByTestId('memory-file')).toHaveCount(2) + expect(s.removed).toEqual(['Memory/job.md']) + await expect(page.getByRole('status').filter({ hasText: 'Moved job.md to the Trash.' })).toHaveCount(1) +}) + +test('Clear all asks to confirm, then removes every file', async ({ page }) => { + const s = await open(page) + await page.getByTestId('memory-clear').click() + await expect(page.getByTestId('memory-clear-confirm')).toContainText('Delete 5 files?') + await shot(page, 'memory-page-clear', { fullPage: true }) + await page.getByTestId('memory-clear-yes').click() + await expect(page.getByTestId('memory-empty')).toContainText('No notes yet') + expect(s.removed.sort()).toEqual(Object.keys(fixture().texts).sort()) +}) + +test('a file over its cap says so', async ({ page }) => { + const s = fixture() + s.texts['Memory/index.md'] = Array.from({ length: 45 }, (_, i) => `- n${i}`).join('\n') + await open(page, s) + const use = group(page, 'notes-user:').getByTestId('memory-use') + await expect(use).toContainText('45 of 40 lines') + await expect(use).toContainText('Over the cap') +}) + +test('nothing saved yet shows an empty state', async ({ page }) => { + await open(page, { texts: {}, removed: [], writes: [] }) + await expect(page.getByTestId('memory-empty')).toContainText('No notes yet') + await expect(page.getByTestId('memory-clear')).toHaveCount(0) +}) + +test('a server that cannot list shows an error with a retry', async ({ page }) => { + await mockChat(page) + await page.route('**/api/chats/context-files', route => route.fulfill({ status: 500, json: { error: { message: 'busy' } } })) + await page.goto('/app/account?tab=memory') + await expect(page.getByTestId('memory-load-error')).toBeVisible() +}) + +test('on a phone the page fits and the rows keep their buttons', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 800 }) + await open(page) + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) + expect(overflow).toBeLessThanOrEqual(0) + await shot(page, 'memory-page-phone', { fullPage: true }) +})