diff --git a/src/hooks/install.test.ts b/src/hooks/install.test.ts index e5eeb16c01e..5c0cabc141b 100644 --- a/src/hooks/install.test.ts +++ b/src/hooks/install.test.ts @@ -3,10 +3,13 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { expectSingleNpmPackIgnoreScriptsCall } from "../test-utils/exec-assertions.js"; import { - expectSingleNpmInstallIgnoreScriptsCall, - expectSingleNpmPackIgnoreScriptsCall, -} from "../test-utils/exec-assertions.js"; + expectInstallUsesIgnoreScripts, + expectIntegrityDriftRejected, + expectUnsupportedNpmSpec, + mockNpmPackMetadataResult, +} from "../test-utils/npm-spec-install-test-helpers.js"; import { isAddressInUseError } from "./gmail-watcher.js"; const fixtureRoot = path.join(os.tmpdir(), `openclaw-hook-install-${randomUUID()}`); @@ -60,17 +63,6 @@ function writeArchiveFixture(params: { fileName: string; contents: Buffer }) { }; } -async function expectUnsupportedNpmSpec( - install: (spec: string) => Promise<{ ok: boolean; error?: string }>, -) { - const result = await install("github:evil/evil"); - expect(result.ok).toBe(false); - if (result.ok) { - return; - } - expect(result.error).toContain("unsupported npm spec"); -} - function expectInstallFailureContains( result: Awaited>, snippets: string[], @@ -196,26 +188,13 @@ describe("installHooksFromPath", () => { ); const run = vi.mocked(runCommandWithTimeout); - run.mockResolvedValue({ - code: 0, - stdout: "", - stderr: "", - signal: null, - killed: false, - termination: "exit", - }); - - const res = await installHooksFromPath({ - path: pkgDir, - hooksDir: path.join(stateDir, "hooks"), - }); - expect(res.ok).toBe(true); - if (!res.ok) { - return; - } - expectSingleNpmInstallIgnoreScriptsCall({ - calls: run.mock.calls as Array<[unknown, { cwd?: string } | undefined]>, - expectedCwd: res.targetDir, + await expectInstallUsesIgnoreScripts({ + run, + install: async () => + await installHooksFromPath({ + path: pkgDir, + hooksDir: path.join(stateDir, "hooks"), + }), }); }); @@ -383,22 +362,13 @@ describe("installHooksFromNpmSpec", () => { it("aborts when integrity drift callback rejects the fetched artifact", async () => { const run = vi.mocked(runCommandWithTimeout); - run.mockResolvedValue({ - code: 0, - stdout: JSON.stringify([ - { - id: "@openclaw/test-hooks@0.0.1", - name: "@openclaw/test-hooks", - version: "0.0.1", - filename: "test-hooks-0.0.1.tgz", - integrity: "sha512-new", - shasum: "newshasum", - }, - ]), - stderr: "", - signal: null, - killed: false, - termination: "exit", + mockNpmPackMetadataResult(run, { + id: "@openclaw/test-hooks@0.0.1", + name: "@openclaw/test-hooks", + version: "0.0.1", + filename: "test-hooks-0.0.1.tgz", + integrity: "sha512-new", + shasum: "newshasum", }); const onIntegrityDrift = vi.fn(async () => false); @@ -407,18 +377,12 @@ describe("installHooksFromNpmSpec", () => { expectedIntegrity: "sha512-old", onIntegrityDrift, }); - - expect(onIntegrityDrift).toHaveBeenCalledWith( - expect.objectContaining({ - expectedIntegrity: "sha512-old", - actualIntegrity: "sha512-new", - }), - ); - expect(result.ok).toBe(false); - if (result.ok) { - return; - } - expect(result.error).toContain("integrity drift"); + expectIntegrityDriftRejected({ + onIntegrityDrift, + result, + expectedIntegrity: "sha512-old", + actualIntegrity: "sha512-new", + }); }); }); diff --git a/src/infra/install-flow.test.ts b/src/infra/install-flow.test.ts new file mode 100644 index 00000000000..62148f8e075 --- /dev/null +++ b/src/infra/install-flow.test.ts @@ -0,0 +1,122 @@ +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 * as archive from "./archive.js"; +import { resolveExistingInstallPath, withExtractedArchiveRoot } from "./install-flow.js"; +import * as installSource from "./install-source-utils.js"; + +describe("resolveExistingInstallPath", () => { + let fixtureRoot = ""; + + beforeEach(async () => { + fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-install-flow-")); + }); + + afterEach(async () => { + if (fixtureRoot) { + await fs.rm(fixtureRoot, { recursive: true, force: true }); + } + }); + + it("returns resolved path and stat for existing files", async () => { + const filePath = path.join(fixtureRoot, "plugin.tgz"); + await fs.writeFile(filePath, "archive"); + + const result = await resolveExistingInstallPath(filePath); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.resolvedPath).toBe(filePath); + expect(result.stat.isFile()).toBe(true); + }); + + it("returns a path-not-found error for missing paths", async () => { + const missing = path.join(fixtureRoot, "missing.tgz"); + + const result = await resolveExistingInstallPath(missing); + + expect(result).toEqual({ + ok: false, + error: `path not found: ${missing}`, + }); + }); +}); + +describe("withExtractedArchiveRoot", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("extracts archive and passes root directory to callback", async () => { + const withTempDirSpy = vi + .spyOn(installSource, "withTempDir") + .mockImplementation(async (_prefix, fn) => await fn("/tmp/openclaw-install-flow")); + const extractSpy = vi.spyOn(archive, "extractArchive").mockResolvedValue(undefined); + const resolveRootSpy = vi + .spyOn(archive, "resolvePackedRootDir") + .mockResolvedValue("/tmp/openclaw-install-flow/extract/package"); + + const onExtracted = vi.fn(async (rootDir: string) => ({ ok: true as const, rootDir })); + const result = await withExtractedArchiveRoot({ + archivePath: "/tmp/plugin.tgz", + tempDirPrefix: "openclaw-plugin-", + timeoutMs: 1000, + onExtracted, + }); + + expect(withTempDirSpy).toHaveBeenCalledWith("openclaw-plugin-", expect.any(Function)); + expect(extractSpy).toHaveBeenCalledWith( + expect.objectContaining({ + archivePath: "/tmp/plugin.tgz", + }), + ); + expect(resolveRootSpy).toHaveBeenCalledWith("/tmp/openclaw-install-flow/extract"); + expect(onExtracted).toHaveBeenCalledWith("/tmp/openclaw-install-flow/extract/package"); + expect(result).toEqual({ + ok: true, + rootDir: "/tmp/openclaw-install-flow/extract/package", + }); + }); + + it("returns extract failure when extraction throws", async () => { + vi.spyOn(installSource, "withTempDir").mockImplementation( + async (_prefix, fn) => await fn("/tmp/openclaw-install-flow"), + ); + vi.spyOn(archive, "extractArchive").mockRejectedValue(new Error("boom")); + + const result = await withExtractedArchiveRoot({ + archivePath: "/tmp/plugin.tgz", + tempDirPrefix: "openclaw-plugin-", + timeoutMs: 1000, + onExtracted: async () => ({ ok: true as const }), + }); + + expect(result).toEqual({ + ok: false, + error: "failed to extract archive: Error: boom", + }); + }); + + it("returns root-resolution failure when archive layout is invalid", async () => { + vi.spyOn(installSource, "withTempDir").mockImplementation( + async (_prefix, fn) => await fn("/tmp/openclaw-install-flow"), + ); + vi.spyOn(archive, "extractArchive").mockResolvedValue(undefined); + vi.spyOn(archive, "resolvePackedRootDir").mockRejectedValue(new Error("invalid layout")); + + const result = await withExtractedArchiveRoot({ + archivePath: "/tmp/plugin.tgz", + tempDirPrefix: "openclaw-plugin-", + timeoutMs: 1000, + onExtracted: async () => ({ ok: true as const }), + }); + + expect(result).toEqual({ + ok: false, + error: "Error: invalid layout", + }); + }); +}); diff --git a/src/infra/install-mode-options.test.ts b/src/infra/install-mode-options.test.ts new file mode 100644 index 00000000000..fe9cfa1a64c --- /dev/null +++ b/src/infra/install-mode-options.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { + resolveInstallModeOptions, + resolveTimedInstallModeOptions, +} from "./install-mode-options.js"; + +describe("install mode option helpers", () => { + it("applies logger, mode, and dryRun defaults", () => { + const logger = { warn: (_message: string) => {} }; + const result = resolveInstallModeOptions({}, logger); + + expect(result).toEqual({ + logger, + mode: "install", + dryRun: false, + }); + }); + + it("preserves explicit mode and dryRun values", () => { + const logger = { warn: (_message: string) => {} }; + const result = resolveInstallModeOptions( + { + logger, + mode: "update", + dryRun: true, + }, + { warn: () => {} }, + ); + + expect(result).toEqual({ + logger, + mode: "update", + dryRun: true, + }); + }); + + it("uses default timeout when not provided", () => { + const logger = { warn: (_message: string) => {} }; + const result = resolveTimedInstallModeOptions({}, logger); + + expect(result.timeoutMs).toBe(120_000); + expect(result.mode).toBe("install"); + expect(result.dryRun).toBe(false); + }); + + it("honors custom timeout default override", () => { + const result = resolveTimedInstallModeOptions({}, { warn: () => {} }, 5000); + + expect(result.timeoutMs).toBe(5000); + }); +}); diff --git a/src/infra/npm-pack-install.test.ts b/src/infra/npm-pack-install.test.ts index 503b27a1b3c..c0428ec03c5 100644 --- a/src/infra/npm-pack-install.test.ts +++ b/src/infra/npm-pack-install.test.ts @@ -1,7 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { packNpmSpecToArchive, withTempDir } from "./install-source-utils.js"; import type { NpmIntegrityDriftPayload } from "./npm-integrity.js"; -import { installFromNpmSpecArchive } from "./npm-pack-install.js"; +import { + finalizeNpmSpecArchiveInstall, + installFromNpmSpecArchive, + installFromNpmSpecArchiveWithInstaller, +} from "./npm-pack-install.js"; vi.mock("./install-source-utils.js", async (importOriginal) => { const actual = await importOriginal(); @@ -173,3 +177,99 @@ describe("installFromNpmSpecArchive", () => { expect(okResult.integrityDrift).toBeUndefined(); }); }); + +describe("installFromNpmSpecArchiveWithInstaller", () => { + beforeEach(() => { + vi.mocked(packNpmSpecToArchive).mockClear(); + }); + + it("passes archive path and installer params to installFromArchive", async () => { + vi.mocked(packNpmSpecToArchive).mockResolvedValue({ + ok: true, + archivePath: "/tmp/openclaw-plugin.tgz", + metadata: { + resolvedSpec: "@openclaw/voice-call@1.0.0", + integrity: "sha512-same", + }, + }); + const installFromArchive = vi.fn( + async (_params: { archivePath: string; pluginId: string }) => + ({ ok: true as const, pluginId: "voice-call" }) as const, + ); + + const result = await installFromNpmSpecArchiveWithInstaller({ + tempDirPrefix: "openclaw-test-", + spec: "@openclaw/voice-call@1.0.0", + timeoutMs: 1000, + installFromArchive, + archiveInstallParams: { pluginId: "voice-call" }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(installFromArchive).toHaveBeenCalledWith({ + archivePath: "/tmp/openclaw-plugin.tgz", + pluginId: "voice-call", + }); + expect(result.installResult).toEqual({ ok: true, pluginId: "voice-call" }); + }); +}); + +describe("finalizeNpmSpecArchiveInstall", () => { + it("returns top-level flow errors unchanged", () => { + const result = finalizeNpmSpecArchiveInstall<{ ok: true } | { ok: false; error: string }>({ + ok: false, + error: "pack failed", + }); + + expect(result).toEqual({ ok: false, error: "pack failed" }); + }); + + it("returns install errors unchanged", () => { + const result = finalizeNpmSpecArchiveInstall<{ ok: true } | { ok: false; error: string }>({ + ok: true, + installResult: { ok: false, error: "install failed" }, + npmResolution: { + resolvedSpec: "@openclaw/test@1.0.0", + integrity: "sha512-same", + resolvedAt: "2026-01-01T00:00:00.000Z", + }, + }); + + expect(result).toEqual({ ok: false, error: "install failed" }); + }); + + it("attaches npm metadata to successful install results", () => { + const result = finalizeNpmSpecArchiveInstall< + { ok: true; pluginId: string } | { ok: false; error: string } + >({ + ok: true, + installResult: { ok: true, pluginId: "voice-call" }, + npmResolution: { + resolvedSpec: "@openclaw/voice-call@1.0.0", + integrity: "sha512-same", + resolvedAt: "2026-01-01T00:00:00.000Z", + }, + integrityDrift: { + expectedIntegrity: "sha512-old", + actualIntegrity: "sha512-same", + }, + }); + + expect(result).toEqual({ + ok: true, + pluginId: "voice-call", + npmResolution: { + resolvedSpec: "@openclaw/voice-call@1.0.0", + integrity: "sha512-same", + resolvedAt: "2026-01-01T00:00:00.000Z", + }, + integrityDrift: { + expectedIntegrity: "sha512-old", + actualIntegrity: "sha512-same", + }, + }); + }); +}); diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index a2642acfba4..02360ebccc6 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -6,10 +6,13 @@ import JSZip from "jszip"; import * as tar from "tar"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import * as skillScanner from "../security/skill-scanner.js"; +import { expectSingleNpmPackIgnoreScriptsCall } from "../test-utils/exec-assertions.js"; import { - expectSingleNpmInstallIgnoreScriptsCall, - expectSingleNpmPackIgnoreScriptsCall, -} from "../test-utils/exec-assertions.js"; + expectInstallUsesIgnoreScripts, + expectIntegrityDriftRejected, + expectUnsupportedNpmSpec, + mockNpmPackMetadataResult, +} from "../test-utils/npm-spec-install-test-helpers.js"; vi.mock("../process/exec.js", () => ({ runCommandWithTimeout: vi.fn(), @@ -181,20 +184,37 @@ async function expectArchiveInstallReservedSegmentRejection(params: { packageName: string; outName: string; }) { - const stateDir = makeTempDir(); - const workDir = makeTempDir(); - const pkgDir = path.join(workDir, "package"); - fs.mkdirSync(path.join(pkgDir, "dist"), { recursive: true }); - fs.writeFileSync( - path.join(pkgDir, "package.json"), - JSON.stringify({ + const result = await installArchivePackageAndReturnResult({ + packageJson: { name: params.packageName, version: "0.0.1", openclaw: { extensions: ["./dist/index.js"] }, - }), - "utf-8", - ); - fs.writeFileSync(path.join(pkgDir, "dist", "index.js"), "export {};", "utf-8"); + }, + outName: params.outName, + withDistIndex: true, + }); + + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + expect(result.error).toContain("reserved path segment"); +} + +async function installArchivePackageAndReturnResult(params: { + packageJson: Record; + outName: string; + withDistIndex?: boolean; +}) { + const stateDir = makeTempDir(); + const workDir = makeTempDir(); + const pkgDir = path.join(workDir, "package"); + fs.mkdirSync(pkgDir, { recursive: true }); + if (params.withDistIndex) { + fs.mkdirSync(path.join(pkgDir, "dist"), { recursive: true }); + fs.writeFileSync(path.join(pkgDir, "dist", "index.js"), "export {};", "utf-8"); + } + fs.writeFileSync(path.join(pkgDir, "package.json"), JSON.stringify(params.packageJson), "utf-8"); const archivePath = await packToArchive({ pkgDir, @@ -207,12 +227,7 @@ async function expectArchiveInstallReservedSegmentRejection(params: { archivePath, extensionsDir, }); - - expect(result.ok).toBe(false); - if (result.ok) { - return; - } - expect(result.error).toContain("reserved path segment"); + return result; } afterAll(() => { @@ -346,27 +361,10 @@ describe("installPluginFromArchive", () => { }); it("rejects packages without openclaw.extensions", async () => { - const stateDir = makeTempDir(); - const workDir = makeTempDir(); - const pkgDir = path.join(workDir, "package"); - fs.mkdirSync(pkgDir, { recursive: true }); - fs.writeFileSync( - path.join(pkgDir, "package.json"), - JSON.stringify({ name: "@openclaw/nope", version: "0.0.1" }), - "utf-8", - ); - - const archivePath = await packToArchive({ - pkgDir, - outDir: workDir, + const result = await installArchivePackageAndReturnResult({ + packageJson: { name: "@openclaw/nope", version: "0.0.1" }, outName: "bad.tgz", }); - - const extensionsDir = path.join(stateDir, "extensions"); - const result = await installPluginFromArchive({ - archivePath, - extensionsDir, - }); expect(result.ok).toBe(false); if (result.ok) { return; @@ -464,26 +462,13 @@ describe("installPluginFromDir", () => { fs.writeFileSync(path.join(pluginDir, "dist", "index.js"), "export {};", "utf-8"); const run = vi.mocked(runCommandWithTimeout); - run.mockResolvedValue({ - code: 0, - stdout: "", - stderr: "", - signal: null, - killed: false, - termination: "exit", - }); - - const res = await installPluginFromDir({ - dirPath: pluginDir, - extensionsDir: path.join(stateDir, "extensions"), - }); - expect(res.ok).toBe(true); - if (!res.ok) { - return; - } - expectSingleNpmInstallIgnoreScriptsCall({ - calls: run.mock.calls as Array<[unknown, { cwd?: string } | undefined]>, - expectedCwd: res.targetDir, + await expectInstallUsesIgnoreScripts({ + run, + install: async () => + await installPluginFromDir({ + dirPath: pluginDir, + extensionsDir: path.join(stateDir, "extensions"), + }), }); }); @@ -596,32 +581,18 @@ describe("installPluginFromNpmSpec", () => { }); it("rejects non-registry npm specs", async () => { - const result = await installPluginFromNpmSpec({ spec: "github:evil/evil" }); - expect(result.ok).toBe(false); - if (result.ok) { - return; - } - expect(result.error).toContain("unsupported npm spec"); + await expectUnsupportedNpmSpec((spec) => installPluginFromNpmSpec({ spec })); }); it("aborts when integrity drift callback rejects the fetched artifact", async () => { const run = vi.mocked(runCommandWithTimeout); - run.mockResolvedValue({ - code: 0, - stdout: JSON.stringify([ - { - id: "@openclaw/voice-call@0.0.1", - name: "@openclaw/voice-call", - version: "0.0.1", - filename: "voice-call-0.0.1.tgz", - integrity: "sha512-new", - shasum: "newshasum", - }, - ]), - stderr: "", - signal: null, - killed: false, - termination: "exit", + mockNpmPackMetadataResult(run, { + id: "@openclaw/voice-call@0.0.1", + name: "@openclaw/voice-call", + version: "0.0.1", + filename: "voice-call-0.0.1.tgz", + integrity: "sha512-new", + shasum: "newshasum", }); const onIntegrityDrift = vi.fn(async () => false); @@ -630,17 +601,11 @@ describe("installPluginFromNpmSpec", () => { expectedIntegrity: "sha512-old", onIntegrityDrift, }); - - expect(onIntegrityDrift).toHaveBeenCalledWith( - expect.objectContaining({ - expectedIntegrity: "sha512-old", - actualIntegrity: "sha512-new", - }), - ); - expect(result.ok).toBe(false); - if (result.ok) { - return; - } - expect(result.error).toContain("integrity drift"); + expectIntegrityDriftRejected({ + onIntegrityDrift, + result, + expectedIntegrity: "sha512-old", + actualIntegrity: "sha512-new", + }); }); }); diff --git a/src/test-utils/npm-spec-install-test-helpers.ts b/src/test-utils/npm-spec-install-test-helpers.ts new file mode 100644 index 00000000000..23c06afe44b --- /dev/null +++ b/src/test-utils/npm-spec-install-test-helpers.ts @@ -0,0 +1,94 @@ +import { expect } from "vitest"; +import type { SpawnResult } from "../process/exec.js"; +import { expectSingleNpmInstallIgnoreScriptsCall } from "./exec-assertions.js"; + +export type InstallResultLike = { + ok: boolean; + error?: string; +}; + +export type NpmPackMetadata = { + id: string; + name: string; + version: string; + filename: string; + integrity: string; + shasum: string; +}; + +export function createSuccessfulSpawnResult(stdout = ""): SpawnResult { + return { + code: 0, + stdout, + stderr: "", + signal: null, + killed: false, + termination: "exit", + }; +} + +export async function expectUnsupportedNpmSpec( + install: (spec: string) => Promise, + spec = "github:evil/evil", +) { + const result = await install(spec); + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + expect(result.error).toContain("unsupported npm spec"); +} + +export function mockNpmPackMetadataResult( + run: { mockResolvedValue: (value: SpawnResult) => unknown }, + metadata: NpmPackMetadata, +) { + run.mockResolvedValue(createSuccessfulSpawnResult(JSON.stringify([metadata]))); +} + +export function expectIntegrityDriftRejected(params: { + onIntegrityDrift: (...args: unknown[]) => unknown; + result: InstallResultLike; + expectedIntegrity: string; + actualIntegrity: string; +}) { + expect(params.onIntegrityDrift).toHaveBeenCalledWith( + expect.objectContaining({ + expectedIntegrity: params.expectedIntegrity, + actualIntegrity: params.actualIntegrity, + }), + ); + expect(params.result.ok).toBe(false); + if (params.result.ok) { + return; + } + expect(params.result.error).toContain("integrity drift"); +} + +export async function expectInstallUsesIgnoreScripts(params: { + run: { + mockResolvedValue: (value: SpawnResult) => unknown; + mock: { calls: unknown[][] }; + }; + install: () => Promise< + | { + ok: true; + targetDir: string; + } + | { + ok: false; + error?: string; + } + >; +}) { + params.run.mockResolvedValue(createSuccessfulSpawnResult()); + const result = await params.install(); + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expectSingleNpmInstallIgnoreScriptsCall({ + calls: params.run.mock.calls as Array<[unknown, { cwd?: string } | undefined]>, + expectedCwd: result.targetDir, + }); +}