mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
test: dedupe registry/session tests and add install source coverage
This commit is contained in:
@@ -33,6 +33,12 @@ function getPersistedMessages(sm: SessionManager): AgentMessage[] {
|
||||
.map((e) => (e as { message: AgentMessage }).message);
|
||||
}
|
||||
|
||||
function expectPersistedRoles(sm: SessionManager, expectedRoles: AgentMessage["role"][]) {
|
||||
const messages = getPersistedMessages(sm);
|
||||
expect(messages.map((message) => message.role)).toEqual(expectedRoles);
|
||||
return messages;
|
||||
}
|
||||
|
||||
function getToolResultText(messages: AgentMessage[]): string {
|
||||
const toolResult = messages.find((m) => m.role === "toolResult") as {
|
||||
content: Array<{ type: string; text: string }>;
|
||||
@@ -58,13 +64,8 @@ describe("installSessionToolResultGuard", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const entries = sm
|
||||
.getEntries()
|
||||
.filter((e) => e.type === "message")
|
||||
.map((e) => (e as { message: AgentMessage }).message);
|
||||
|
||||
expect(entries.map((m) => m.role)).toEqual(["assistant", "toolResult", "assistant"]);
|
||||
const synthetic = entries[1] as {
|
||||
const messages = expectPersistedRoles(sm, ["assistant", "toolResult", "assistant"]);
|
||||
const synthetic = messages[1] as {
|
||||
toolCallId?: string;
|
||||
isError?: boolean;
|
||||
content?: Array<{ type?: string; text?: string }>;
|
||||
@@ -81,12 +82,7 @@ describe("installSessionToolResultGuard", () => {
|
||||
sm.appendMessage(toolCallMessage);
|
||||
guard.flushPendingToolResults();
|
||||
|
||||
const messages = sm
|
||||
.getEntries()
|
||||
.filter((e) => e.type === "message")
|
||||
.map((e) => (e as { message: AgentMessage }).message);
|
||||
|
||||
expect(messages.map((m) => m.role)).toEqual(["assistant", "toolResult"]);
|
||||
expectPersistedRoles(sm, ["assistant", "toolResult"]);
|
||||
});
|
||||
|
||||
it("does not add synthetic toolResult when a matching one exists", () => {
|
||||
@@ -103,12 +99,7 @@ describe("installSessionToolResultGuard", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const messages = sm
|
||||
.getEntries()
|
||||
.filter((e) => e.type === "message")
|
||||
.map((e) => (e as { message: AgentMessage }).message);
|
||||
|
||||
expect(messages.map((m) => m.role)).toEqual(["assistant", "toolResult"]);
|
||||
expectPersistedRoles(sm, ["assistant", "toolResult"]);
|
||||
});
|
||||
|
||||
it("preserves ordering with multiple tool calls and partial results", () => {
|
||||
@@ -139,12 +130,7 @@ describe("installSessionToolResultGuard", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const messages = sm
|
||||
.getEntries()
|
||||
.filter((e) => e.type === "message")
|
||||
.map((e) => (e as { message: AgentMessage }).message);
|
||||
|
||||
expect(messages.map((m) => m.role)).toEqual([
|
||||
const messages = expectPersistedRoles(sm, [
|
||||
"assistant", // tool calls
|
||||
"toolResult", // call_a real
|
||||
"toolResult", // synthetic for call_b
|
||||
@@ -187,11 +173,7 @@ describe("installSessionToolResultGuard", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const messages = sm
|
||||
.getEntries()
|
||||
.filter((e) => e.type === "message")
|
||||
.map((e) => (e as { message: AgentMessage }).message);
|
||||
expect(messages.map((m) => m.role)).toEqual(["assistant", "toolResult"]);
|
||||
expectPersistedRoles(sm, ["assistant", "toolResult"]);
|
||||
});
|
||||
|
||||
it("drops malformed tool calls missing input before persistence", () => {
|
||||
@@ -205,11 +187,7 @@ describe("installSessionToolResultGuard", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const messages = sm
|
||||
.getEntries()
|
||||
.filter((e) => e.type === "message")
|
||||
.map((e) => (e as { message: AgentMessage }).message);
|
||||
|
||||
const messages = getPersistedMessages(sm);
|
||||
expect(messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -231,12 +209,7 @@ describe("installSessionToolResultGuard", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const messages = sm
|
||||
.getEntries()
|
||||
.filter((e) => e.type === "message")
|
||||
.map((e) => (e as { message: AgentMessage }).message);
|
||||
|
||||
expect(messages.map((m) => m.role)).toEqual(["assistant", "toolResult"]);
|
||||
expectPersistedRoles(sm, ["assistant", "toolResult"]);
|
||||
});
|
||||
|
||||
it("caps oversized tool result text during persistence", () => {
|
||||
|
||||
@@ -22,6 +22,19 @@ function makeEntry(updatedAt: number): SessionEntry {
|
||||
return { sessionId: crypto.randomUUID(), updatedAt };
|
||||
}
|
||||
|
||||
function applyEnforcedMaintenanceConfig(mockLoadConfig: ReturnType<typeof vi.fn>) {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
session: {
|
||||
maintenance: {
|
||||
mode: "enforce",
|
||||
pruneAfter: "7d",
|
||||
maxEntries: 500,
|
||||
rotateBytes: 10_485_760,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createCaseDir(prefix: string): Promise<string> {
|
||||
const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
@@ -64,16 +77,7 @@ describe("Integration: saveSessionStore with pruning", () => {
|
||||
});
|
||||
|
||||
it("saveSessionStore prunes stale entries on write", async () => {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
session: {
|
||||
maintenance: {
|
||||
mode: "enforce",
|
||||
pruneAfter: "7d",
|
||||
maxEntries: 500,
|
||||
rotateBytes: 10_485_760,
|
||||
},
|
||||
},
|
||||
});
|
||||
applyEnforcedMaintenanceConfig(mockLoadConfig);
|
||||
|
||||
const now = Date.now();
|
||||
const store: Record<string, SessionEntry> = {
|
||||
@@ -89,16 +93,7 @@ describe("Integration: saveSessionStore with pruning", () => {
|
||||
});
|
||||
|
||||
it("archives transcript files for stale sessions pruned on write", async () => {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
session: {
|
||||
maintenance: {
|
||||
mode: "enforce",
|
||||
pruneAfter: "7d",
|
||||
maxEntries: 500,
|
||||
rotateBytes: 10_485_760,
|
||||
},
|
||||
},
|
||||
});
|
||||
applyEnforcedMaintenanceConfig(mockLoadConfig);
|
||||
|
||||
const now = Date.now();
|
||||
const staleSessionId = "stale-session";
|
||||
@@ -127,16 +122,7 @@ describe("Integration: saveSessionStore with pruning", () => {
|
||||
});
|
||||
|
||||
it("cleans up archived transcripts older than the prune window", async () => {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
session: {
|
||||
maintenance: {
|
||||
mode: "enforce",
|
||||
pruneAfter: "7d",
|
||||
maxEntries: 500,
|
||||
rotateBytes: 10_485_760,
|
||||
},
|
||||
},
|
||||
});
|
||||
applyEnforcedMaintenanceConfig(mockLoadConfig);
|
||||
|
||||
const now = Date.now();
|
||||
const staleSessionId = "stale-session";
|
||||
|
||||
161
src/infra/install-source-utils.test.ts
Normal file
161
src/infra/install-source-utils.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
packNpmSpecToArchive,
|
||||
resolveArchiveSourcePath,
|
||||
withTempDir,
|
||||
} from "./install-source-utils.js";
|
||||
|
||||
const runCommandWithTimeoutMock = vi.fn();
|
||||
|
||||
vi.mock("../process/exec.js", () => ({
|
||||
runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args),
|
||||
}));
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function createTempDir(prefix: string) {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
runCommandWithTimeoutMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (!dir) {
|
||||
break;
|
||||
}
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("withTempDir", () => {
|
||||
it("creates a temp dir and always removes it after callback", async () => {
|
||||
let observedDir = "";
|
||||
const markerFile = "marker.txt";
|
||||
|
||||
const value = await withTempDir("openclaw-install-source-utils-", async (tmpDir) => {
|
||||
observedDir = tmpDir;
|
||||
await fs.writeFile(path.join(tmpDir, markerFile), "ok", "utf-8");
|
||||
await expect(fs.stat(path.join(tmpDir, markerFile))).resolves.toBeDefined();
|
||||
return "done";
|
||||
});
|
||||
|
||||
expect(value).toBe("done");
|
||||
await expect(fs.stat(observedDir)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveArchiveSourcePath", () => {
|
||||
it("returns not found error for missing archive paths", async () => {
|
||||
const result = await resolveArchiveSourcePath("/tmp/does-not-exist-openclaw-archive.tgz");
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toContain("archive not found");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unsupported archive extensions", async () => {
|
||||
const dir = await createTempDir("openclaw-install-source-utils-");
|
||||
const filePath = path.join(dir, "plugin.txt");
|
||||
await fs.writeFile(filePath, "not-an-archive", "utf-8");
|
||||
|
||||
const result = await resolveArchiveSourcePath(filePath);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toContain("unsupported archive");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts supported archive extensions", async () => {
|
||||
const dir = await createTempDir("openclaw-install-source-utils-");
|
||||
const filePath = path.join(dir, "plugin.zip");
|
||||
await fs.writeFile(filePath, "", "utf-8");
|
||||
|
||||
const result = await resolveArchiveSourcePath(filePath);
|
||||
expect(result).toEqual({ ok: true, path: filePath });
|
||||
});
|
||||
});
|
||||
|
||||
describe("packNpmSpecToArchive", () => {
|
||||
it("packs spec and returns archive path using the final non-empty stdout line", async () => {
|
||||
const cwd = await createTempDir("openclaw-install-source-utils-");
|
||||
runCommandWithTimeoutMock.mockResolvedValue({
|
||||
stdout: "npm notice created package\nopenclaw-plugin-1.2.3.tgz\n",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
});
|
||||
|
||||
const result = await packNpmSpecToArchive({
|
||||
spec: "openclaw-plugin@1.2.3",
|
||||
timeoutMs: 1000,
|
||||
cwd,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
archivePath: path.join(cwd, "openclaw-plugin-1.2.3.tgz"),
|
||||
});
|
||||
expect(runCommandWithTimeoutMock).toHaveBeenCalledWith(
|
||||
["npm", "pack", "openclaw-plugin@1.2.3", "--ignore-scripts"],
|
||||
expect.objectContaining({
|
||||
cwd,
|
||||
timeoutMs: 300_000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns npm pack error details when command fails", async () => {
|
||||
const cwd = await createTempDir("openclaw-install-source-utils-");
|
||||
runCommandWithTimeoutMock.mockResolvedValue({
|
||||
stdout: "fallback stdout",
|
||||
stderr: "registry timeout",
|
||||
code: 1,
|
||||
signal: null,
|
||||
killed: false,
|
||||
});
|
||||
|
||||
const result = await packNpmSpecToArchive({
|
||||
spec: "bad-spec",
|
||||
timeoutMs: 5000,
|
||||
cwd,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toContain("npm pack failed");
|
||||
expect(result.error).toContain("registry timeout");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns explicit error when npm pack produces no archive name", async () => {
|
||||
const cwd = await createTempDir("openclaw-install-source-utils-");
|
||||
runCommandWithTimeoutMock.mockResolvedValue({
|
||||
stdout: " \n\n",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
});
|
||||
|
||||
const result = await packNpmSpecToArchive({
|
||||
spec: "openclaw-plugin@1.2.3",
|
||||
timeoutMs: 5000,
|
||||
cwd,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: "npm pack produced no archive",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,34 @@ function writeManifest(dir: string, manifest: Record<string, unknown>) {
|
||||
fs.writeFileSync(path.join(dir, "openclaw.plugin.json"), JSON.stringify(manifest), "utf-8");
|
||||
}
|
||||
|
||||
function createPluginCandidate(params: {
|
||||
idHint: string;
|
||||
rootDir: string;
|
||||
sourceName?: string;
|
||||
origin: "bundled" | "global" | "workspace" | "config";
|
||||
}): PluginCandidate {
|
||||
return {
|
||||
idHint: params.idHint,
|
||||
source: path.join(params.rootDir, params.sourceName ?? "index.ts"),
|
||||
rootDir: params.rootDir,
|
||||
origin: params.origin,
|
||||
};
|
||||
}
|
||||
|
||||
function loadRegistry(candidates: PluginCandidate[]) {
|
||||
return loadPluginManifestRegistry({
|
||||
candidates,
|
||||
cache: false,
|
||||
});
|
||||
}
|
||||
|
||||
function countDuplicateWarnings(registry: ReturnType<typeof loadPluginManifestRegistry>): number {
|
||||
return registry.diagnostics.filter(
|
||||
(diagnostic) =>
|
||||
diagnostic.level === "warn" && diagnostic.message?.includes("duplicate plugin id"),
|
||||
).length;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
@@ -42,29 +70,19 @@ describe("loadPluginManifestRegistry", () => {
|
||||
writeManifest(dirB, manifest);
|
||||
|
||||
const candidates: PluginCandidate[] = [
|
||||
{
|
||||
createPluginCandidate({
|
||||
idHint: "test-plugin",
|
||||
source: path.join(dirA, "index.ts"),
|
||||
rootDir: dirA,
|
||||
origin: "bundled",
|
||||
},
|
||||
{
|
||||
}),
|
||||
createPluginCandidate({
|
||||
idHint: "test-plugin",
|
||||
source: path.join(dirB, "index.ts"),
|
||||
rootDir: dirB,
|
||||
origin: "global",
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const registry = loadPluginManifestRegistry({
|
||||
candidates,
|
||||
cache: false,
|
||||
});
|
||||
|
||||
const duplicateWarnings = registry.diagnostics.filter(
|
||||
(d) => d.level === "warn" && d.message?.includes("duplicate plugin id"),
|
||||
);
|
||||
expect(duplicateWarnings.length).toBe(1);
|
||||
expect(countDuplicateWarnings(loadRegistry(candidates))).toBe(1);
|
||||
});
|
||||
|
||||
it("suppresses duplicate warning when candidates share the same physical directory via symlink", () => {
|
||||
@@ -84,29 +102,19 @@ describe("loadPluginManifestRegistry", () => {
|
||||
}
|
||||
|
||||
const candidates: PluginCandidate[] = [
|
||||
{
|
||||
createPluginCandidate({
|
||||
idHint: "feishu",
|
||||
source: path.join(realDir, "index.ts"),
|
||||
rootDir: realDir,
|
||||
origin: "bundled",
|
||||
},
|
||||
{
|
||||
}),
|
||||
createPluginCandidate({
|
||||
idHint: "feishu",
|
||||
source: path.join(symlinkPath, "index.ts"),
|
||||
rootDir: symlinkPath,
|
||||
origin: "bundled",
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const registry = loadPluginManifestRegistry({
|
||||
candidates,
|
||||
cache: false,
|
||||
});
|
||||
|
||||
const duplicateWarnings = registry.diagnostics.filter(
|
||||
(d) => d.level === "warn" && d.message?.includes("duplicate plugin id"),
|
||||
);
|
||||
expect(duplicateWarnings.length).toBe(0);
|
||||
expect(countDuplicateWarnings(loadRegistry(candidates))).toBe(0);
|
||||
});
|
||||
|
||||
it("suppresses duplicate warning when candidates have identical rootDir paths", () => {
|
||||
@@ -115,29 +123,21 @@ describe("loadPluginManifestRegistry", () => {
|
||||
writeManifest(dir, manifest);
|
||||
|
||||
const candidates: PluginCandidate[] = [
|
||||
{
|
||||
createPluginCandidate({
|
||||
idHint: "same-path-plugin",
|
||||
source: path.join(dir, "a.ts"),
|
||||
rootDir: dir,
|
||||
sourceName: "a.ts",
|
||||
origin: "bundled",
|
||||
},
|
||||
{
|
||||
}),
|
||||
createPluginCandidate({
|
||||
idHint: "same-path-plugin",
|
||||
source: path.join(dir, "b.ts"),
|
||||
rootDir: dir,
|
||||
sourceName: "b.ts",
|
||||
origin: "global",
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const registry = loadPluginManifestRegistry({
|
||||
candidates,
|
||||
cache: false,
|
||||
});
|
||||
|
||||
const duplicateWarnings = registry.diagnostics.filter(
|
||||
(d) => d.level === "warn" && d.message?.includes("duplicate plugin id"),
|
||||
);
|
||||
expect(duplicateWarnings.length).toBe(0);
|
||||
expect(countDuplicateWarnings(loadRegistry(candidates))).toBe(0);
|
||||
});
|
||||
|
||||
it("prefers higher-precedence origins for the same physical directory (config > workspace > global > bundled)", () => {
|
||||
@@ -150,29 +150,20 @@ describe("loadPluginManifestRegistry", () => {
|
||||
const altDir = path.join(dir, "sub", "..");
|
||||
|
||||
const candidates: PluginCandidate[] = [
|
||||
{
|
||||
createPluginCandidate({
|
||||
idHint: "precedence-plugin",
|
||||
source: path.join(dir, "index.ts"),
|
||||
rootDir: dir,
|
||||
origin: "bundled",
|
||||
},
|
||||
{
|
||||
}),
|
||||
createPluginCandidate({
|
||||
idHint: "precedence-plugin",
|
||||
source: path.join(altDir, "index.ts"),
|
||||
rootDir: altDir,
|
||||
origin: "config",
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const registry = loadPluginManifestRegistry({
|
||||
candidates,
|
||||
cache: false,
|
||||
});
|
||||
|
||||
const duplicateWarnings = registry.diagnostics.filter(
|
||||
(d) => d.level === "warn" && d.message?.includes("duplicate plugin id"),
|
||||
);
|
||||
expect(duplicateWarnings.length).toBe(0);
|
||||
const registry = loadRegistry(candidates);
|
||||
expect(countDuplicateWarnings(registry)).toBe(0);
|
||||
expect(registry.plugins.length).toBe(1);
|
||||
expect(registry.plugins[0]?.origin).toBe("config");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user