blob: d8995687c70857c120eda2068618f47bfdd25ab9 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
import test from 'node:test';
// Production Settings, locale and receipt handling run unchanged. Only native
// input state, element builders and the Host boundary are supplied by the test.
const nativeModules = {
'gpui-kit': `const ui = (...args) => globalThis[Symbol.for('language.test.element')](...args);
export { ui as div, ui as list, ui as svg, ui as uniform_list, ui as PathBuilder };
export class View {}`,
'gpui-base': `const ui = (...args) => globalThis[Symbol.for('language.test.element')](...args);
const state = { new: ({placeholder}) => ({
placeholder, on() {}, set_masked() {}, set_auto_grow() {}, value: () => '',
set_placeholder(value) { this.placeholder = value; }
}) };
export { state as InputState, state as TextareaState };
export const Button = { new: ui };
export { ui as v_flex, ui as h_flex, ui as TextView, ui as Scrollbar,
ui as set_theme, ui as Input, ui as Checkbox, ui as Switch,
ui as Popover, ui as Textarea, ui as h_resizable, ui as resizable_panel };`,
'maka-locale': `export const system = () => 'en';`,
'maka-files': `const unexpected = () => { throw new Error('unexpected native file call'); };
export { unexpected as openText, unexpected as chooseFolder, unexpected as chooseFiles,
unexpected as release, unexpected as open, unexpected as reveal };`,
'maka-desktop': `export const Terminal = {}; export const TerminalState = {};`,
'maka-host': `export const query = (...args) => globalThis[Symbol.for('language.test.query')](...args);
export const mutate = (...args) => globalThis[Symbol.for('language.test.mutate')](...args);
const unexpected = () => { throw new Error('unexpected native subscription'); };
export { unexpected as subscribe, unexpected as next, unexpected as close };`,
};
const hooks = registerHooks({
resolve(specifier, context, nextResolve) {
if (Object.hasOwn(nativeModules, specifier)) {
return { url: `language-test:${specifier}`, shortCircuit: true };
}
return nextResolve(specifier, context);
},
load(url, context, nextLoad) {
if (url.startsWith('language-test:')) {
return { format: 'module', source: nativeModules[url.slice(14)], shortCircuit: true };
}
return nextLoad(url, context);
},
});
const { Settings, settingsPage } = await import(
'../../products/maka/desktop-shell/app/settings.js'
);
const { default: MakaDesktop } = await import('../../products/maka/desktop-shell/app/main.js');
const { language, t } = await import('../../products/maka/desktop-shell/app/locale.js');
hooks.deregister();
function setup(preferences) {
const requests = [];
const reports = [];
const read = async (endpoint, method, input) => {
requests.push({ endpoint, method, input });
assert.equal(endpoint, 'settings');
switch (method) {
case 'assistantPreferences':
return preferences;
case 'connections':
return { kind: 'ready', revision: 0, connections: [], next: null };
case 'models':
return {
kind: 'ready',
revision: 0,
choices: [],
defaultModel: { kind: 'none' },
next: null,
};
default:
throw new Error(`unexpected query ${method}`);
}
};
globalThis[Symbol.for('language.test.query')] = read;
globalThis[Symbol.for('language.test.mutate')] = () => {
throw new Error('unexpected mutation');
};
const settings = new Settings({
changed() {},
report: (message) => reports.push(message),
modelsChanged() {},
presetsChanged() {},
});
return { settings, requests, reports, read };
}
test('opening Settings uses the Host language for labels and retained inputs', async () => {
const { settings, requests, reports } = setup({
revision: 7,
language: null,
resolvedLanguage: 'zh-TW',
});
await settings.load();
assert.equal(language(), 'zh-TW');
assert.equal(t('newTask'), '新任務');
assert.equal(settings.modelQuery.placeholder, '搜尋模型');
assert.equal(settings.preferences.language, null);
assert.deepEqual(
requests.filter((request) => request.method === 'assistantPreferences'),
[{ endpoint: 'settings', method: 'assistantPreferences', input: {} }],
);
assert.deepEqual(reports, []);
});
function deferred() {
let resolve;
const promise = new Promise((done) => {
resolve = done;
});
return { promise, resolve };
}
function returned(value) {
return { receipt: { kind: 'recorded', outcome: { kind: 'returned', value } } };
}
test('save sends one CAS mutation and applies only the confirmed readback', async () => {
const { settings } = setup({ revision: 10, language: 'en', resolvedLanguage: 'en' });
await settings.loadPreferences();
const mutation = deferred();
const reading = deferred();
const snapshot = deferred();
const mutations = [];
globalThis[Symbol.for('language.test.mutate')] = (...args) => {
mutations.push(args);
return mutation.promise;
};
globalThis[Symbol.for('language.test.query')] = () => {
reading.resolve();
return snapshot.promise;
};
const saving = settings.setLanguage('zh-TW');
await settings.setLanguage('zh-CN');
assert.equal(language(), 'en');
assert.equal(settings.preferences.language, 'en');
mutation.resolve(returned({ kind: 'committed', revision: 11 }));
await reading.promise;
assert.equal(t('newTask'), 'New task');
assert.equal(settings.preferences.revision, 10);
snapshot.resolve({ revision: 11, language: 'zh-TW', resolvedLanguage: 'zh-TW' });
await saving;
assert.equal(t('newTask'), '新任務');
assert.equal(settings.modelQuery.placeholder, '搜尋模型');
assert.equal(settings.preferences.language, 'zh-TW');
assert.deepEqual(mutations, [
[
'settings',
'updateAssistantPreferences',
{ expectedRevision: 10, mutation: { kind: 'language', preference: 'zh-TW' } },
undefined,
],
]);
assert.equal(settings.busy, null);
assert.equal(settings.error, null);
});
test('conflict refreshes the actual Host value without replaying the mutation', async () => {
const { settings, reports } = setup({ revision: 20, language: 'en', resolvedLanguage: 'en' });
await settings.loadPreferences();
const mutations = [];
globalThis[Symbol.for('language.test.mutate')] = (...args) => {
mutations.push(args);
return returned({ kind: 'revision_conflict', expectedRevision: 20, actualRevision: 21 });
};
globalThis[Symbol.for('language.test.query')] = async () => ({
revision: 21,
language: 'auto',
resolvedLanguage: 'en',
});
await settings.setLanguage('zh-CN');
assert.deepEqual(settings.preferences, {
revision: 21,
language: 'auto',
resolvedLanguage: 'en',
});
assert.equal(t('newTask'), 'New task');
assert.deepEqual(reports, [
'Settings: Preferences changed elsewhere. The current language was reloaded; choose again to save.',
]);
assert.deepEqual(
mutations.map((args) => args[2]),
[{ expectedRevision: 20, mutation: { kind: 'language', preference: 'zh-CN' } }],
);
});
for (const [name, response, error] of [
['rejected', returned({ kind: 'rejected' }), 'Settings: The Host rejected the language change.'],
[
'unknown receipt',
{ receipt: { kind: 'unknown' } },
'Settings: settings.updateAssistantPreferences was not recorded as returned: {"kind":"unknown"}',
],
['failed readback', returned({ kind: 'committed', revision: 31 }), 'Settings: read unavailable'],
]) {
test(`${name} preserves the confirmed language and reports failure`, async () => {
const { settings, reports } = setup({ revision: 30, language: 'en', resolvedLanguage: 'en' });
await settings.loadPreferences();
const mutations = [];
globalThis[Symbol.for('language.test.mutate')] = (...args) => {
mutations.push(args);
return response;
};
globalThis[Symbol.for('language.test.query')] = () => {
throw new Error('read unavailable');
};
await settings.setLanguage('auto');
assert.equal(language(), 'en');
assert.deepEqual(settings.preferences, {
revision: 30,
language: 'en',
resolvedLanguage: 'en',
});
assert.deepEqual(reports, [error]);
assert.deepEqual(
mutations.map((args) => args[2]),
[{ expectedRevision: 30, mutation: { kind: 'language', preference: 'auto' } }],
);
});
}
test('late read cannot roll back a newer Host snapshot', async () => {
const { settings } = setup({ revision: 40, language: 'en', resolvedLanguage: 'en' });
await settings.loadPreferences();
const old = deferred();
globalThis[Symbol.for('language.test.query')] = () => old.promise;
const pending = settings.loadPreferences();
globalThis[Symbol.for('language.test.query')] = async () => ({
revision: 42,
language: 'auto',
resolvedLanguage: 'zh-CN',
});
await settings.loadPreferences();
old.resolve({ revision: 41, language: 'en', resolvedLanguage: 'en' });
await pending;
assert.deepEqual(settings.preferences, {
revision: 42,
language: 'auto',
resolvedLanguage: 'zh-CN',
});
assert.equal(t('newTask'), '新任务');
});
test('the production startup reads Host language before starting conversation loads', async () => {
const { settings, requests } = setup({
revision: 50,
language: 'auto',
resolvedLanguage: 'zh-TW',
});
const labels = [];
const app = {
settings,
rootFocus: { focus() {} },
composer: { loadModel: () => labels.push(t('newTask')) },
directories: { loadRoots() {} },
catalog: { run() {} },
};
await MakaDesktop.prototype.start.call(app, { bind_keys() {}, spawn() {} });
assert.deepEqual(labels, ['新任務']);
assert.deepEqual(requests, [{ endpoint: 'settings', method: 'assistantPreferences', input: {} }]);
});
test('reopening Settings reads another client’s language without local storage', async () => {
const { settings } = setup({ revision: 60, language: 'zh-TW', resolvedLanguage: 'zh-TW' });
await settings.load();
const finished = deferred();
const { read } = setup({ revision: 61, language: 'en', resolvedLanguage: 'en' });
globalThis[Symbol.for('language.test.query')] = read;
settings.hooks.changed = () => {
if (!settings.loading && settings.preferences?.revision === 61) finished.resolve();
};
settings.show();
await finished.promise;
assert.equal(settings.open, true);
assert.equal(settings.preferences.language, 'en');
assert.equal(settings.modelQuery.placeholder, 'Find a model');
assert.equal(t('newTask'), 'New task');
});
// Native element builders record the callback/disabled state, not pixels or
// layout. All Settings rendering and event wiring remains production code.
function elements() {
const buttons = new Map();
const labels = [];
globalThis[Symbol.for('language.test.element')] = (id) => {
const value = { disabled: false, click: null };
let element;
element = new Proxy(value, {
get(target, name) {
if (name === 'when') return (condition, render) => (condition ? render(element) : element);
if (name === 'child') {
return (child) => {
if (typeof child === 'string') labels.push(child);
return element;
};
}
if (name === 'disabled') {
return (disabled) => {
target.disabled = disabled;
return element;
};
}
if (name === 'on_click') {
return (click) => {
target.click = click;
return element;
};
}
return () => element;
},
});
if (typeof id === 'string') buttons.set(id, value);
return element;
};
return { buttons, labels };
}
test('the General language button uses Host CAS; inherited default is not saved auto', async () => {
const { settings } = setup({ revision: 70, language: null, resolvedLanguage: 'zh-TW' });
const { buttons, labels } = elements();
const cx = { theme: () => ({ is_dark: false }) };
settingsPage(settings, cx);
assert.equal(buttons.get('language-auto').disabled, true);
await settings.load();
settingsPage(settings, cx);
assert.equal(buttons.get('language-auto').disabled, false);
assert.deepEqual(
labels.filter((label) => label.endsWith('\nHost 預設:繁體中文')),
['儲存到目前連線的 Host,用於介面與新任務。已受理的任務保留原語言。\nHost 預設:繁體中文'],
);
const mutations = [];
globalThis[Symbol.for('language.test.mutate')] = (...args) => {
mutations.push(args);
return returned({ kind: 'committed', revision: 71 });
};
globalThis[Symbol.for('language.test.query')] = async () => ({
revision: 71,
language: 'auto',
resolvedLanguage: 'zh-CN',
});
await buttons.get('language-auto').click();
assert.deepEqual(
mutations.map((args) => args[2]),
[{ expectedRevision: 70, mutation: { kind: 'language', preference: 'auto' } }],
);
assert.equal(settings.preferences.language, 'auto');
assert.equal(t('newTask'), '新任务');
});