feat: 增加 e2e 用例执行器与清理保障

This commit is contained in:
Robin
2026-04-15 11:38:14 +08:00
parent 3472014766
commit 5c5fcd0bf0
5 changed files with 121 additions and 1 deletions
@@ -0,0 +1,22 @@
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
/**
* Poll until `fn` returns a truthy value or attempts are exhausted (final-consistency window).
*/
export async function pollUntil<T>(
fn: () => Promise<T | undefined | null | false>,
options: { maxAttempts: number; delayMs: number },
): Promise<T | undefined> {
for (let i = 0; i < options.maxAttempts; i++) {
const value = await fn();
if (value !== undefined && value !== null && value !== false) {
return value as T;
}
await sleep(options.delayMs);
}
return undefined;
}
@@ -1,7 +1,8 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { readE2EEnv } from './config/env';
import { getOptionalFieldsFromSchema } from './config/schemaIntrospector';
import { toolMatrix } from './config/toolMatrix';
import { runCase } from './runner/caseRunner';
import { createMcpClient } from './runner/mcpClient';
describe('readE2EEnv', () => {
@@ -46,3 +47,37 @@ describe('toolMatrix', () => {
}
});
});
describe('runCase cleanup', () => {
it('always runs cleanup after assert', async () => {
const cleanup = vi.fn(async () => {});
await runCase({
name: 'x',
prepare: async () => ({}),
invoke: async () => ({}),
assertState: async () => ({ passed: true, details: [] }),
cleanup,
});
expect(cleanup).toHaveBeenCalledTimes(1);
});
it('runs cleanup when assertState throws', async () => {
const cleanup = vi.fn(async () => {});
await expect(
runCase({
name: 'fail-assert',
prepare: async () => ({ marker: 1 }),
invoke: async () => ({}),
assertState: async () => {
throw new Error('assert boom');
},
cleanup,
}),
).rejects.toThrow(/assert boom/);
expect(cleanup).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,9 @@
import type { ResourceTracker } from './resourceTracker.js';
/**
* Global/suite cleanup hook. Domain-specific delete logic is wired in later tasks.
* Call at end of suite to attempt teardown of everything still in `tracker`.
*/
export async function runGlobalCleanup(_tracker: ResourceTracker): Promise<void> {
// Intentionally empty until Task 5+ registers per-kind cleanup.
}
@@ -0,0 +1,29 @@
/**
* Tracks created resource IDs per kind for suite teardown (Task 5+ will register handlers).
*/
export class ResourceTracker {
private readonly byKind = new Map<string, Set<string>>();
track(kind: string, id: string): void {
if (!this.byKind.has(kind)) {
this.byKind.set(kind, new Set());
}
this.byKind.get(kind)!.add(id);
}
untrack(kind: string, id: string): void {
this.byKind.get(kind)?.delete(id);
}
idsFor(kind: string): string[] {
return [...(this.byKind.get(kind) ?? [])];
}
kinds(): string[] {
return [...this.byKind.keys()];
}
clear(): void {
this.byKind.clear();
}
}
@@ -0,0 +1,25 @@
export interface RunCaseDefinition {
name: string;
prepare: () => Promise<Record<string, unknown>>;
invoke: (ctx: Record<string, unknown>) => Promise<unknown>;
assertState: (
result: unknown,
ctx: Record<string, unknown>,
) => Promise<{ passed: boolean; details: string[] }>;
cleanup: (ctx: Record<string, unknown>) => Promise<void>;
}
/**
* Single case pipeline: prepare → invoke → assert → cleanup (cleanup always runs).
*/
export async function runCase(
def: RunCaseDefinition,
): Promise<{ passed: boolean; details: string[] }> {
const ctx = await def.prepare();
try {
const invokeResult = await def.invoke(ctx);
return await def.assertState(invokeResult, ctx);
} finally {
await def.cleanup(ctx);
}
}