Skip to content
Open
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
36 changes: 36 additions & 0 deletions apps/desktop/e2e/bot-onboarding-retry-health.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* 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 { expect, test } from './fixtures';
import { getBotSettingsCopy } from '../src/renderer/locales/settings-bot-copy';

test('bot onboarding shows bounded retry health while preserving the QR', async ({
linkColorWindow: page,
}, testInfo) => {
const status = page.locator('.settingsBotOnboardingStatus');
const expectedStatuses = (['zh-CN', 'en'] as const).map((locale) =>
getBotSettingsCopy(locale).onboarding.retrying('server', 1, 3),
);
await expect(status).toHaveAttribute('data-state', 'waiting');
await expect.poll(async () => expectedStatuses.includes(await status.innerText())).toBe(true);
await expect(status).not.toContainText('HTTP 503');
await expect(status).not.toContainText('provider detail');
await expect(page.locator('.settingsBotOnboardingQrFrame img')).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('bot-onboarding-retry-health.png') });
});
87 changes: 87 additions & 0 deletions apps/desktop/src/main/__tests__/bot-onboarding-main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,13 +484,99 @@ describe('BotOnboardingService', () => {
const afterFirst = await test.service.poll(started.sessionId);
assert.equal(afterFirst.state, 'waiting', 'a single transient blip must not kill the session');
assert.equal(attempts, 1);
assert.deepEqual(afterFirst.retryHealth, {
category: 'timeout',
consecutiveFailures: 1,
nextRetryAt: 13_000,
nextRetryAfterMs: 7_000,
});

let last = afterFirst;
for (let i = 0; i < 12 && last.state !== 'error'; i += 1) {
test.advance(60_000);
last = await test.service.poll(started.sessionId);
}
assert.equal(last.state, 'error', 'repeated consecutive transient failures must go terminal');
assert.equal(last.retryHealth, undefined, 'terminal sessions must not advertise another retry');
});

it('projects only a finite redacted category and clears retry health after recovery', async () => {
let attempts = 0;
const adapter: BotOnboardingProviderAdapter = {
async start() { return startResult(); },
async poll() {
attempts += 1;
if (attempts === 1) {
throw new Error('HTTP 503 https://provider.example/poll?token=super-secret credential=hidden');
}
return { status: 'pending' };
},
};
const test = harness(adapter);
const started = await test.service.start({ provider: 'dingtalk' });
test.advance(5_000);
const backingOff = await test.service.poll(started.sessionId);
assert.deepEqual(backingOff.retryHealth, {
category: 'server',
consecutiveFailures: 1,
nextRetryAt: 13_000,
nextRetryAfterMs: 7_000,
});
assert.equal(JSON.stringify(backingOff).includes('super-secret'), false);
assert.equal(JSON.stringify(backingOff).includes('provider.example'), false);

test.advance(7_000);
const recovered = await test.service.poll(started.sessionId);
assert.equal(recovered.state, 'waiting');
assert.equal(recovered.retryHealth, undefined);
});

it('clears retry health on provider terminal responses and cancellation', async () => {
let attempts = 0;
const adapter: BotOnboardingProviderAdapter = {
async start() { return startResult(); },
async poll() {
attempts += 1;
if (attempts === 1) throw new Error('HTTP 429 rate limited');
return { status: 'denied', error: 'Provider denied authorization' };
},
};
const test = harness(adapter);
const first = await test.service.start({ provider: 'dingtalk' });
test.advance(5_000);
const backingOff = await test.service.poll(first.sessionId);
assert.equal(backingOff.retryHealth?.category, 'rate_limited');
assert.equal(test.service.cancel(first.sessionId).retryHealth, undefined);

attempts = 0;
const second = await test.service.start({ provider: 'dingtalk' });
test.advance(5_000);
assert.equal((await test.service.poll(second.sessionId)).retryHealth?.category, 'rate_limited');
test.advance(7_000);
const denied = await test.service.poll(second.sessionId);
assert.equal(denied.state, 'denied');
assert.equal(denied.retryHealth, undefined);
assert.equal((await test.service.poll(second.sessionId)).retryHealth, undefined);
});

it('does not project a late transient failure after session supersession', async () => {
const pending = deferred<never>();
let starts = 0;
const adapter: BotOnboardingProviderAdapter = {
async start() { starts += 1; return startResult(); },
async poll() { return pending.promise; },
};
const test = harness(adapter);
const first = await test.service.start({ provider: 'dingtalk' });
test.advance(5_000);
const stalePoll = test.service.poll(first.sessionId);
await test.service.start({ provider: 'dingtalk' });
assert.equal(starts, 2);
pending.reject(new Error('network token=late-super-secret'));
const superseded = await stalePoll;
assert.equal(superseded.state, 'cancelled');
assert.equal(superseded.retryHealth, undefined);
assert.equal(JSON.stringify(superseded).includes('late-super-secret'), false);
});

