mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
test(ui): cover the proposal card, the Proposals page and the Memory tab
Playwright specs with a stubbed API: confirm, edit then confirm, dismiss, undo, expired, stale, a failed write, unknown kinds and sources, the inbox count and row, the Proposals page, and the Memory tab with read, edit, delete and clear all. The account spec lists the new tab. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
4 files changed
+533
-2
No files matched your search
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
Reference in new issue
Block a user