mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
* fix(test): retire MCP managers between shared test files Drain the current manager before resetting file-scoped modules, preserve replacement and uncertain custody, and carry earlier disposal failures into later global cleanup scopes. Keep the existing best-effort disposal contract. * fix(test): settle MCP clients before closing HTTP fixtures Keep intentional failed retirement on a fixture-owned real manager, drain shared clients before server closure, and join fixture shutdown. Preserve all failure assertions and custody guards. Extract the three HTTP lifecycle cases without changing their registration order. Final runtime owner suite passes all 114 cases with clean teardown. * fix(test): resolve deferred helper under raw Node Import the canonical dependency-free deferred implementation by its real TypeScript path so standalone shutdown fixtures do not rely on Vitest aliases. The exact preimage reproduces module-not-found; all 21 raw shutdown cases, both shared-runner cases, and affected static checks pass on macOS Node 26.10. * test(ui): wait for history before asserting effort controls Model browsing can become ready while chat history is still hydrating. Bind the existing partial-refresh fixture to the successful history response and current rendered session before checking effort controls, preserving the immediate enabled-state assertion.
110 lines
4.2 KiB
TypeScript
110 lines
4.2 KiB
TypeScript
// Shared workers reset modules between files, orphaning skills watchers a file left open.
|
|
// Their re-armed timers then land on a later file's fake clock and abort its
|
|
// vi.runAllTimersAsync(). Remember each real watcher generation so the runner can close
|
|
// leftovers after the file and fail the file that leaked them.
|
|
import path from "node:path";
|
|
import {
|
|
normalizeModuleId,
|
|
type EvaluatedModuleNode,
|
|
type EvaluatedModules,
|
|
} from "vite/module-runner";
|
|
import { vi } from "vitest";
|
|
|
|
const source = (name: string) => normalizeModuleId(path.resolve(import.meta.dirname, "..", name));
|
|
const refreshSource = source("src/skills/runtime/refresh.ts");
|
|
const registrySource = source("src/skills/runtime/refresh-watch-registry.ts");
|
|
|
|
type RefreshModule = typeof import("../src/skills/runtime/refresh.js");
|
|
type SkillsWatchRegistry = Pick<
|
|
typeof import("../src/skills/runtime/refresh-watch-registry.js"),
|
|
"pathWatchers" | "workspaceWatchOwners"
|
|
>;
|
|
|
|
type SkillsWatcherTestLifecycle = {
|
|
generations: Map<RefreshModule["closeSkillsWatchers"], SkillsWatchRegistry>;
|
|
beforeModuleReset: (() => void) | undefined;
|
|
};
|
|
|
|
const SKILLS_WATCHER_TEST_LIFECYCLE = Symbol.for("openclaw.skillsWatcherTestLifecycle");
|
|
const lifecycleStore = globalThis as typeof globalThis & {
|
|
[SKILLS_WATCHER_TEST_LIFECYCLE]?: SkillsWatcherTestLifecycle;
|
|
};
|
|
|
|
function createLifecycle(): SkillsWatcherTestLifecycle {
|
|
const state: SkillsWatcherTestLifecycle = {
|
|
generations: new Map(),
|
|
beforeModuleReset: undefined,
|
|
};
|
|
const nativeResetModules = vi.resetModules;
|
|
vi.resetModules = () => {
|
|
state.beforeModuleReset?.();
|
|
return nativeResetModules();
|
|
};
|
|
return state;
|
|
}
|
|
|
|
// Runner/helper re-evaluation must share custody and preserve the installed reset hook.
|
|
const lifecycle = (lifecycleStore[SKILLS_WATCHER_TEST_LIFECYCLE] ??= createLifecycle());
|
|
|
|
export function setSkillsWatcherCaptureBeforeReset(capture: (() => void) | undefined): void {
|
|
lifecycle.beforeModuleReset = capture;
|
|
}
|
|
|
|
function realExports(
|
|
node: EvaluatedModuleNode | undefined,
|
|
executions: ReadonlyMap<string, { external?: boolean }>,
|
|
): unknown {
|
|
const execution =
|
|
node && executions.get(node.id.startsWith("mock:") ? node.id.slice(5) : node.id);
|
|
return execution && !execution.external ? node.exports : undefined;
|
|
}
|
|
|
|
export function rememberSkillsWatcherGenerations(
|
|
modules: Pick<EvaluatedModules, "fileToModulesMap" | "idToModuleMap">,
|
|
executions: ReadonlyMap<string, { external?: boolean }>,
|
|
): void {
|
|
for (const node of modules.fileToModulesMap.get(refreshSource) ?? []) {
|
|
const close = (realExports(node, executions) as Partial<RefreshModule> | undefined)
|
|
?.closeSkillsWatchers;
|
|
if (typeof close !== "function" || vi.isMockFunction(close)) {
|
|
continue;
|
|
}
|
|
// Same-file instances (query or importActual variants) can coexist; pair each closer
|
|
// with the registry instance its own evaluation imported.
|
|
for (const id of node.imports) {
|
|
const dependency = modules.idToModuleMap.get(id);
|
|
if (dependency?.file !== registrySource) {
|
|
continue;
|
|
}
|
|
const registry = realExports(dependency, executions) as
|
|
| Partial<SkillsWatchRegistry>
|
|
| undefined;
|
|
const { pathWatchers, workspaceWatchOwners } = registry ?? {};
|
|
if (pathWatchers instanceof Map && workspaceWatchOwners instanceof Map) {
|
|
lifecycle.generations.set(close, { pathWatchers, workspaceWatchOwners });
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Closes every remembered generation's open watchers and returns their live entry count. */
|
|
export async function closeLeakedSkillsWatchers(): Promise<number> {
|
|
const remembered = [...lifecycle.generations];
|
|
lifecycle.generations.clear();
|
|
let leaked = 0;
|
|
const failures: unknown[] = [];
|
|
for (const [close, registry] of remembered) {
|
|
// Retiring watchers are already aborted; owners and path watchers can still re-arm.
|
|
const live = registry.workspaceWatchOwners.size + registry.pathWatchers.size;
|
|
if (live > 0) {
|
|
leaked += live;
|
|
// One failed shutdown must not leave the remaining generations open.
|
|
await close(true).catch((error: unknown) => failures.push(error));
|
|
}
|
|
}
|
|
if (failures.length > 0) {
|
|
throw new AggregateError(failures, `Closing ${leaked} leaked skills watch entries failed`);
|
|
}
|
|
return leaked;
|
|
}
|