diff --git a/src/agents/session-tool-result-guard.e2e.test.ts b/src/agents/session-tool-result-guard.e2e.test.ts index 4cce7ad41b..37cf5c96e7 100644 --- a/src/agents/session-tool-result-guard.e2e.test.ts +++ b/src/agents/session-tool-result-guard.e2e.test.ts @@ -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", () => { diff --git a/src/config/sessions/store.pruning.e2e.test.ts b/src/config/sessions/store.pruning.e2e.test.ts index a7b7182f7c..0ea3587e51 100644 --- a/src/config/sessions/store.pruning.e2e.test.ts +++ b/src/config/sessions/store.pruning.e2e.test.ts @@ -22,6 +22,19 @@ function makeEntry(updatedAt: number): SessionEntry { return { sessionId: crypto.randomUUID(), updatedAt }; } +function applyEnforcedMaintenanceConfig(mockLoadConfig: ReturnType) { + mockLoadConfig.mockReturnValue({ + session: { + maintenance: { + mode: "enforce", + pruneAfter: "7d", + maxEntries: 500, + rotateBytes: 10_485_760, + }, + }, + }); +} + async function createCaseDir(prefix: string): Promise { 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 = { @@ -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"; diff --git a/src/infra/install-source-utils.test.ts b/src/infra/install-source-utils.test.ts new file mode 100644 index 0000000000..143572479a --- /dev/null +++ b/src/infra/install-source-utils.test.ts @@ -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", + }); + }); +}); diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index 714d72f744..75ae9ef418 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -19,6 +19,34 @@ function writeManifest(dir: string, manifest: Record) { 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): 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"); });