feat(core): Local API 请求可配置最小间隔(E2E 动态超时)

This commit is contained in:
Robin
2026-04-15 13:13:22 +08:00
parent 4e57f7d15f
commit 1b74a8b7b0
2 changed files with 115 additions and 24 deletions
+66 -3
View File
@@ -1,7 +1,63 @@
import axios from 'axios';
import type { InternalAxiosRequestConfig } from 'axios';
import { PORT, API_KEY, CONFIG } from './config.js';
import { LOCAL_API_CONTRACTS } from './localApiContracts.js';
/** 若 >0任意两次经 `getApiClient()` 发往 Local API 的请求之间至少间隔该毫秒数(串行化,避免并发绕过)。 */
function readLocalApiMinIntervalMs(): number {
const raw = process.env.ADSP_LOCAL_API_MIN_INTERVAL_MS?.trim() ?? '';
if (!raw) {
return 0;
}
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
}
let localApiThrottleLock: Promise<void> = Promise.resolve();
let localApiLastRequestStartMs = 0;
async function sleep(ms: number): Promise<void> {
await new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
}
async function throttleLocalApiRequest(): Promise<void> {
const gapMs = readLocalApiMinIntervalMs();
if (gapMs <= 0) {
return;
}
const prev = localApiThrottleLock;
let release!: () => void;
localApiThrottleLock = new Promise<void>((resolve) => {
release = resolve;
});
await prev;
try {
const now = Date.now();
const earliest = localApiLastRequestStartMs === 0 ? now : localApiLastRequestStartMs + gapMs;
const waitMs = Math.max(0, earliest - now);
if (waitMs > 0) {
await sleep(waitMs);
}
localApiLastRequestStartMs = Date.now();
} finally {
release();
}
}
function isLocalApiRequestUrl(url: string | undefined): boolean {
if (!url) {
return false;
}
try {
const u = new URL(url, 'http://127.0.0.1');
return u.hostname === '127.0.0.1' || u.hostname === 'localhost';
} catch {
return url.includes('127.0.0.1') || url.includes('localhost');
}
}
export const LOCAL_API_BASE = `http://127.0.0.1:${PORT}`;
export const getLocalApiBase = () => {
@@ -48,7 +104,14 @@ export const apiClient = axios.create({
});
export const getApiClient = () => {
return axios.create({
headers: CONFIG.apiKey ? { 'Authorization': `Bearer ${CONFIG.apiKey}` } : {}
const client = axios.create({
headers: CONFIG.apiKey ? { 'Authorization': `Bearer ${CONFIG.apiKey}` } : {},
});
}
client.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
if (isLocalApiRequestUrl(config.url)) {
await throttleLocalApiRequest();
}
return config;
});
return client;
};
@@ -162,29 +162,57 @@ function isE2ERealApiEnabled(): boolean {
return process.env.ADSP_MCP_E2E_ENABLED === '1';
}
/** `ADSP_LOCAL_API_MIN_INTERVAL_MS` 开启时,两次 Local API 请求至少间隔该毫秒数,全量 case 会显著变长。 */
function realApiPerCaseTimeoutMs(): number {
const n = Number(process.env.ADSP_LOCAL_API_MIN_INTERVAL_MS ?? '0');
return n > 0 ? 120_000 : 30_000;
}
function realApiBrowserTimeoutMs(): number {
const n = Number(process.env.ADSP_LOCAL_API_MIN_INTERVAL_MS ?? '0');
return n > 0 ? 360_000 : 180_000;
}
function realApiRunAllTimeoutMs(): number {
const n = Number(process.env.ADSP_LOCAL_API_MIN_INTERVAL_MS ?? '0');
return n > 0 ? 900_000 : 180_000;
}
describe.skipIf(!isE2ERealApiEnabled())('e2e real Local API (Task 5)', () => {
beforeEach(() => {
resetOptionalCoverage();
});
it('create-group: group_name discoverable via get-group-list', async () => {
const result = await runOneCase('group.create.basic');
expect(result.passed).toBe(true);
expect(result.details.join('\n')).toMatch(/group_name=/);
});
it(
'create-group: group_name discoverable via get-group-list',
async () => {
const result = await runOneCase('group.create.basic');
expect(result.passed).toBe(true);
expect(result.details.join('\n')).toMatch(/group_name=/);
},
realApiPerCaseTimeoutMs(),
);
it('create-group: each optionalAll field has a passing case (remark)', async () => {
await runOneCase('group.create.withRemark');
const coverage = getParameterCoverage('create-group');
for (const param of coverage.optionalAll) {
expect(coverage.passedOptionalParams).toContain(param);
}
});
it(
'create-group: each optionalAll field has a passing case (remark)',
async () => {
await runOneCase('group.create.withRemark');
const coverage = getParameterCoverage('create-group');
for (const param of coverage.optionalAll) {
expect(coverage.passedOptionalParams).toContain(param);
}
},
realApiPerCaseTimeoutMs(),
);
it('proxy: create → list → delete', async () => {
const result = await runOneCase('proxy.create.list.delete');
expect(result.passed).toBe(true);
});
it(
'proxy: createlistdelete',
async () => {
const result = await runOneCase('proxy.create.list.delete');
expect(result.passed).toBe(true);
},
realApiPerCaseTimeoutMs(),
);
it(
'browser: create → open headless → close → delete',
@@ -192,7 +220,7 @@ describe.skipIf(!isE2ERealApiEnabled())('e2e real Local API (Task 5)', () => {
const result = await runOneCase('browser.open.headless');
expect(result.passed).toBe(true);
},
180_000,
realApiBrowserTimeoutMs(),
);
});
@@ -212,7 +240,7 @@ describe.skipIf(!isE2ERealApiEnabled())('e2e parameter report (Task 6)', () => {
status: expect.any(String),
});
},
180_000,
realApiRunAllTimeoutMs(),
);
it(
@@ -222,7 +250,7 @@ describe.skipIf(!isE2ERealApiEnabled())('e2e parameter report (Task 6)', () => {
const row = report.tools.find((t) => t.name === 'create-group');
expect(row?.missingOptionalParameters).toEqual([]);
},
180_000,
realApiRunAllTimeoutMs(),
);
});
@@ -238,7 +266,7 @@ describe.skipIf(!isE2ERealApiEnabled())('e2e coverage summary gates (Task 8)', (
expect(report.summary.casePassRate).toBeGreaterThanOrEqual(0.95);
expect(report.summary.parameterPassRate).toBeGreaterThanOrEqual(0.95);
},
180_000,
realApiRunAllTimeoutMs(),
);
it(
@@ -254,6 +282,6 @@ describe.skipIf(!isE2ERealApiEnabled())('e2e coverage summary gates (Task 8)', (
expect(report.summary.optionalAllCoverage).toBeLessThanOrEqual(1);
expect(report.summary.totalTools).toBe(report.tools.length);
},
180_000,
realApiRunAllTimeoutMs(),
);
});