diff --git a/src/praisonai-desktop/frontend/tests/settings-effects.test.mjs b/src/praisonai-desktop/frontend/tests/settings-effects.test.mjs index 080ef4148..35fb81cd2 100644 --- a/src/praisonai-desktop/frontend/tests/settings-effects.test.mjs +++ b/src/praisonai-desktop/frontend/tests/settings-effects.test.mjs @@ -40,7 +40,8 @@ function sseReader(frames) { : { done: true, value: undefined }) }; } -async function boot(cfg = {}, { confirmAnswer = true, sse = [], prefersReducedMotion = false } = {}) { +async function boot(cfg = {}, { confirmAnswer = true, sse = [], prefersReducedMotion = false, + failSettingsWrite = false } = {}) { const calls = []; const bodies = []; const settings = { @@ -52,6 +53,16 @@ async function boot(cfg = {}, { confirmAnswer = true, sse = [], prefersReducedMo calls.push(`${opts.method || 'GET'} ${path}`); if (opts.body) { try { bodies.push(JSON.parse(opts.body)); } catch {} } const u = String(url); + // Simulate the engine rejecting a settings write -- the very case the UI + // itself warns about across a base_url/api_key change that restarts it. + if (failSettingsWrite && (opts.method || 'GET') === 'POST' && u.includes('/settings')) { + return { ok: false, status: 500, json: async () => ({}), text: async () => '' }; + } + // The real engine persists the write and echoes the stored settings back; + // reconciliation reads that echo, so the stub must reflect what it was sent. + if ((opts.method || 'GET') === 'POST' && u.includes('/settings') && opts.body) { + try { Object.assign(settings, JSON.parse(opts.body)); } catch {} + } const body = u.includes('/settings') ? settings : u.includes('/chats/') ? { id: 'c1', title: 'Hi', messages: [] } @@ -105,6 +116,23 @@ test('font_size changes the variable the message text is sized from', async () = assert.equal(large.doc.documentElement.style.getPropertyValue('--fs'), '20px'); }); +test('rapid text-size steps accumulate instead of collapsing to one', async () => { + // saveCfg only updates CFG once the write resolves. Two quick zoom-in presses + // fired back-to-back would otherwise both read the same starting size and + // land on the same next value; the pending-target bookkeeping must let them + // walk 13 -> 14 -> 15 across the two writes. + const b = await boot({ font_size: 13 }); + const zoom = () => b.doc.dispatchEvent(new b.window.KeyboardEvent('keydown', + { key: '=', metaKey: true, bubbles: true, cancelable: true })); + zoom(); + zoom(); + await new Promise((r) => setTimeout(r, 120)); + assert.equal(b.doc.documentElement.style.getPropertyValue('--fs'), '15px', + 'two quick steps collapsed to a single step'); + const sent = b.bodies.filter((x) => x && 'font_size' in x).map((x) => x.font_size); + assert.deepEqual(sent, [14, 15], `expected 14 then 15 to be persisted, got ${sent}`); +}); + test('code_font_size is its own setting, and scales with the interface', async () => { // It used to be an absolute px value, so at text size 18 the prose grew and // code blocks stayed put. It is now multiplied by the same scale -- which @@ -166,6 +194,59 @@ test('theme "system" leaves the attribute off so the OS decides', async () => { assert.equal(b.doc.documentElement.getAttribute('data-theme'), null); }); +// --- a settings write the engine rejects ------------------------------------ + +/** Open Settings and switch to the named section, waiting for it to render. */ +async function openSettings(b, section) { + click(b.doc.getElementById('settings')); + for (let i = 0; i < 40; i++) { + await new Promise((r) => setTimeout(r, 25)); + if (b.doc.getElementById('setbody')) break; + } + assert.ok(b.doc.querySelector('#setbody .srow'), 'settings never opened'); + const nav = [...b.doc.querySelectorAll('#setside button')] + .find((x) => x.textContent.includes(section)); + assert.ok(nav, `the ${section} section is missing`); + click(nav); + await new Promise((r) => setTimeout(r, 40)); +} + +test('a rejected theme write applies nothing and tells the user', async () => { + // saveCfg used to mutate first and never check r.ok, so a 500 left the button + // un-highlighted, no data-theme, and no error -- a completely dead button. + const b = await boot({ theme: 'system' }, { failSettingsWrite: true }); + await openSettings(b, 'Appearance'); + const light = [...b.doc.querySelectorAll('#setting-theme .seg button')] + .find((x) => x.textContent === 'Light'); + assert.ok(light, 'the Light option is missing'); + click(light); + await new Promise((r) => setTimeout(r, 80)); + assert.equal(b.doc.documentElement.getAttribute('data-theme'), null, + 'a failed write still applied the theme'); + const t = b.doc.getElementById('toast'); + assert.ok(t && t.classList.contains('show'), 'no error was surfaced'); +}); + +test('a rejected toggle write leaves the switch where it was', async () => { + // The toggle mutated cfg before the write, so a rejection left the switch and + // the stored value disagreeing and the next click flipped the wrong one back. + // The observable contract: a failed write must not move the switch, and the + // engine must never see a value the user did not manage to persist. + const b = await boot({ confirm_delete: true }, { failSettingsWrite: true }); + await openSettings(b, 'Safety'); + const sw = b.doc.querySelector('#setting-confirm_delete .sw'); + assert.ok(sw, 'the confirm_delete toggle is missing'); + assert.equal(sw.classList.contains('on'), true, 'toggle did not start on'); + b.bodies.length = 0; + click(sw); + await new Promise((r) => setTimeout(r, 80)); + const now = b.doc.querySelector('#setting-confirm_delete .sw'); + assert.equal(now.classList.contains('on'), true, + 'a failed write moved the switch to a state that was never persisted'); + assert.equal(b.bodies.some((x) => x && 'confirm_delete' in x && x.confirm_delete === false), + true, 'the attempted write should still have been sent'); +}); + // --- safety ------------------------------------------------------------------ /** Answer the in-app confirmation, and fail loudly if none appeared. */ diff --git a/src/praisonai-desktop/ui/index.html b/src/praisonai-desktop/ui/index.html index a413a9fe3..0f0026e41 100644 --- a/src/praisonai-desktop/ui/index.html +++ b/src/praisonai-desktop/ui/index.html @@ -1812,18 +1812,28 @@