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); + } + }); +});