From a1406f1753ad01f9daebf4e56597228cc6b4e23e Mon Sep 17 00:00:00 2001 From: JD Date: Wed, 28 Oct 2026 01:02:03 -0400 Subject: [PATCH] fix(settings): stop a failed settings write from saying it was saved App Settings' save handled only the multi-user admin-only 403 and the tunnel's no-password 403, so a 409 for a settings.json that does not parse, a 400, a 500 or a dropped connection (`_apiPut` answers null) still toasted "Settings saved" and closed the modal, and the run mode and respawn preset writes failed with no word at all. Those failures now warn with the server's reason (else the HTTP status, else that the server could not be reached), the modal stays open, and nothing that assumes the server stored the save runs: Apply's refresh, the tunnel-starting toast and the gesture-control reload. Since the modal now invites a second Save, the plan-usage collection flip and the gesture reload are judged against the settings the failed save started from until a save lands, so the retry still sends the flip and still reloads. A save with the tunnel switch on is read as a tunnel refusal only on a 403, so another failure no longer turns the switch off or shows the no-password message, and an accepted no-password retry now resends the whole save instead of the tunnel keys alone, which had left every other setting unsaved behind "Settings saved". Run mode and respawn presets stay saved on this device as before and now say they were not saved on the server. Stopping the tunnel from the welcome screen or the tunnel panel no longer says "Tunnel stopped" when the server refused the write, since the tunnel is then still running. --- src/web/public/i18n.js | 14 + src/web/public/respawn-ui.js | 4 +- src/web/public/session-ui.js | 4 +- src/web/public/settings-ui.js | 94 ++++- test/plan-usage-collection-flip.test.ts | 7 +- test/settings-admin-only-ui.test.ts | 1 + test/settings-save-failure-ui.test.ts | 536 ++++++++++++++++++++++++ 7 files changed, 639 insertions(+), 21 deletions(-) create mode 100644 test/settings-save-failure-ui.test.ts diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index dc02f5e55..945dfc64f 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -918,6 +918,8 @@ 'Saved on this device. Server-wide settings can only be changed by an admin.': '已保存到本设备。服务器全局设置只能由管理员更改。', 'Server-wide settings can only be changed by an admin': '服务器全局设置只能由管理员更改', + // The reason a settings write gives when no answer came back (settings-ui.js _settingsWriteError). + 'the server could not be reached': '无法连接到服务器', 'Tunnel active': '隧道已启用', 'Tunnel starting — QR code will appear when ready...': '隧道正在启动,准备好后将显示二维码…', 'Push notifications enabled': '推送通知已启用', @@ -1380,6 +1382,7 @@ [/^Lifetime: (\d+) sessions created$/, (_m, count) => `累计已创建 ${count} 个会话`], [/^Tunnel active: (.+)$/, (_m, url) => `隧道已启用:${url}`], [/^Tunnel error: (.+)$/, (_m, error) => `隧道错误:${error}`], + [/^Tunnel not stopped: ([\s\S]+)$/, (_m, why) => `隧道未停止:${why}`], [/^Update to v(.+)$/, (_m, version) => `更新到 v${version}`], [/^You're up to date \(v(.+)\)\.$/, (_m, version) => `已是最新版本(v${version})。`], [/^Update available: v(.+)$/, (_m, version) => `有可用更新:v${version}`], @@ -1448,6 +1451,17 @@ (_m, n) => `窗口太小,容纳不下 ${n} 个窗格:只显示聚焦的窗格`, ], [/^The agent exited \((-?\d+)\)$/, (_m, code) => `智能体已退出(${code})`], + // A settings write the server did not store (settings-ui.js). The server's reason passes + // through untranslated; the one the page supplies itself has its own entry. + [ + /^Saved on this device, not on the server: ([\s\S]+)$/, + (_m, why) => `已保存到本设备,但未保存到服务器:${ZH_CN[why] || why}`, + ], + [ + /^(Run mode|Respawn presets) saved on this device, not on the server: ([\s\S]+)$/, + (_m, what, why) => + `${what === 'Run mode' ? '运行模式' : '重生预设'}已保存到本设备,但未保存到服务器:${ZH_CN[why] || why}`, + ], // A dialog read off a hook-less CLI's screen (src/web/screen-blocked.ts), as the // notification drawer shows it. The CLI's label passes through. [/^(.+) is waiting for your answer in the terminal$/, (_m, cli) => `${cli} 正在终端中等待你的回答`], diff --git a/src/web/public/respawn-ui.js b/src/web/public/respawn-ui.js index e7f8dadf3..1ba7ce246 100644 --- a/src/web/public/respawn-ui.js +++ b/src/web/public/respawn-ui.js @@ -601,13 +601,13 @@ Object.assign(CodemanApp.prototype, { // presets are a key of the server-wide settings.json there, and its PUT is admin-only // (403), so they are kept as their own on this device and win over the server's on the // next load (_rememberOwnSetting, settings-ui.js). A PUT refused that way (sent before the - // role was known) marks the page read-only for the writes after it. + // role was known) marks the page read-only for the writes after it; any other failure is toasted. if (this._settingsReadOnly?.()) { this._rememberOwnSetting?.('respawnPresets', custom); return; } this._apiPut('/api/settings', { respawnPresets: custom }) - .then((res) => this._noteSettingsWriteRefusal?.(res)) + .then((res) => this._noteSettingsWriteResult?.(res, 'Respawn presets')) .catch(() => {}); }, diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 1842cca23..7afc50e36 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -735,12 +735,12 @@ Object.assign(CodemanApp.prototype, { // where settings.json is server-wide and its PUT admin-only: the mode is kept as their own // on this device and wins over the server's on the next load (_rememberOwnSetting, // settings-ui.js). A PUT refused that way (sent before the role was known) marks the page - // read-only for the writes after it (_noteSettingsWriteRefusal). + // read-only for the writes after it (_noteSettingsWriteRefusal); any other failure is toasted. if (this._settingsReadOnly?.()) { this._rememberOwnSetting?.('runMode', mode); } else { this._apiPut('/api/settings', { runMode: mode }) - .then((res) => this._noteSettingsWriteRefusal?.(res)) + .then((res) => this._noteSettingsWriteResult?.(res, 'Run mode')) .catch(() => {}); } // Close menu diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 0bf5025c0..3ef724470 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -2300,6 +2300,14 @@ Object.assign(CodemanApp.prototype, { btn.disabled = false; return; } + // A stop the server did not store (a 409 for a settings.json that does not parse) left the + // tunnel running: say so, and leave the button showing it on. + const stopError = newEnabled ? '' : await this._settingsWriteError(res); + if (stopError) { + this.showToast(`Tunnel not stopped: ${stopError}`, 'error', { duration: 0 }); + btn.disabled = false; + return; + } if (newEnabled) { this._showTunnelConnecting(); // Poll tunnel status as fallback in case SSE event is missed @@ -2661,9 +2669,11 @@ Object.assign(CodemanApp.prototype, { * Shows the server's (actionable) message as an error toast. Since #706 this is the * fallback for when `_confirmTunnelEnable` could not read the tunnel status. * @param {Response|null} res - the fetch Response from the settings PUT + * @param {object} [sent] - what that PUT sent, resent with the acknowledgement: the refusal + * rejected all of it, so an App Settings save retried with the tunnel keys alone lost the rest * @returns {Promise} true if the tunnel-enable was refused (caller should abort) */ - async _handleTunnelEnableRefusal(res) { + async _handleTunnelEnableRefusal(res, sent = { tunnelEnabled: true }) { if (!res || res.ok) return false; let message = 'Tunnel refused: set CODEMAN_PASSWORD before exposing Codeman publicly.'; let errorCode = ''; @@ -2695,7 +2705,7 @@ Object.assign(CodemanApp.prototype, { const retry = await fetch('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tunnelEnabled: true, acknowledgeUnauthTunnel: true }), + body: JSON.stringify({ ...sent, acknowledgeUnauthTunnel: true }), }); if (retry.ok) { this.showToast('Public tunnel enabling — no password set ⚠️', 'warning'); @@ -2745,6 +2755,13 @@ Object.assign(CodemanApp.prototype, { this.closeTunnelPanel(); return; } + // A stop the server did not store left the tunnel running (see toggleTunnelFromWelcome). + const stopError = enable ? '' : await this._settingsWriteError(res); + if (stopError) { + this.showToast(`Tunnel not stopped: ${stopError}`, 'error', { duration: 0 }); + this.closeTunnelPanel(); + return; + } if (enable) { this._updateTunnelIndicator(false); const indicator = document.getElementById('tunnelIndicator'); @@ -2963,7 +2980,11 @@ Object.assign(CodemanApp.prototype, { // Gesture overlay is injected at page render (server-side), so a change to it // only takes effect on reload — remember the prior value to decide below. const _prev = this.loadAppSettingsFromStorage(); - const _prevGestureEnabled = (_prev.gestureControlEnabled ?? false) === true; + // After a save the server did not store, this device's blob is ahead of the server, so the + // two server-side changes judged against it (the gesture reload, the plan-usage collection + // flip) are judged against the blob that save started from until a save lands. + const _prevServer = this._unsavedSettingsBaseline ?? _prev; + const _prevGestureEnabled = (_prevServer.gestureControlEnabled ?? false) === true; // WebGL toggle: default ON (desktop), so only an explicit stored false counts // as "previously off" — used below to detect a real OFF→ON flip. const _prevWebglEnabled = (_prev.webglRendererEnabled ?? true) === true; @@ -3282,7 +3303,7 @@ Object.assign(CodemanApp.prototype, { // switch collection off for every desktop, whose chip then went stale with // no error anywhere. An explicit toggle on any device still writes it, in // either direction. - const _chipFlip = this.planUsageCollectionFlip(_prev, settings.showPlanUsageLimits); + const _chipFlip = this.planUsageCollectionFlip(_prevServer, settings.showPlanUsageLimits); const { localEchoEnabled: _leo, // Per-device (it is about this screen's keyboard) and absent from @@ -3355,6 +3376,7 @@ Object.assign(CodemanApp.prototype, { ...serverSettings } = settings; let webhookError = ''; + let saveError = ''; let serverSaved = false; try { // #706: a save that turns the tunnel on asks first when the server has no login @@ -3368,13 +3390,14 @@ Object.assign(CodemanApp.prototype, { } // A non-admin's save is not sent at all (see settingsReadOnly above): the server // would refuse it with a 403, the plan-usage collection flip included. - const res = settingsReadOnly ? null : await this._apiPut('/api/settings', { + const body = { ...serverSettings, ...(_chipFlip !== undefined ? { showPlanUsageLimits: _chipFlip } : {}), ...(tunnelGate.acknowledge ? { acknowledgeUnauthTunnel: true } : {}), notificationPreferences: notifPrefsToSave, voiceSettings, - }); + }; + const res = settingsReadOnly ? null : await this._apiPut('/api/settings', body); // Multi-user mode, a non-admin whose role was not known when this save started // (/api/me had not answered, or failed and admin-ui.js fell back to a single-user @@ -3389,15 +3412,24 @@ Object.assign(CodemanApp.prototype, { // COD-55: the server refuses an unauthenticated public tunnel with a 403 — which // rejects the WHOLE settings PUT. Surface the message and revert the tunnel toggle - // so it doesn't look enabled. - if (!refusedAsNonAdmin && settings.tunnelEnabled && (await this._handleTunnelEnableRefusal(res))) { + // so it doesn't look enabled. Only a 403: any other failure with the switch on is + // not about the tunnel, and turning the switch off for it would be wrong too. + const tunnelRefused = !refusedAsNonAdmin && settings.tunnelEnabled && res?.status === 403; + if (tunnelRefused && (await this._handleTunnelEnableRefusal(res, body))) { this._abandonTunnelEnable(settings, keepOpen); return; } // `_apiPut` answers null or a non-ok response instead of throwing, so this is the only - // evidence the server kept the flags the Apply refresh below reads. - serverSaved = !!res?.ok; + // evidence the server kept the flags the Apply refresh below reads (a tunnel 403 still + // here was retried with the whole save and accepted). + serverSaved = !!res?.ok || tunnelRefused; + // Any other failure (a 409 for a settings.json that does not parse, a 400, a 500, a dropped + // connection) stored nothing on the server, so the save must not say it did. A known + // non-admin sent nothing (res is null). + saveError = + settingsReadOnly || refusedAsNonAdmin || tunnelRefused ? '' : await this._settingsWriteError(res); + this._unsavedSettingsBaseline = saveError ? _prevServer : null; // Save model configuration separately. For a non-admin it is not sent either, and the // answer says whether this save would have changed it. @@ -3408,7 +3440,9 @@ Object.assign(CodemanApp.prototype, { // it. Only when the group was edited: an untouched group must not re-PUT. A refusal (bad URL, // enabled with no URL) keeps the modal open below, with the pasted URL still in the box. webhookError = this._webhookPending() ? await this.saveWebhook() : ''; - if (webhookError) { + if (saveError) { + this.showToast(`Saved on this device, not on the server: ${saveError}`, 'warning', { duration: 0 }); + } else if (webhookError) { this.showToast(`Settings saved, but not the webhook: ${webhookError}`, 'warning'); } else if (syncedChangeRefused || modelConfigRefused || refusedAsNonAdmin) { // ONE toast for the whole save, and only when the user really changed a server-wide @@ -3421,7 +3455,7 @@ Object.assign(CodemanApp.prototype, { // Show tunnel-specific feedback if toggled on (never for a non-admin, whose save // starts nothing: the box only shows what the server already has) - if (settings.tunnelEnabled && !settingsReadOnly && !refusedAsNonAdmin) { + if (settings.tunnelEnabled && !settingsReadOnly && !refusedAsNonAdmin && !saveError) { this.showToast('Tunnel starting — QR code will appear when ready...', 'info'); } } catch (err) { @@ -3433,9 +3467,10 @@ Object.assign(CodemanApp.prototype, { // old flags, and a webhook-only failure still saved the rest, so this runs ahead of that branch. if (keepOpen && serverSaved) this._refreshSettingsAfterApply(settings); + // A failed settings PUT keeps the modal open too, with the form as the user left it. if (webhookError) { document.getElementById('webhookGroup')?.scrollIntoView({ block: 'center' }); - } else if (!keepOpen) { + } else if (!keepOpen && !saveError) { this.closeAppSettings(); } @@ -3446,10 +3481,10 @@ Object.assign(CodemanApp.prototype, { // The gesture overlay is injected at page render (server reads // gestureControlEnabled from settings.json), so a change only takes effect on // reload. Reload when it actually changed — the server PUT above already - // persisted the new value. + // persisted the new value (not after a failed PUT: the reload would render the old one). // `?? false`: a non-admin's save puts a synced key the server never stored back to // absent (_keepServerSyncedSettings), and absent means off, as in _prevGestureEnabled. - if ((settings.gestureControlEnabled ?? false) !== _prevGestureEnabled) { + if (!saveError && (settings.gestureControlEnabled ?? false) !== _prevGestureEnabled) { this.showToast( settings.gestureControlEnabled ? 'Enabling gesture control — reloading…' : 'Disabling gesture control — reloading…', 'info' @@ -3498,6 +3533,35 @@ Object.assign(CodemanApp.prototype, { return true; }, + /** + * Why a settings write did not land, or '' when it did: the server's `error` (a 409 when + * settings.json does not parse, a 400, ...), else the HTTP status, else, for the null that + * `_apiPut` answers a dropped connection with, that the server could not be reached. Reads a + * clone, like _noteSettingsWriteRefusal. + * @returns {Promise} + */ + async _settingsWriteError(res) { + if (!res) return 'the server could not be reached'; + if (res.ok) return ''; + let body = null; + try { + body = await res.clone().json(); + } catch { + /* not JSON: the status is all there is */ + } + return body?.error || `HTTP ${res.status}`; + }, + + /** + * The end of a background settings write (run mode, respawn presets): the admin-only refusal is + * _noteSettingsWriteRefusal's, and any other failure says that `what` stayed on this device only. + */ + async _noteSettingsWriteResult(res, what) { + if (await this._noteSettingsWriteRefusal(res)) return; + const error = await this._settingsWriteError(res); + if (error) this.showToast(`${what} saved on this device, not on the server: ${error}`, 'warning', { duration: 0 }); + }, + /** * Where a non-admin's OWN run mode and respawn presets live (multi-user mode). Both are keys of * the server-wide settings.json, whose PUT is admin-only, so this user's picks are kept on this diff --git a/test/plan-usage-collection-flip.test.ts b/test/plan-usage-collection-flip.test.ts index 776810a50..7b186159a 100644 --- a/test/plan-usage-collection-flip.test.ts +++ b/test/plan-usage-collection-flip.test.ts @@ -70,8 +70,11 @@ describe('saveAppSettings wiring', () => { ); // Stripped from serverSettings like the other per-device display keys. expect(save).toMatch(/showPlanUsageLimits: _pul,/); - // Decided once against the device's prior settings, before they are overwritten. - expect(save).toMatch(/const _chipFlip = this\.planUsageCollectionFlip\(_prev, settings\.showPlanUsageLimits\);/); + // Decided once against the device's prior settings, before they are overwritten (or, after a + // save the server did not store, against the settings that save started from). + expect(save).toMatch( + /const _chipFlip = this\.planUsageCollectionFlip\(_prevServer, settings\.showPlanUsageLimits\);/ + ); // And only a real flip reaches the PUT body. expect(save).toMatch(/\.\.\.\(_chipFlip !== undefined \? \{ showPlanUsageLimits: _chipFlip \} : \{\}\),/); }); diff --git a/test/settings-admin-only-ui.test.ts b/test/settings-admin-only-ui.test.ts index 92449171b..30bfcc9ac 100644 --- a/test/settings-admin-only-ui.test.ts +++ b/test/settings-admin-only-ui.test.ts @@ -95,6 +95,7 @@ const REAL = new Set([ '_keepServerSyncedSettings', '_displaySettingKeySet', '_noteSettingsWriteRefusal', + '_settingsWriteError', '_applyOwnSettings', '_readOwnSettings', '_ownSettingsKey', diff --git a/test/settings-save-failure-ui.test.ts b/test/settings-save-failure-ui.test.ts new file mode 100644 index 000000000..f18fd0831 --- /dev/null +++ b/test/settings-save-failure-ui.test.ts @@ -0,0 +1,536 @@ +/** + * @fileoverview A browser write to settings.json that did not land must not say it did. + * + * PUT /api/settings can fail for reasons other than the two refusals the page already handles + * (the multi-user admin-only 403 FORBIDDEN and the tunnel's no-password 403): a 409 CONFLICT when + * settings.json does not parse (settings-writer.ts refuses to overwrite it), a 400, a 500, or a + * dropped connection, which `_apiPut` answers with null instead of throwing. Each of those used + * to end the App Settings save with "Settings saved" and a closed modal, and the run mode and + * respawn preset writes failed with no word at all. Now: + * - App Settings: a warning that carries the server's reason (else its HTTP status, else that the + * server could not be reached), the modal stays open, and nothing that assumes the server saved + * runs (Apply's refresh, the tunnel-starting toast, the gesture reload). A save with the tunnel + * switch on is no longer read as a tunnel refusal unless the server said 403. + * - Run mode and respawn presets: the same warning, naming what was not saved; they stay saved on + * this device as before. + * - The success path and both 403 paths are unchanged. + * - The new toasts read in Chinese under zh-CN. + * + * The real modules run inside a JSDOM window with the real index.html (the App Settings save) or a + * vm context (the background writes); `_apiPut` is the real api-client.js, and only `fetch` is + * mocked. Port: none. + */ + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import vm from 'node:vm'; +import { JSDOM } from 'jsdom'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const PUBLIC = join(process.cwd(), 'src/web/public'); +const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8'); +const INDEX = read('index.html'); +const I18N = read('i18n.js'); +const SETTINGS_UI = read('settings-ui.js'); +const SESSION_UI = read('session-ui.js'); +const RESPAWN_UI = read('respawn-ui.js'); +const API_CLIENT = read('api-client.js'); + +const DESKTOP_KEY = 'codeman-app-settings'; +type Settings = Record; + +/** What settings-writer.ts answers (409 CONFLICT) when settings.json does not parse. */ +const TORN = + '/home/u/.codeman/settings.json is not a valid JSON object, so this change was not saved (saving would have replaced every other setting in it). Fix or delete the file, or restore it from a config backup, then try again.'; +const INVALID = 'Invalid settings: codexModel: Invalid string'; + +/** A fetch answer: a JSON envelope, or a body that is not JSON (`body: null`). */ +type Answer = { status: number; body: Settings | null } | 'unreachable'; +const envelope = (status: number, error: string, errorCode: string): Answer => ({ + status, + body: { success: false, error, errorCode }, +}); +const OK: Answer = { status: 200, body: { success: true, data: {} } }; +const CONFLICT = envelope(409, TORN, 'CONFLICT'); +const BAD_REQUEST = envelope(400, INVALID, 'INVALID_INPUT'); +const SERVER_ERROR: Answer = { status: 500, body: null }; +const FORBIDDEN = envelope(403, 'You do not have permission', 'FORBIDDEN'); + +function respond(answer: Answer) { + if (answer === 'unreachable') return Promise.reject(new TypeError('Failed to fetch')); + const make = (): any => ({ + ok: answer.status >= 200 && answer.status < 300, + status: answer.status, + json: async () => { + if (answer.body === null) throw new SyntaxError('Unexpected token < in JSON'); + return answer.body; + }, + clone: make, + }); + return Promise.resolve(make()); +} + +const warning = (text: string) => [text, 'warning', { duration: 0 }]; +const errorToast = (text: string) => [text, 'error', { duration: 0 }]; + +const windows: { close(): void }[] = []; +afterEach(() => { + for (const win of windows.splice(0)) win.close(); +}); +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +/** Methods that run for real; every other prototype method is a no-op (panels this file is not about). */ +const REAL = new Set([ + 'openAppSettings', + 'saveAppSettings', + 'applyAppSettings', + 'loadAppSettingsFromServer', + 'loadAppSettingsFromStorage', + 'saveAppSettingsToStorage', + 'getSettingsStorageKey', + 'getDefaultSettings', + 'tabCliLogosEnabled', + 'resolveTabArrangement', + 'resolveTabStateOrder', + 'resolveHeaderStatsStyle', + 'resolveSessionSidebarFontSize', + 'planUsageChipEnabled', + 'planUsageCollectionFlip', + 'loadModelConfigForSettings', + 'saveModelConfigFromSettings', + '_modelConfigFromSettingsForm', + '_settingsReadOnly', + '_captureSettingsFormBaseline', + '_keepServerSyncedSettings', + '_displaySettingKeySet', + '_noteSettingsWriteRefusal', + '_settingsWriteError', + '_confirmTunnelEnable', + '_handleTunnelEnableRefusal', + '_abandonTunnelEnable', + '_askUnauthTunnel', + '_webhookPending', + '_api', + '_apiPut', +]); +const ELSEWHERE = [ + '_updateLocalEchoState', + 'renderProjectInsightsPanel', + 'updateSubagentWindowVisibility', + 'showWelcome', +]; + +/** + * Boot index.html with the real modules, signed in as a single-user admin. `put` is what + * PUT /api/settings answers; `tunnelStatus` what GET /api/tunnel/status answers. + */ +async function boot( + put: Answer | Answer[], + tunnelStatus: Answer = { status: 200, body: { success: true, data: {} } }, + confirmAnswer = false +) { + const putAnswers = Array.isArray(put) ? [...put] : null; + const dom = new JSDOM(INDEX, { url: 'http://localhost/', runScripts: 'outside-only' }); + const win = dom.window as unknown as Window & + typeof globalThis & { eval(src: string): void; __App: any; __codemanUser: Settings; fetch: unknown }; + windows.push(win); + if (win.document.readyState !== 'complete') await new Promise((resolve) => win.addEventListener('load', resolve)); + win.setInterval = (() => 0) as unknown as typeof win.setInterval; + win.confirm = vi.fn(() => confirmAnswer); + win.localStorage.setItem(DESKTOP_KEY, JSON.stringify({})); + const fetchCalls: { url: string; method: string; body?: string }[] = []; + win.fetch = vi.fn((url: string, opts: { method?: string; body?: string } = {}) => { + const method = opts.method || 'GET'; + fetchCalls.push({ url, method, body: opts.body }); + if (url === '/api/settings' && method === 'PUT') return respond(putAnswers ? putAnswers.shift()! : (put as Answer)); + if (url === '/api/tunnel/status') return respond(tunnelStatus); + if (url === '/api/execution/model-config' && method === 'GET') { + return respond({ status: 200, body: { success: true, data: { defaultModel: 'sonnet' } } }); + } + return respond(OK); + }); + win.eval( + [ + `var MobileDetection = { + getDeviceType: () => 'desktop', + isHandheldDevice: () => false, + isMobile: () => false, + isTouchDevice: () => false, + }; + var KeyboardHandler = {}, SwipeHandler = {}, DeepgramProvider = {}, NotificationManager = function () {}; + var KeyboardAccessoryBar = { setMode() {} }; + var VoiceInput = { _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() }; + var FocusTrap = function () { this.activate = () => {}; this.deactivate = () => {}; }; + var DEFAULT_VOICE_KEYTERMS = '';`, + read('constants.js'), + read('app.js'), + SETTINGS_UI, + API_CLIENT, + 'window.__App = CodemanApp;', + ].join('\n') + ); + win.__codemanUser = { username: 'admin', role: 'admin', multiUser: false }; + const target = Object.create(win.__App.prototype); + target.sessions = new Map(); + target.sessionOrder = []; + target.showToast = vi.fn(); + target.closeAppSettings = vi.fn(); + target._refreshSettingsAfterApply = vi.fn(); + const noop = () => undefined; + for (const name of ELSEWHERE) target[name] = noop; + const app = new Proxy(target, { + get(t, key, receiver) { + const value = Reflect.get(t, key, receiver); + const stubbed = + typeof key === 'string' && typeof value === 'function' && !Object.hasOwn(t, key) && !REAL.has(key); + return stubbed ? noop : value; + }, + }) as any; + await app.loadAppSettingsFromServer(Promise.resolve({})); + app.openAppSettings(); + await tick(); + await tick(); + const doc = win.document; + const box = (id: string) => doc.getElementById(id) as HTMLInputElement; + const storedBlob = () => JSON.parse(win.localStorage.getItem(DESKTOP_KEY) || '{}') as Settings; + const settingsPuts = () => fetchCalls.filter((c) => c.url === '/api/settings' && c.method === 'PUT'); + return { app, target, box, storedBlob, settingsPuts }; +} + +describe('App Settings save whose settings PUT did not land', () => { + const failures: [string, Answer, string][] = [ + ['409 CONFLICT (settings.json does not parse)', CONFLICT, TORN], + ['400 INVALID_INPUT', BAD_REQUEST, INVALID], + ['500 with a body that is not JSON', SERVER_ERROR, 'HTTP 500'], + ['a dropped connection (_apiPut answers null)', 'unreachable', 'the server could not be reached'], + ]; + + for (const [name, put, reason] of failures) { + it(`${name}: warns with the reason, keeps the modal open, keeps the change on this device`, async () => { + const { app, target, box, storedBlob, settingsPuts } = await boot(put); + box('appSettingsTabTwoRows').checked = true; // per-device + box('appSettingsAutoNameSessions').checked = true; // synced + target.showToast.mockClear(); + target.closeAppSettings.mockClear(); + + await app.saveAppSettings(); + + expect(settingsPuts()).toHaveLength(1); + expect(target.showToast.mock.calls).toEqual([warning(`Saved on this device, not on the server: ${reason}`)]); + expect(target.closeAppSettings).not.toHaveBeenCalled(); + expect(storedBlob().tabTwoRows).toBe(true); + expect(storedBlob().autoNameSessions).toBe(true); + }); + } + + it('Apply: the same warning, and the refresh that needs the saved flags does not run', async () => { + const { app, target } = await boot(CONFLICT); + target.showToast.mockClear(); + target._refreshSettingsAfterApply.mockClear(); + await app.applyAppSettings(); + expect(target.showToast.mock.calls).toEqual([warning(`Saved on this device, not on the server: ${TORN}`)]); + expect(target._refreshSettingsAfterApply).not.toHaveBeenCalled(); + expect(target.closeAppSettings).not.toHaveBeenCalled(); + }); + + it('with the tunnel switch on: not read as a tunnel refusal, no "Tunnel starting", the switch stays on', async () => { + for (const put of [CONFLICT, 'unreachable'] as Answer[]) { + const { app, target, box, storedBlob } = await boot(put, { + status: 200, + body: { success: true, data: { running: true, loginConfigured: true } }, + }); + box('appSettingsTunnelEnabled').checked = true; + target.showToast.mockClear(); + target.closeAppSettings.mockClear(); + + await app.saveAppSettings(); + + const reason = put === 'unreachable' ? 'the server could not be reached' : TORN; + expect(target.showToast.mock.calls, JSON.stringify(put)).toEqual([ + warning(`Saved on this device, not on the server: ${reason}`), + ]); + expect(target.closeAppSettings).not.toHaveBeenCalled(); + expect(box('appSettingsTunnelEnabled').checked).toBe(true); + expect(storedBlob().tunnelEnabled).toBe(true); + } + }); + + it('a gesture-control change does not reload the page as if the server had stored it', async () => { + const { app, target, box } = await boot(CONFLICT); + box('appSettingsGestureControl').checked = true; + target.showToast.mockClear(); + await app.saveAppSettings(); + expect(target.showToast.mock.calls).toEqual([warning(`Saved on this device, not on the server: ${TORN}`)]); + }); + + it('a retried Save still sends the plan-usage flip and reloads for gesture control, once the server stores it', async () => { + const { app, target, box, settingsPuts } = await boot([CONFLICT, OK]); + box('appSettingsShowPlanUsageLimits').checked = false; // desktop default ON: a flip + box('appSettingsGestureControl').checked = true; + target.showToast.mockClear(); + await app.saveAppSettings(); + expect(target.showToast.mock.calls).toEqual([warning(`Saved on this device, not on the server: ${TORN}`)]); + + // The user fixes the file and presses Save again; the form still shows the same choices. + target.showToast.mockClear(); + await app.saveAppSettings(); + const [first, second] = settingsPuts().map((p) => JSON.parse(p.body!)); + expect(first.showPlanUsageLimits).toBe(false); + expect(second.showPlanUsageLimits).toBe(false); + expect(target.showToast.mock.calls).toEqual([ + ['Settings saved', 'success'], + ['Enabling gesture control — reloading…', 'info'], + ]); + expect(target.closeAppSettings).toHaveBeenCalledTimes(1); + }); +}); + +describe('App Settings save: the paths that already worked', () => { + it('a saved PUT still says "Settings saved" and closes the modal', async () => { + const { app, target, box } = await boot(OK); + box('appSettingsAutoNameSessions').checked = true; + target.showToast.mockClear(); + await app.saveAppSettings(); + expect(target.showToast.mock.calls).toEqual([['Settings saved', 'success']]); + expect(target.closeAppSettings).toHaveBeenCalledTimes(1); + }); + + it('a 403 FORBIDDEN still ends with the one admin-only toast', async () => { + const { app, target } = await boot(FORBIDDEN); + target.showToast.mockClear(); + await app.saveAppSettings(); + expect(target.showToast.mock.calls).toEqual([ + ['Saved on this device. Server-wide settings can only be changed by an admin.', 'warning'], + ]); + expect(target.closeAppSettings).toHaveBeenCalledTimes(1); + expect(app._settingsReadOnly()).toBe(true); + }); + + it("the tunnel's no-password 403 still goes to the tunnel prompt, and cancelling it turns the switch off", async () => { + // GET /api/tunnel/status unreadable: the PUT goes out unacknowledged and the server refuses it. + const { app, target, box, storedBlob } = await boot( + envelope(403, 'Refusing to start a public tunnel with no login', 'OPERATION_FAILED'), + 'unreachable' + ); + box('appSettingsTunnelEnabled').checked = true; + target.showToast.mockClear(); + await app.saveAppSettings(); + expect(target.showToast.mock.calls).toEqual([['Tunnel not enabled', 'info']]); + expect(target.closeAppSettings).toHaveBeenCalledTimes(1); + expect(box('appSettingsTunnelEnabled').checked).toBe(false); + expect(storedBlob().tunnelEnabled).toBe(false); + }); + + it('an accepted no-password retry sends the whole save again, so "Settings saved" is true', async () => { + const { app, target, box, settingsPuts } = await boot( + [envelope(403, 'Refusing to start a public tunnel with no login', 'OPERATION_FAILED'), OK], + 'unreachable', + true + ); + box('appSettingsTunnelEnabled').checked = true; + box('appSettingsAutoNameSessions').checked = true; + target.showToast.mockClear(); + await app.saveAppSettings(); + + const [first, retry] = settingsPuts().map((p) => JSON.parse(p.body!)); + expect(settingsPuts()).toHaveLength(2); + expect(first.autoNameSessions).toBe(true); + expect(first.acknowledgeUnauthTunnel).toBeUndefined(); + expect(retry).toEqual({ ...first, acknowledgeUnauthTunnel: true }); + expect(target.showToast.mock.calls).toEqual([ + ['Public tunnel enabling — no password set ⚠️', 'warning'], + ['Settings saved', 'success'], + ['Tunnel starting — QR code will appear when ready...', 'info'], + ]); + expect(target.closeAppSettings).toHaveBeenCalledTimes(1); + }); + + it('Apply: an accepted retry refreshes the groups that read the saved flags', async () => { + const { app, target, box } = await boot( + [envelope(403, 'Refusing to start a public tunnel with no login', 'OPERATION_FAILED'), OK], + 'unreachable', + true + ); + box('appSettingsTunnelEnabled').checked = true; + target.showToast.mockClear(); + await app.applyAppSettings(); + expect(target.showToast.mock.calls).toEqual([ + ['Public tunnel enabling — no password set ⚠️', 'warning'], + ['Settings applied', 'success'], + ['Tunnel starting — QR code will appear when ready...', 'info'], + ]); + expect(target._refreshSettingsAfterApply).toHaveBeenCalledTimes(1); + expect(target.closeAppSettings).not.toHaveBeenCalled(); + }); +}); + +// ─── Background writes (vm harness: settings-ui.js + session-ui.js + respawn-ui.js + api-client.js) ─── + +function loadBackground(put: Answer) { + const storage = new Map(); + const elements: Record = { + runModeMenu: { classList: { remove: () => {} } }, + runBtn: { className: '', nextElementSibling: { className: '' } }, + runBtnLabel: { textContent: '' }, + welcomeTunnelBtn: { classList: { contains: (c: string) => c === 'active' }, disabled: false }, + }; + const CodemanApp = function CodemanApp(this: any) {}; + const fetchMock = vi.fn((url: string, opts: { method?: string } = {}) => + url === '/api/settings' && opts.method === 'PUT' ? respond(put) : respond(OK) + ); + const context = vm.createContext({ + CodemanApp, + VoiceInput: {}, + window: { __codemanUser: { username: 'admin', role: 'admin', multiUser: false } }, + localStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + }, + document: { getElementById: (id: string) => elements[id] ?? null, addEventListener: () => {} }, + fetch: fetchMock, + console, + }); + for (const [src, filename] of [ + [SETTINGS_UI, 'settings-ui.js'], + [SESSION_UI, 'session-ui.js'], + [RESPAWN_UI, 'respawn-ui.js'], + [API_CLIENT, 'api-client.js'], + ]) { + vm.runInContext(src, context, { filename }); + } + const app = new (CodemanApp as any)(); + app.showToast = vi.fn(); + app._dismissTunnelConnecting = vi.fn(); + app._updateWelcomeTunnelBtn = vi.fn(); + app.closeTunnelPanel = vi.fn(); + return { app, fetchMock, storage, elements }; +} + +/** Let the fire-and-forget PUT and its response handling settle. */ +const settle = async () => { + for (let i = 0; i < 5; i++) await tick(); +}; + +describe('the run mode and respawn preset writes', () => { + const failures: [string, Answer, string][] = [ + ['409 CONFLICT', CONFLICT, TORN], + ['500 with a body that is not JSON', SERVER_ERROR, 'HTTP 500'], + ['a dropped connection', 'unreachable', 'the server could not be reached'], + ]; + + for (const [name, put, reason] of failures) { + it(`${name}: run mode warns with the reason and stays on this device`, async () => { + const b = loadBackground(put); + b.app.setRunMode('codex'); + await settle(); + expect(b.fetchMock).toHaveBeenCalledWith('/api/settings', expect.objectContaining({ method: 'PUT' })); + expect(b.app.showToast.mock.calls).toEqual([ + warning(`Run mode saved on this device, not on the server: ${reason}`), + ]); + expect(b.storage.get('codeman_runMode')).toBe('codex'); + }); + + it(`${name}: respawn presets warn with the reason and stay on this device`, async () => { + const b = loadBackground(put); + b.app.saveRespawnPresets([ + { id: 'builtin', builtIn: true }, + { id: 'mine', name: 'mine' }, + ]); + await settle(); + expect(b.app.showToast.mock.calls).toEqual([ + warning(`Respawn presets saved on this device, not on the server: ${reason}`), + ]); + expect(JSON.parse(b.storage.get('codeman-respawn-presets')!)).toEqual([{ id: 'mine', name: 'mine' }]); + }); + } + + it('a saved write says nothing', async () => { + const b = loadBackground(OK); + b.app.setRunMode('codex'); + b.app.saveRespawnPresets([{ id: 'mine' }]); + await settle(); + expect(b.fetchMock).toHaveBeenCalledTimes(2); + expect(b.app.showToast).not.toHaveBeenCalled(); + }); + + it('a 403 FORBIDDEN stays the silent admin-only refusal that makes the page read-only', async () => { + const b = loadBackground(FORBIDDEN); + b.app.setRunMode('codex'); + await settle(); + expect(b.app.showToast).not.toHaveBeenCalled(); + expect(b.app._settingsReadOnly()).toBe(true); + }); +}); + +describe('stopping the tunnel (welcome screen and tunnel panel)', () => { + it('a stop the server did not store says the tunnel was not stopped, and leaves it showing on', async () => { + for (const put of [CONFLICT, SERVER_ERROR]) { + const reason = put === CONFLICT ? TORN : 'HTTP 500'; + const welcome = loadBackground(put); + await welcome.app.toggleTunnelFromWelcome(); + expect(welcome.app.showToast.mock.calls).toEqual([errorToast(`Tunnel not stopped: ${reason}`)]); + expect(welcome.app._updateWelcomeTunnelBtn).not.toHaveBeenCalled(); + expect(welcome.app._dismissTunnelConnecting).not.toHaveBeenCalled(); + expect(welcome.elements.welcomeTunnelBtn.disabled).toBe(false); + + const panel = loadBackground(put); + await panel.app._tunnelPanelToggle(false); + expect(panel.app.showToast.mock.calls).toEqual([errorToast(`Tunnel not stopped: ${reason}`)]); + expect(panel.app.closeTunnelPanel).toHaveBeenCalledTimes(1); + } + }); + + it('a stored stop still says "Tunnel stopped"', async () => { + const welcome = loadBackground(OK); + await welcome.app.toggleTunnelFromWelcome(); + expect(welcome.app.showToast.mock.calls).toEqual([['Tunnel stopped', 'info']]); + expect(welcome.app._updateWelcomeTunnelBtn).toHaveBeenCalledWith(false); + + const panel = loadBackground(OK); + await panel.app._tunnelPanelToggle(false); + expect(panel.app.showToast.mock.calls).toEqual([['Tunnel stopped', 'info']]); + expect(panel.app.closeTunnelPanel).toHaveBeenCalledTimes(1); + }); +}); + +// ─── zh-CN ─── + +describe('the new toasts in zh-CN', () => { + type Api = { t(s: string): string; configure(o: Record): void }; + function translator(language: string) { + const dom = new JSDOM('', { + runScripts: 'outside-only', + url: 'http://localhost/', + }); + windows.push(dom.window); + vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' }); + const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n; + api.configure({ language }); + return api; + } + + it('translates each, keeps the reason as it came, and leaves English as it is', () => { + const zh = translator('zh-CN'); + const en = translator('en'); + const cases: [string, string][] = [ + [`Saved on this device, not on the server: ${TORN}`, `已保存到本设备,但未保存到服务器:${TORN}`], + [ + 'Run mode saved on this device, not on the server: HTTP 500', + '运行模式已保存到本设备,但未保存到服务器:HTTP 500', + ], + [ + 'Respawn presets saved on this device, not on the server: the server could not be reached', + '重生预设已保存到本设备,但未保存到服务器:无法连接到服务器', + ], + [ + 'Saved on this device, not on the server: the server could not be reached', + '已保存到本设备,但未保存到服务器:无法连接到服务器', + ], + [`Tunnel not stopped: ${TORN}`, `隧道未停止:${TORN}`], + ]; + for (const [english, chinese] of cases) { + expect(zh.t(english)).toBe(chinese); + expect(en.t(english)).toBe(english); + } + }); +});