it('fails immediately on a fatal (non-transient) poll error', async () => {
Expand All @@ -516,6 +602,7 @@ describe('BotOnboardingService', () => {
test.advance(1_001);
const expired = await test.service.poll(started.sessionId);
assert.equal(expired.state, 'expired');
assert.equal(expired.retryHealth, undefined);
assert.equal(polls, 0);
});

Expand Down
46 changes: 46 additions & 0 deletions apps/desktop/src/main/__tests__/bot-onboarding-status-copy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* 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 { strict as assert } from 'node:assert';
import { readFile } from 'node:fs/promises';
import { test } from 'node:test';
import { getBotSettingsCopy } from '../../renderer/locales/settings-bot-copy.js';

test('provides concise localized retry health without provider error text', () => {
const zh = getBotSettingsCopy('zh-CN');
const en = getBotSettingsCopy('en');
assert.equal(
zh.onboarding.retrying('network', 2, 7),
'网络暂时异常;连续失败 2 次,约 7 秒后自动重试。',
);
assert.equal(
en.onboarding.retrying('network', 2, 7),
'The network is temporarily unavailable; 2 consecutive failures. Retrying automatically in about 7s.',
);
});

test('the existing onboarding status surface prefers retry health while present', async () => {
const source = await readFile(
new URL('../../../src/renderer/settings/bot-onboarding-modal.tsx', import.meta.url),
'utf8',
);
assert.match(source, /if \(snapshot\?\.retryHealth\)/);
assert.match(source, /shared\.retrying\(/);
assert.match(source, /case 'waiting': return copy\.waiting/);
});
20 changes: 9 additions & 11 deletions apps/desktop/src/main/bot-onboarding-e2e-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,10 @@ const WAITING_HOLD_TTL_SECONDS = 60 * 60;
* They exercise the real main-owned session, IPC, persistence, runtime-effect,
* and renderer polling paths without contacting an external IM platform.
*
* Scenario-aware: the `settings-bots-onboarding` fixture needs the modal frozen
* in its 'waiting' state so the QR-onboarding capture is stable, so every
* provider holds a fixed QR + long TTL + never-confirming poll. All other
* scenarios keep the scanned → confirmed happy-path adapters the E2E
* onboarding specs rely on.
* Scenario-aware: the `settings-bots-onboarding` fixture holds a fixed QR and
* long TTL while deterministic HTTP 503 poll failures exercise the retry-health
* presentation. All other scenarios keep the scanned → confirmed happy-path
* adapters the E2E onboarding specs rely on.
*/
export function createE2eFixtureBotOnboardingAdapters(): AdapterMap {
if (process.env.MAKA_E2E_FIXTURE === 'settings-bots-onboarding') {
Expand Down Expand Up @@ -158,11 +157,10 @@ export function createE2eFixtureBotOnboardingAdapters(): AdapterMap {

/**
* #1233 deferral (settings-bots-onboarding): adapters that hold the modal in
* its 'waiting' state. Every value is FIXED (no Date.now / random) so the
* rendered QR image is byte-identical across runs, the TTL is long
* enough to outlast the fixture settle window, and `poll` never leaves
* 'pending' — so the main service keeps the session 'waiting' and the modal's
* waiting layout stays put for a deterministic fixture state.
* its waiting/backoff state. Every value is FIXED (no Date.now / random) so the
* rendered QR image is byte-identical across runs, the TTL outlasts the fixture
* settle window, and a finite HTTP 503 category exercises the renderer without
* leaking provider text.
*/
function createWaitingHoldBotOnboardingAdapters(): AdapterMap {
function waitingHold(provider: BotOnboardingProvider): BotOnboardingProviderAdapter {
Expand All @@ -177,7 +175,7 @@ function createWaitingHoldBotOnboardingAdapters(): AdapterMap {
};
},
async poll() {
return { status: 'pending' };
throw new Error('HTTP 503 e2e fixture provider detail must stay in main');
},
};
}
Expand Down
44 changes: 34 additions & 10 deletions apps/desktop/src/main/bot-onboarding-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type { BotChannelSettings } from '@maka/core/bot-chat-settings';
import type {
BotOnboardingBrand,
BotOnboardingProvider,
BotOnboardingRetryFailureCategory,
BotOnboardingSnapshot,
BotOnboardingStartInput,
BotOnboardingState,
Expand Down Expand Up @@ -99,6 +100,7 @@ interface BotOnboardingSession {
controller: AbortController;
pollPromise?: Promise<BotOnboardingSnapshot>;
pollFailures: number;
pollFailureCategory?: BotOnboardingRetryFailureCategory;
identity?: { id?: string; displayName?: string };
error?: string;
warning?: string;
Expand Down Expand Up @@ -206,6 +208,7 @@ export class BotOnboardingService {
}
if (session.expiresAt !== undefined && session.expiresAt <= this.now()) {
session.state = 'expired';
this.clearRetryHealth(session);
return this.snapshot(session);
}
if (session.nextPollAt > this.now()) return this.snapshot(session);
Expand Down Expand Up @@ -244,7 +247,7 @@ export class BotOnboardingService {
const result = await this.adapters[session.provider].poll(session, session.controller.signal);
this.assertCurrent(session);
// A response of any kind clears the transient-failure streak.
session.pollFailures = 0;
this.clearRetryHealth(session);
switch (result.status) {
case 'pending':
session.state = 'waiting';
Expand Down Expand Up @@ -294,14 +297,17 @@ export class BotOnboardingService {
// retry with backoff until enough CONSECUTIVE failures accumulate; only
// then surface a terminal error. A definite provider/protocol error is
// fatal immediately.
if (isTransientPollError(error)) {
const failureCategory = classifyTransientPollError(error);
if (failureCategory) {
session.pollFailures += 1;
if (session.pollFailures < MAX_CONSECUTIVE_POLL_FAILURES) {
session.pollIntervalMs = Math.min(session.pollIntervalMs + 2_000, MAX_POLL_INTERVAL_MS);
session.nextPollAt = this.now() + session.pollIntervalMs;
session.pollFailureCategory = failureCategory;
return this.snapshot(session);
}
}
this.clearRetryHealth(session);
session.state = 'error';
session.error = safeProviderError(error);
return this.snapshot(session);
Expand Down Expand Up @@ -421,6 +427,7 @@ export class BotOnboardingService {

private cancelSession(session: BotOnboardingSession): void {
if (!session.controller.signal.aborted) session.controller.abort();
this.clearRetryHealth(session);
if (session.state !== 'connected' && session.state !== 'expired' && session.state !== 'denied') {
session.state = 'cancelled';
}
Expand All @@ -438,6 +445,11 @@ export class BotOnboardingService {
if (!this.isCurrent(session)) throw new Error('Bot onboarding session is no longer active');
}

private clearRetryHealth(session: BotOnboardingSession): void {
session.pollFailures = 0;
session.pollFailureCategory = undefined;
}

private snapshot(session: BotOnboardingSession, includeQrCode = false): BotOnboardingSnapshot {
const state = session.state === 'starting' ? 'waiting' : session.state;
return {
Expand All @@ -448,6 +460,16 @@ export class BotOnboardingService {
...(includeQrCode && session.qrCodeDataUrl ? { qrCodeDataUrl: session.qrCodeDataUrl } : {}),
...(session.expiresAt !== undefined ? { expiresAt: session.expiresAt } : {}),
nextPollAfterMs: Math.max(0, session.nextPollAt - this.now()),
...(session.pollFailureCategory && session.pollFailures > 0
? {
retryHealth: {
category: session.pollFailureCategory,
consecutiveFailures: session.pollFailures,
nextRetryAt: session.nextPollAt,
nextRetryAfterMs: Math.max(0, session.nextPollAt - this.now()),
},
}
: {}),
canOpenInBrowser: Boolean(session.verificationUrl),
...(session.identity ? { identity: { ...session.identity } } : {}),
...(session.error ? { error: session.error } : {}),
Expand Down Expand Up @@ -491,19 +513,21 @@ function safeProviderError(error: unknown): string {
* fault, server 5xx, or 429 rate limit) versus a fatal provider/protocol error.
* User-initiated aborts are filtered out before this runs.
*/
function isTransientPollError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
if (error.name === 'TimeoutError' || error.name === 'AbortError') return true;
function classifyTransientPollError(error: unknown): BotOnboardingRetryFailureCategory | undefined {
if (!(error instanceof Error)) return undefined;
if (error.name === 'TimeoutError' || error.name === 'AbortError') return 'timeout';
const message = error.message.toLowerCase();
if (/fetch failed|network|socket|econn|enotfound|eai_again|und_err|timeout|timed out/.test(message)) {
return true;
}
if (/timeout|timed out/.test(message)) return 'timeout';
const httpMatch = message.match(/http (\d{3})/);
if (httpMatch) {
const status = Number(httpMatch[1]);
return status === 429 || status >= 500;
if (status === 429) return 'rate_limited';
if (status >= 500) return 'server';
}
if (/fetch failed|network|socket|econn|enotfound|eai_again|und_err/.test(message)) {
return 'network';
}
return false;
return undefined;
}

function channelPatchFromCredential(credential: OnboardingCredential): Partial<BotChannelSettings> {
Expand Down
Loading