Files
LocalAI/addons/memory/ui/test/settings.test.mjs
T
Ettore Di Giacinto 7079666185 feat(memory): choose how a note is saved on the add-on page
Declare the save-mode user setting (ask, auto, never; default ask) and
edit it on the Short memory page with the settings kit, a line for each
option, and the rule that messaging apps and background runs always ask.
The page reads and writes through sdk.userSettings and keeps the earlier
choice on screen when a save fails. The memory skill now says what to
tell the user for each answer of propose_change.

Assisted-by: Claude:claude-sonnet-5-5 node
2026-11-03 22:46:22 +00:00

127 lines
6.5 KiB
JavaScript

import test from 'node:test'
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { load, hostUi, React, draw } from './load.mjs'
// A real browser tree (jsdom) so the picker's effects and clicks run. React DOM
// reads the document at import time, so it is required after the globals.
const require = createRequire(join(hostUi, 'package.json'))
const { JSDOM } = require('jsdom')
const dom = new JSDOM('<div id="root"></div>', { url: 'http://localhost/' })
globalThis.window = dom.window
globalThis.document = dom.window.document
globalThis.IS_REACT_ACT_ENVIRONMENT = true
const { createRoot } = require('react-dom/client')
const { act } = React
const { userSettingsRequest } = await import(pathToFileURL(join(hostUi, 'src/addons/userSettingsCalls.js')).href)
const DECLARED = { settings: [{ key: 'save-mode', values: ['ask', 'auto', 'never'], default: 'ask', value: 'ask' }] }
const view = (value) => ({ settings: [{ ...DECLARED.settings[0], value }] })
// The host's Choice with a working change handler: the kit stub does not wire one.
const Choice = (p) => React.createElement('fieldset', { 'data-testid': p.testId, disabled: p.disabled },
React.createElement('legend', null, p.legend),
p.options.map((o) => React.createElement('label', { key: o.value, 'data-value': o.value },
React.createElement('input', { type: 'radio', name: 'mode', value: o.value, checked: p.value === o.value, disabled: o.disabled, onChange: () => p.onChange(o.value) }),
React.createElement('span', { className: 'label' }, o.label),
React.createElement('span', { className: 'desc' }, o.description))))
// mount draws the page with a fake owner behind sdk.userSettings. Every call
// goes through the real request builder, so a wrong route or header fails.
async function mount(handler) {
const calls = []
const { mod, sdk } = await load({
components: { Choice },
api: { userSettings: async (call) => { const r = userSettingsRequest(call); calls.push(r); return handler(r) } },
})
const addon = mod.createAddon(sdk)
const root = createRoot(document.getElementById('root'))
await act(async () => root.render(React.createElement(addon.Component, { page: 'memory' })))
const q = (sel) => document.querySelector(sel)
return { calls, q, root, async close() { await act(async () => root.unmount()) } }
}
const settle = () => act(async () => { await new Promise((r) => setTimeout(r, 0)) })
test('settings: it shows the three choices with a line each and the stored one selected', async () => {
const p = await mount(async () => view('auto'))
await settle()
assert.deepEqual(p.calls.map((c) => [c.method, c.path]), [['GET', '/api/addons/memory/user-settings']])
const labels = [...document.querySelectorAll('[data-testid="memory-save-mode"] label')].map((l) => [l.dataset.value, l.querySelector('.label').textContent, l.querySelector('.desc').textContent.length > 20])
assert.deepEqual(labels, [['ask', 'Ask me first', true], ['auto', 'Save it and tell me', true], ['never', 'Never', true]])
assert.equal(p.q('input[value="auto"]').checked, true)
assert.equal(p.q('input[value="ask"]').checked, false)
assert.match(p.q('[data-testid="memory-save-mode"] legend').textContent, /When the assistant wants to save something to my memory/)
await p.close()
})
test('settings: the help text says that messaging apps and background runs keep asking', async () => {
const p = await mount(async () => view('ask'))
await settle()
const text = p.q('[data-testid="memory-save-exception"]').textContent
assert.match(text, /messaging app always ask first, whatever you choose/)
assert.match(text, /Scheduled and background runs cannot save notes/)
assert.match(text, /cannot widen your choice/)
await p.close()
})
test('settings: choosing a mode sends that one key from the page of this add-on and shows the saved choice', async () => {
const p = await mount(async (r) => (r.method === 'PUT' ? view(r.body.value) : view('ask')))
await settle()
await act(async () => { p.q('input[value="auto"]').click() })
await settle()
const put = p.calls.find((c) => c.method === 'PUT')
assert.equal(put.path, '/api/addons/memory/user-settings')
assert.deepEqual(put.body, { key: 'save-mode', value: 'auto' })
assert.equal(put.headers['X-LocalAI-Confirm'], 'memory', 'the own-session confirmation names the add-on')
assert.equal(p.q('input[value="auto"]').checked, true)
assert.match(p.q('[data-testid="memory-save-status"]').textContent, /Your choice is saved/)
assert.equal(p.q('[data-testid="memory-save-failed"]'), null)
await p.close()
})
test('settings: a failed save keeps the earlier choice and says so', async () => {
const p = await mount(async (r) => { if (r.method === 'PUT') throw new Error('503'); return view('ask') })
await settle()
await act(async () => { p.q('input[value="never"]').click() })
await settle()
assert.equal(p.q('input[value="ask"]').checked, true, 'the earlier choice stays')
assert.equal(p.q('input[value="never"]').checked, false)
assert.match(p.q('[data-testid="memory-save-failed"]').textContent, /earlier choice is still in force/)
await p.close()
})
test('settings: the host answer decides, not the click', async () => {
// The owner stored something else (a concurrent change): the page shows it.
const p = await mount(async (r) => (r.method === 'PUT' ? view('never') : view('ask')))
await settle()
await act(async () => { p.q('input[value="auto"]').click() })
await settle()
assert.equal(p.q('input[value="never"]').checked, true)
await p.close()
})
test('settings: an unreadable choice shows a warning and no radios', async () => {
const p = await mount(async () => { throw new Error('503') })
await settle()
assert.match(p.q('[data-testid="memory-save-unreadable"]').textContent, /could not be read/)
assert.equal(p.q('[data-testid="memory-save-mode"]'), null)
await p.close()
})
test('settings: a package that declares no such setting shows no picker', async () => {
const p = await mount(async () => ({ settings: [] }))
await settle()
assert.equal(p.q('[data-testid="memory-save"]'), null)
assert.ok(p.q('[data-testid="memory-open"]'), 'the rest of the page stays')
await p.close()
})
test('settings: the page reads the setting and does not guess before it arrives', async () => {
const { mod, sdk } = await load({ components: { Choice } })
const html = draw(mod.createAddon(sdk), { page: 'memory' })
assert.match(html, /data-testid="memory-save-loading"/)
assert.doesNotMatch(html, /type="radio"/)
})