2026-10-28 01:02:03 -04:00
/**
* @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 < string , unknown >;
/** 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() {} };
2026-11-09 07:57:14 -08:00
var VoiceInput = { SILENCE_TIMEOUT_SECONDS: [0, 3, 6, 15, 30], _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
2026-10-28 01:02:03 -04:00
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 < string , string >();
const elements : Record < string , any > = {
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 < string , string >) : void };
function translator ( language : string ) {
const dom = new JSDOM ( '<!doctype html><html><body></body></html>' , {
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' ,
'已保存到本设备,但未保存到服务器:无法连接到服务器' ,
],
2026-11-09 14:01:09 +08:00
[
'Options for "demo" (until the next sync) saved on this device, not on the server: HTTP 500' ,
'案例“demo”的选项已保存到本设备(同步前有效),但未保存到服务器:HTTP 500' ,
],
2026-10-28 01:02:03 -04:00
[ `Tunnel not stopped: ${ TORN } ` , `隧道未停止: ${ TORN } ` ],
];
for ( const [ english , chinese ] of cases ) {
expect ( zh . t ( english )). toBe ( chinese );
expect ( en . t ( english )). toBe ( english );
}
});
});