Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions apps/desktop/renderer-architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -1642,7 +1642,9 @@
"lifecycleMethods": {},
"unresolvedDependencies": 0,
"actionFactories": [],
"dependencyPaths": {}
"dependencyPaths": {
"@maka/core/ui-locale": 1
}
},
"src/renderer/locales/settings-daily-review-copy.ts": {
"bridgePaths": {},
Expand Down Expand Up @@ -1743,7 +1745,9 @@
"lifecycleMethods": {},
"unresolvedDependencies": 0,
"actionFactories": [],
"dependencyPaths": {}
"dependencyPaths": {
"@maka/core/ui-locale": 1
}
},
"src/renderer/locales/settings-usage-copy.ts": {
"bridgePaths": {},
Expand Down Expand Up @@ -2700,6 +2704,7 @@
"unresolvedDependencies": 0,
"actionFactories": [],
"dependencyPaths": {
"../locales/settings-bot-copy": 1,
"../locales/settings-health-copy": 1,
"./runtime-host-settings-target.js": 1,
"./settings-error-copy": 1,
Expand Down Expand Up @@ -2908,6 +2913,7 @@
"actionFactories": [],
"dependencyPaths": {
"../locales/permission-center-copy": 1,
"../locales/settings-bot-copy": 1,
"./runtime-host-settings-target.js": 1,
"./settings-error-copy": 1,
"./settings-section": 1,
Expand Down
291 changes: 291 additions & 0 deletions apps/desktop/src/main/__tests__/bot-chat-detail.test.ts

Large diffs are not rendered by default.

51 changes: 47 additions & 4 deletions apps/desktop/src/main/__tests__/bot-onboarding-main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ import {
type AppSettings,
type UpdateAppSettingsInput,
} from '@maka/core/settings';
import type { BotRegistry } from '@maka/runtime/bots';
import { BotRegistry } from '@maka/runtime/bots';
import { UI_LOCALES } from '@maka/core/ui-locale';
import { botOnboardingErrorMessage, botStatusReasonMessage, getBotSettingsCopy } from '../../renderer/locales/settings-bot-copy.js';
import { loadRuntimeUndici } from './runtime-undici.js';
import type { SettingsStore } from '@maka/storage/settings-store';
import {
BotOnboardingService,
Expand Down Expand Up @@ -105,6 +108,46 @@ function startResult() {
}

describe('BotOnboardingService', () => {
it('preserves a failed live Stream probe through the onboarding warning and localized presenter', async () => {
const { MockAgent, getGlobalDispatcher, setGlobalDispatcher } = loadRuntimeUndici();
const previous = getGlobalDispatcher();
const agent = new MockAgent();
agent.disableNetConnect();
setGlobalDispatcher(agent);
const registry = new BotRegistry({ onIncomingMessage() {}, onStatusChange() {} });
agent.get('https://oapi.dingtalk.com').intercept({ path: '/gettoken?appkey=public-id&appsecret=private-secret', method: 'GET' }).reply(200, { access_token: 'test-access-token', expires_in: 7200 });
agent.get('https://api.dingtalk.com').intercept({ path: '/v1.0/gateway/connections/open', method: 'POST' }).reply(503, { message: 'unavailable' });
const flow = harness({
async start() { return startResult(); },
async poll() { return { status: 'confirmed', credential: { provider: 'dingtalk', clientId: 'public-id', clientSecret: 'private-secret' } }; },
}, async (settings) => registry.applySettings(settings.botChat), { getStatus: () => ({ ...registry.getStatus('dingtalk') }) });
try {
const started = await flow.service.start({ provider: 'dingtalk' });
flow.advance(5000);
const snapshot = await flow.service.poll(started.sessionId);
agent.assertNoPendingInterceptors();
assert.equal(snapshot.state, 'connected');
assert.equal(snapshot.warningCode, 'saved_not_connected');
assert.equal(snapshot.warningDetail, 'connections-open-503');
for (const locale of UI_LOCALES) assert.equal(botStatusReasonMessage(snapshot.warningDetail, locale), getBotSettingsCopy(locale).statusReasons.withCode.connectionsOpen('503'));
} finally {
await registry.stopAll();
setGlobalDispatcher(previous);
await agent.close();
}
});
it('returns a coded error snapshot when the provider start fails', async () => {
const flow = harness({
async start() { throw new Error('HTTP 503 from provider'); },
async poll() { throw new Error('unreachable'); },
}, async () => {}, { getStatus: () => ({ readiness: 'not_configured' }) as never });
const started = await flow.service.start({ provider: 'dingtalk' });
assert.equal(started.state, 'error');
assert.equal(started.errorCode, 'provider_error');
for (const locale of UI_LOCALES) {
assert.equal(botOnboardingErrorMessage(started.errorCode, locale), getBotSettingsCopy(locale).onboarding.errors.provider_error);
}
});
it('persists confirmed credentials in main while returning a secret-free snapshot', async () => {
const adapter: BotOnboardingProviderAdapter = {
async start() { return startResult(); },
Expand Down Expand Up @@ -351,8 +394,8 @@ describe('BotOnboardingService', () => {
test.advance(5_000);
const connected = await test.service.poll(started.sessionId);
assert.equal(connected.state, 'connected');
assert.match(connected.warning ?? '', /凭据已保存,但连接未建立/);
assert.match(connected.warning ?? '', /鉴权失败/);
assert.equal(connected.warningCode, 'saved_not_connected');
assert.match(connected.warningDetail ?? '', /鉴权失败/);
assert.equal(JSON.stringify(connected).includes('private-client-secret'), false);
});

Expand All @@ -376,7 +419,7 @@ describe('BotOnboardingService', () => {
test.advance(5_000);
const connected = await test.service.poll(started.sessionId);
assert.equal(connected.state, 'connected');
assert.equal(connected.warning, undefined);
assert.equal(connected.warningCode, undefined);
});

it('invalidates an older session when the same provider starts again', async () => {
Expand Down
117 changes: 117 additions & 0 deletions apps/desktop/src/main/__tests__/permission-center-bot-reason.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*
* 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 { mkdtemp, rm } from 'node:fs/promises';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { after, before, test } from 'node:test';
import { build } from 'esbuild';
import { UI_LOCALES, type UiLocale } from '@maka/core/ui-locale';
import type { HealthSignal } from '@maka/core/health';
import { botStatusReasonMessage, getBotSettingsCopy } from '../../renderer/locales/settings-bot-copy.js';
import { getHealthCenterCopy, type HealthCenterCopy } from '../../renderer/locales/settings-health-copy.js';

const REPO_ROOT = resolve(import.meta.dirname, '../../../../..');
let localizedSignalDetail: (
signal: HealthSignal,
copy: HealthCenterCopy,
locale: UiLocale,
) => string | undefined;

before(async () => {
// The health page pre-resolves bot capability reasons at the page layer
// (copy catalogs may not runtime-import each other); bundle it the same way
// bot-chat-detail.test.ts does so node's ESM resolver sees a self-contained
// module graph.
const outdir = await mkdtemp(resolve(REPO_ROOT, 'apps/desktop/dist/main/__tests__/bot-reason-'));
await build({
entryPoints: [resolve(REPO_ROOT, 'apps/desktop/src/renderer/settings/health-center-page.tsx')],
outdir,
outExtension: { '.js': '.mjs' },
bundle: true,
packages: 'external',
platform: 'node',
format: 'esm',
jsx: 'automatic',
target: 'node20',
logLevel: 'silent',
});
({ localizedSignalDetail } = await import(pathToFileURL(resolve(outdir, 'health-center-page.mjs')).href));
after(() => rm(outdir, { recursive: true, force: true }));
});

// Producers emit machine codes; every renderer surface must resolve them
// through the bot copy table. A raw code such as `gateway-closed-4004` must
// never survive to the page in any locale (P2: Permission Center regression).
const BOT_REASONS = ['gateway-closed-4004', 'stream-failed', 'connections-open-503', 'rate-limited'] as const;

test('bot capability reasons resolve to localized sentences for every locale', () => {
for (const reason of BOT_REASONS) {
for (const locale of UI_LOCALES) {
const rendered = botStatusReasonMessage(reason, locale);
assert.ok(rendered, `${locale}: ${reason} must render copy`);
assert.notEqual(rendered, reason, `${locale}: ${reason} must not render raw`);
assert.notEqual(rendered, getBotSettingsCopy(locale).status.detailsInLogs, `${locale}: ${reason} must localize, not fall back to detailsInLogs`);
}
}
});

test('unknown bot reasons degrade to the localized generic line, never the raw code', () => {
for (const locale of UI_LOCALES) {
assert.equal(botStatusReasonMessage('future-code', locale), getBotSettingsCopy(locale).status.detailsInLogs);
}
});

test('health center renders localized bot capability reasons in all locales', () => {
const signal = (reason: string): HealthSignal => ({
id: 'capability:bot:discord',
label: 'Discord Bot',
scope: 'bot',
layer: 'runtime_probe',
status: 'warning',
source: 'capability_snapshot',
checkedAt: 1,
message: 'capability_degraded',
detail: { kind: 'capability_reason', reason },
relatedCapabilityId: 'bot:discord',
});
for (const locale of UI_LOCALES) {
const expected = getBotSettingsCopy(locale).statusReasons.withCode.gatewayClosed('4004');
assert.equal(localizedSignalDetail(signal('gateway-closed-4004'), getHealthCenterCopy(locale), locale), expected);
assert.ok(!localizedSignalDetail(signal('stream-failed'), getHealthCenterCopy(locale), locale)?.includes('stream-failed'));
}
});

test('health center keeps the interim CJK passthrough for non-bot capability reasons', () => {
const signal: HealthSignal = {
id: 'capability:computer_use',
label: 'Computer Use',
scope: 'capability',
layer: 'runtime_probe',
status: 'warning',
source: 'capability_snapshot',
checkedAt: 1,
message: 'capability_degraded',
detail: { kind: 'capability_reason', reason: 'maka-cu service 启动失败、已退出或已停止。' },
relatedCapabilityId: 'computer_use',
};
assert.equal(localizedSignalDetail(signal, getHealthCenterCopy('zh-CN'), 'zh-CN'), 'maka-cu service 启动失败、已退出或已停止。');
assert.equal(localizedSignalDetail(signal, getHealthCenterCopy('en'), 'en'), 'See the corresponding settings page for details.');
});
42 changes: 42 additions & 0 deletions apps/desktop/src/main/__tests__/runtime-undici.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* 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 { createRequire } from 'node:module';

type MockInterceptor = {
reply(status: number, body?: unknown): void;
replyWithError(error: Error): void;
};

export type MockAgentLike = {
disableNetConnect(): void;
get(origin: string): { intercept(options: { path: string; method: string }): MockInterceptor };
assertNoPendingInterceptors(): void;
close(): Promise<void>;
};

type UndiciLike = {
MockAgent: new () => MockAgentLike;
getGlobalDispatcher(): unknown;
setGlobalDispatcher(dispatcher: unknown): void;
};

export function loadRuntimeUndici(): UndiciLike {
return createRequire(import.meta.resolve('@maka/runtime/bots'))('undici');
}
9 changes: 2 additions & 7 deletions apps/desktop/src/main/__tests__/settings-ipc-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,6 @@ describe("settings IPC helpers", () => {
const result = toSettingsTestResult("telegram", {
ok: true,
identity: { id: "42", username: "maka_bot", displayName: "Maka" },
hint: "ready",
});

assert.equal(result.ok, true);
Expand All @@ -115,20 +114,16 @@ describe("settings IPC helpers", () => {
username: "maka_bot",
displayName: "Maka",
});
assert.equal(result.details?.hint, "ready");
});

test("redacts and generalizes bot test errors before returning SettingsTestResult", () => {
test("redacts bot test error diagnostics before returning SettingsTestResult", () => {
const result = toSettingsTestResult("telegram", {
ok: false,
errorCode: "connection_failed",
error: "401 Authorization: Bearer sk-live-secret-token-value",
});

assert.equal(result.code, "bot_connection_failed");
assert.equal(
result.message,
"Telegram connection test failed: Authentication failed.",
);
assert.equal(
JSON.stringify(result).includes("sk-live-secret-token-value"),
false,
Expand Down
Loading