feat(FN-2681): add Vitest kill controls in system stats

- Add dashboard API endpoint and server route to terminate Vitest worker processes safely
- Extend legacy API client with killVitestProcess support for frontend invocation
- Add SystemStatsModal UI controls and styling for triggering Vitest kill actions, including mobile width stability fixes
- Expand route and modal tests to cover new controls and behavior
- Add changeset and architecture docs updates for the new Vitest kill capability
This commit is contained in:
Fusion
2026-04-27 05:50:40 -07:00
committed by gsxdsm
parent fce1755d2b
commit ec092825ac
8 changed files with 506 additions and 9 deletions

View File

@@ -41,9 +41,10 @@ const mockCentralListProjects = vi.fn().mockResolvedValue([]);
const mockCentralInit = vi.fn().mockResolvedValue(undefined);
const mockCentralClose = vi.fn().mockResolvedValue(undefined);
const mockCentralReconcileProjectStatuses = vi.fn().mockResolvedValue(undefined);
const { mockPerformUpdateCheck, mockClearUpdateCheckCache } = vi.hoisted(() => ({
const { mockPerformUpdateCheck, mockClearUpdateCheckCache, mockExecSync } = vi.hoisted(() => ({
mockPerformUpdateCheck: vi.fn(),
mockClearUpdateCheckCache: vi.fn(),
mockExecSync: vi.fn(),
}));
vi.mock("../update-check.js", async () => {
@@ -55,6 +56,15 @@ vi.mock("../update-check.js", async () => {
};
});
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
mockExecSync.mockImplementation(((...args: Parameters<typeof actual.execSync>) => actual.execSync(...args)) as typeof actual.execSync);
return {
...actual,
execSync: mockExecSync,
};
});
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
return {
@@ -290,6 +300,8 @@ describe("GET /api/system-stats", () => {
getFusionDir: vi.fn().mockReturnValue("/fake/default"),
});
mockExecSync.mockReturnValue(`${process.pid}\n111\n222\n` as never);
vi.spyOn(AgentStore.prototype, "init").mockResolvedValue(undefined);
vi.spyOn(AgentStore.prototype, "listAgents").mockResolvedValue([
{ id: "agent-1", state: "idle" },
@@ -337,6 +349,27 @@ describe("GET /api/system-stats", () => {
error: 1,
},
});
expect(res.body.vitestProcessCount).toBe(2);
expect(res.body.vitestLastAutoKillAt).toBeNull();
mockExecSync.mockReset();
});
it("includes last auto-kill timestamp when available in global settings", async () => {
const store = createMockStore({
listTasks: vi.fn().mockResolvedValue([]),
getFusionDir: vi.fn().mockReturnValue("/fake/default"),
getGlobalSettingsStore: vi.fn().mockReturnValue({
getSettings: vi.fn().mockResolvedValue({ vitestLastAutoKillAt: "2026-04-27T12:00:00.000Z" }),
}),
});
vi.spyOn(AgentStore.prototype, "init").mockResolvedValue(undefined);
vi.spyOn(AgentStore.prototype, "listAgents").mockResolvedValue([]);
const res = await GET(buildApp(store), "/api/system-stats");
expect(res.status).toBe(200);
expect(res.body.vitestLastAutoKillAt).toBe("2026-04-27T12:00:00.000Z");
});
it("uses project-scoped store when projectId query param is provided", async () => {
@@ -363,6 +396,56 @@ describe("GET /api/system-stats", () => {
});
});
describe("POST /api/kill-vitest", () => {
function buildApp(store: TaskStore) {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns killed: 0 when no vitest processes are found", async () => {
const store = createMockStore();
mockExecSync.mockReturnValue("" as never);
const res = await REQUEST(buildApp(store), "POST", "/api/kill-vitest");
expect(res.status).toBe(200);
expect(res.body).toEqual({ killed: 0, pids: [] });
mockExecSync.mockReset();
});
it("kills all matched vitest pids except the current dashboard process", async () => {
const store = createMockStore();
mockExecSync.mockReturnValue(`${process.pid}\n1001\n1002\nnot-a-pid\n` as never);
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
const res = await REQUEST(buildApp(store), "POST", "/api/kill-vitest");
expect(res.status).toBe(200);
expect(killSpy).toHaveBeenCalledTimes(2);
expect(killSpy).toHaveBeenNthCalledWith(1, 1001, "SIGKILL");
expect(killSpy).toHaveBeenNthCalledWith(2, 1002, "SIGKILL");
expect(res.body).toEqual({ killed: 2, pids: [1001, 1002] });
killSpy.mockRestore();
mockExecSync.mockReset();
});
it("returns killed: 0 when pgrep exits with no matches", async () => {
const store = createMockStore();
mockExecSync.mockImplementation(() => {
throw new Error("pgrep exited 1");
});
const res = await REQUEST(buildApp(store), "POST", "/api/kill-vitest");
expect(res.status).toBe(200);
expect(res.body).toEqual({ killed: 0, pids: [] });
mockExecSync.mockReset();
});
});
describe("GET /api/plugins/runtimes", () => {
function buildApp(pluginLoader?: { getPluginRuntimes?: () => Array<{ pluginId: string; runtime: { metadata: { runtimeId: string; name: string; description?: string; version?: string }; factory: () => unknown } }> }) {
const app = express();