Stabilize gateway reload/restart behavior and remove doctor --json dependency (#504)
This commit is contained in:
committed by
GitHub
Unverified
parent
89bda3c7af
commit
7f3408559d
@@ -102,3 +102,38 @@ describe('channel credential normalization and duplicate checks', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDoctorValidationOutput', () => {
|
||||
it('extracts channel error and warning lines', async () => {
|
||||
const { parseDoctorValidationOutput } = await import('@electron/utils/channel-config');
|
||||
|
||||
const out = parseDoctorValidationOutput(
|
||||
'feishu',
|
||||
'feishu error: token invalid\nfeishu warning: fallback enabled\n',
|
||||
);
|
||||
|
||||
expect(out.undetermined).toBe(false);
|
||||
expect(out.errors).toEqual(['feishu error: token invalid']);
|
||||
expect(out.warnings).toEqual(['feishu warning: fallback enabled']);
|
||||
});
|
||||
|
||||
it('falls back with hint when output has no channel signal', async () => {
|
||||
const { parseDoctorValidationOutput } = await import('@electron/utils/channel-config');
|
||||
|
||||
const out = parseDoctorValidationOutput('feishu', 'all good, no channel details');
|
||||
|
||||
expect(out.undetermined).toBe(true);
|
||||
expect(out.errors).toEqual([]);
|
||||
expect(out.warnings.some((w) => w.includes('falling back to local channel config checks'))).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back with hint when output is empty', async () => {
|
||||
const { parseDoctorValidationOutput } = await import('@electron/utils/channel-config');
|
||||
|
||||
const out = parseDoctorValidationOutput('feishu', ' ');
|
||||
|
||||
expect(out.undetermined).toBe(true);
|
||||
expect(out.errors).toEqual([]);
|
||||
expect(out.warnings.some((w) => w.includes('falling back to local channel config checks'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
101
tests/unit/gateway-manager-reload-policy-refresh.test.ts
Normal file
101
tests/unit/gateway-manager-reload-policy-refresh.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { mockLoadGatewayReloadPolicy } = vi.hoisted(() => ({
|
||||
mockLoadGatewayReloadPolicy: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => '/tmp',
|
||||
isPackaged: false,
|
||||
},
|
||||
utilityProcess: {
|
||||
fork: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@electron/gateway/reload-policy', async () => {
|
||||
const actual = await vi.importActual<typeof import('@electron/gateway/reload-policy')>(
|
||||
'@electron/gateway/reload-policy',
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
loadGatewayReloadPolicy: (...args: unknown[]) => mockLoadGatewayReloadPolicy(...args),
|
||||
};
|
||||
});
|
||||
|
||||
describe('GatewayManager refreshReloadPolicy', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-03-15T00:00:00.000Z'));
|
||||
});
|
||||
|
||||
it('deduplicates concurrent refresh calls', async () => {
|
||||
const { GatewayManager } = await import('@electron/gateway/manager');
|
||||
let resolveLoad: ((value: { mode: 'reload'; debounceMs: number }) => void) | null = null;
|
||||
mockLoadGatewayReloadPolicy.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveLoad = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const manager = new GatewayManager();
|
||||
const refresh = (manager as unknown as { refreshReloadPolicy: (force?: boolean) => Promise<void> })
|
||||
.refreshReloadPolicy.bind(manager);
|
||||
|
||||
const p1 = refresh(true);
|
||||
const p2 = refresh(true);
|
||||
|
||||
expect(mockLoadGatewayReloadPolicy).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveLoad?.({ mode: 'reload', debounceMs: 1300 });
|
||||
await Promise.all([p1, p2]);
|
||||
|
||||
expect((manager as unknown as { reloadPolicy: { mode: string; debounceMs: number } }).reloadPolicy).toEqual({
|
||||
mode: 'reload',
|
||||
debounceMs: 1300,
|
||||
});
|
||||
});
|
||||
|
||||
it('hits TTL cache and skips refresh within window', async () => {
|
||||
const { GatewayManager } = await import('@electron/gateway/manager');
|
||||
mockLoadGatewayReloadPolicy.mockResolvedValueOnce({ mode: 'restart', debounceMs: 2200 });
|
||||
|
||||
const manager = new GatewayManager();
|
||||
const refresh = (manager as unknown as { refreshReloadPolicy: (force?: boolean) => Promise<void> })
|
||||
.refreshReloadPolicy.bind(manager);
|
||||
|
||||
await refresh();
|
||||
expect(mockLoadGatewayReloadPolicy).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.setSystemTime(new Date('2026-03-15T00:00:10.000Z'));
|
||||
await refresh();
|
||||
|
||||
expect(mockLoadGatewayReloadPolicy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('refreshes immediately when force=true even within TTL', async () => {
|
||||
const { GatewayManager } = await import('@electron/gateway/manager');
|
||||
mockLoadGatewayReloadPolicy
|
||||
.mockResolvedValueOnce({ mode: 'hybrid', debounceMs: 1200 })
|
||||
.mockResolvedValueOnce({ mode: 'off', debounceMs: 9000 });
|
||||
|
||||
const manager = new GatewayManager();
|
||||
const refresh = (manager as unknown as { refreshReloadPolicy: (force?: boolean) => Promise<void> })
|
||||
.refreshReloadPolicy.bind(manager);
|
||||
|
||||
await refresh();
|
||||
expect(mockLoadGatewayReloadPolicy).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.setSystemTime(new Date('2026-03-15T00:00:05.000Z'));
|
||||
await refresh(true);
|
||||
|
||||
expect(mockLoadGatewayReloadPolicy).toHaveBeenCalledTimes(2);
|
||||
expect((manager as unknown as { reloadPolicy: { mode: string; debounceMs: number } }).reloadPolicy).toEqual({
|
||||
mode: 'off',
|
||||
debounceMs: 9000,
|
||||
});
|
||||
});
|
||||
});
|
||||
150
tests/unit/gateway-reload-policy.test.ts
Normal file
150
tests/unit/gateway-reload-policy.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { mockReadFile } = vi.hoisted(() => ({
|
||||
mockReadFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
readFile: mockReadFile,
|
||||
default: {
|
||||
readFile: mockReadFile,
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
DEFAULT_GATEWAY_RELOAD_POLICY,
|
||||
loadGatewayReloadPolicy,
|
||||
parseGatewayReloadPolicy,
|
||||
} from '@electron/gateway/reload-policy';
|
||||
|
||||
describe('parseGatewayReloadPolicy', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns defaults when config is missing', () => {
|
||||
expect(parseGatewayReloadPolicy(undefined)).toEqual(DEFAULT_GATEWAY_RELOAD_POLICY);
|
||||
});
|
||||
|
||||
it('parses mode and debounce from gateway.reload', () => {
|
||||
const result = parseGatewayReloadPolicy({
|
||||
gateway: {
|
||||
reload: {
|
||||
mode: 'off',
|
||||
debounceMs: 3000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ mode: 'off', debounceMs: 3000 });
|
||||
});
|
||||
|
||||
it('normalizes invalid mode and debounce bounds', () => {
|
||||
const negative = parseGatewayReloadPolicy({
|
||||
gateway: { reload: { mode: 'invalid', debounceMs: -100 } },
|
||||
});
|
||||
expect(negative).toEqual({
|
||||
mode: DEFAULT_GATEWAY_RELOAD_POLICY.mode,
|
||||
debounceMs: 0,
|
||||
});
|
||||
|
||||
const overMax = parseGatewayReloadPolicy({
|
||||
gateway: { reload: { mode: 'hybrid', debounceMs: 600_000 } },
|
||||
});
|
||||
expect(overMax).toEqual({ mode: 'hybrid', debounceMs: 60_000 });
|
||||
});
|
||||
|
||||
it('falls back to default mode for non-string or unknown mode values', () => {
|
||||
const unknownString = parseGatewayReloadPolicy({
|
||||
gateway: { reload: { mode: 'HYBRID', debounceMs: 1200 } },
|
||||
});
|
||||
expect(unknownString.mode).toBe(DEFAULT_GATEWAY_RELOAD_POLICY.mode);
|
||||
|
||||
const nonString = parseGatewayReloadPolicy({
|
||||
gateway: { reload: { mode: { value: 'reload' }, debounceMs: 1200 } },
|
||||
});
|
||||
expect(nonString.mode).toBe(DEFAULT_GATEWAY_RELOAD_POLICY.mode);
|
||||
});
|
||||
|
||||
it('handles malformed gateway/reload shapes', () => {
|
||||
const malformedGateway = parseGatewayReloadPolicy({ gateway: 'bad-shape' });
|
||||
expect(malformedGateway).toEqual(DEFAULT_GATEWAY_RELOAD_POLICY);
|
||||
|
||||
const malformedReload = parseGatewayReloadPolicy({
|
||||
gateway: { reload: ['bad-shape'] },
|
||||
});
|
||||
expect(malformedReload).toEqual(DEFAULT_GATEWAY_RELOAD_POLICY);
|
||||
});
|
||||
|
||||
it('normalizes debounce boundary and rounding behavior', () => {
|
||||
const atMin = parseGatewayReloadPolicy({
|
||||
gateway: { reload: { mode: 'reload', debounceMs: 0 } },
|
||||
});
|
||||
expect(atMin).toEqual({ mode: 'reload', debounceMs: 0 });
|
||||
|
||||
const roundsUpToCap = parseGatewayReloadPolicy({
|
||||
gateway: { reload: { mode: 'reload', debounceMs: 60_000.5 } },
|
||||
});
|
||||
expect(roundsUpToCap).toEqual({ mode: 'reload', debounceMs: 60_000 });
|
||||
|
||||
const roundsDownAtCap = parseGatewayReloadPolicy({
|
||||
gateway: { reload: { mode: 'reload', debounceMs: 60_000.4 } },
|
||||
});
|
||||
expect(roundsDownAtCap).toEqual({ mode: 'reload', debounceMs: 60_000 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadGatewayReloadPolicy', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns defaults when config read fails', async () => {
|
||||
mockReadFile.mockRejectedValueOnce(new Error('EACCES'));
|
||||
|
||||
await expect(loadGatewayReloadPolicy()).resolves.toEqual(DEFAULT_GATEWAY_RELOAD_POLICY);
|
||||
expect(mockReadFile).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('returns defaults when config JSON is malformed', async () => {
|
||||
mockReadFile.mockResolvedValueOnce('{');
|
||||
|
||||
await expect(loadGatewayReloadPolicy()).resolves.toEqual(DEFAULT_GATEWAY_RELOAD_POLICY);
|
||||
});
|
||||
|
||||
it('returns defaults when config JSON has malformed shape', async () => {
|
||||
mockReadFile.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
gateway: { reload: ['malformed'] },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(loadGatewayReloadPolicy()).resolves.toEqual(DEFAULT_GATEWAY_RELOAD_POLICY);
|
||||
});
|
||||
|
||||
it('loads config and applies invalid mode fallback', async () => {
|
||||
mockReadFile.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
gateway: { reload: { mode: 'unknown-mode', debounceMs: 1350 } },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(loadGatewayReloadPolicy()).resolves.toEqual({
|
||||
mode: DEFAULT_GATEWAY_RELOAD_POLICY.mode,
|
||||
debounceMs: 1350,
|
||||
});
|
||||
});
|
||||
|
||||
it('loads config and keeps debounce boundary values', async () => {
|
||||
mockReadFile.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
gateway: { reload: { mode: 'restart', debounceMs: 60_000 } },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(loadGatewayReloadPolicy()).resolves.toEqual({
|
||||
mode: 'restart',
|
||||
debounceMs: 60_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
106
tests/unit/gateway-restart-governor.test.ts
Normal file
106
tests/unit/gateway-restart-governor.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { GatewayRestartGovernor } from '@electron/gateway/restart-governor';
|
||||
|
||||
describe('GatewayRestartGovernor', () => {
|
||||
it('suppresses restart during exponential cooldown window', () => {
|
||||
const governor = new GatewayRestartGovernor({
|
||||
baseCooldownMs: 1000,
|
||||
maxCooldownMs: 8000,
|
||||
maxRestartsPerWindow: 10,
|
||||
windowMs: 60000,
|
||||
stableResetMs: 60000,
|
||||
circuitOpenMs: 60000,
|
||||
});
|
||||
|
||||
expect(governor.decide(1000).allow).toBe(true);
|
||||
governor.recordExecuted(1000);
|
||||
|
||||
const blocked = governor.decide(1500);
|
||||
expect(blocked.allow).toBe(false);
|
||||
expect(blocked.allow ? '' : blocked.reason).toBe('cooldown_active');
|
||||
expect(blocked.allow ? 0 : blocked.retryAfterMs).toBeGreaterThan(0);
|
||||
|
||||
expect(governor.decide(3000).allow).toBe(true);
|
||||
});
|
||||
|
||||
it('opens circuit after restart budget is exceeded', () => {
|
||||
const governor = new GatewayRestartGovernor({
|
||||
maxRestartsPerWindow: 2,
|
||||
windowMs: 60000,
|
||||
baseCooldownMs: 0,
|
||||
maxCooldownMs: 0,
|
||||
stableResetMs: 120000,
|
||||
circuitOpenMs: 30000,
|
||||
});
|
||||
|
||||
expect(governor.decide(1000).allow).toBe(true);
|
||||
governor.recordExecuted(1000);
|
||||
expect(governor.decide(2000).allow).toBe(true);
|
||||
governor.recordExecuted(2000);
|
||||
|
||||
const budgetBlocked = governor.decide(3000);
|
||||
expect(budgetBlocked.allow).toBe(false);
|
||||
expect(budgetBlocked.allow ? '' : budgetBlocked.reason).toBe('budget_exceeded');
|
||||
|
||||
const circuitBlocked = governor.decide(4000);
|
||||
expect(circuitBlocked.allow).toBe(false);
|
||||
expect(circuitBlocked.allow ? '' : circuitBlocked.reason).toBe('circuit_open');
|
||||
|
||||
expect(governor.decide(62001).allow).toBe(true);
|
||||
});
|
||||
|
||||
it('resets consecutive backoff after stable running period', () => {
|
||||
const governor = new GatewayRestartGovernor({
|
||||
baseCooldownMs: 1000,
|
||||
maxCooldownMs: 8000,
|
||||
maxRestartsPerWindow: 10,
|
||||
windowMs: 600000,
|
||||
stableResetMs: 5000,
|
||||
circuitOpenMs: 60000,
|
||||
});
|
||||
|
||||
governor.recordExecuted(0);
|
||||
governor.recordExecuted(1000);
|
||||
const blockedBeforeStable = governor.decide(2500);
|
||||
expect(blockedBeforeStable.allow).toBe(false);
|
||||
expect(blockedBeforeStable.allow ? '' : blockedBeforeStable.reason).toBe('cooldown_active');
|
||||
|
||||
governor.onRunning(3000);
|
||||
const allowedAfterStable = governor.decide(9000);
|
||||
expect(allowedAfterStable.allow).toBe(true);
|
||||
});
|
||||
|
||||
it('resets time-based state when clock moves backwards', () => {
|
||||
const governor = new GatewayRestartGovernor({
|
||||
maxRestartsPerWindow: 2,
|
||||
windowMs: 60000,
|
||||
baseCooldownMs: 1000,
|
||||
maxCooldownMs: 8000,
|
||||
stableResetMs: 60000,
|
||||
circuitOpenMs: 30000,
|
||||
});
|
||||
|
||||
governor.recordExecuted(10_000);
|
||||
governor.recordExecuted(11_000);
|
||||
const blocked = governor.decide(11_500);
|
||||
expect(blocked.allow).toBe(false);
|
||||
|
||||
// Simulate clock rewind and verify stale guard state does not lock out restarts.
|
||||
const afterRewind = governor.decide(9_000);
|
||||
expect(afterRewind.allow).toBe(true);
|
||||
});
|
||||
|
||||
it('wraps counters safely at MAX_SAFE_INTEGER', () => {
|
||||
const governor = new GatewayRestartGovernor();
|
||||
(governor as unknown as { executedTotal: number; suppressedTotal: number }).executedTotal = Number.MAX_SAFE_INTEGER;
|
||||
(governor as unknown as { executedTotal: number; suppressedTotal: number }).suppressedTotal = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
governor.recordExecuted(1000);
|
||||
governor.decide(1000);
|
||||
|
||||
expect(governor.getCounters()).toEqual({
|
||||
executedTotal: 0,
|
||||
suppressedTotal: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -155,4 +155,23 @@ describe('openclaw doctor output handling', () => {
|
||||
expect(result.stdout).toBe('line-1\nline-2\n');
|
||||
expect(result.stderr).toBe('warn-1\nwarn-2\n');
|
||||
});
|
||||
|
||||
it('runs plain doctor command without --json', async () => {
|
||||
const child = new MockUtilityChild();
|
||||
mockFork.mockReturnValue(child);
|
||||
|
||||
const { runOpenClawDoctor } = await import('@electron/utils/openclaw-doctor');
|
||||
const resultPromise = runOpenClawDoctor();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockFork).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
child.stdout.emit('data', Buffer.from('doctor ok\n'));
|
||||
child.emit('exit', 0);
|
||||
|
||||
const result = await resultPromise;
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('openclaw doctor');
|
||||
expect(mockFork.mock.calls[0][1]).toEqual(['doctor']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user