mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
Expose stale plugin runtime symlink doctor lint findings
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
// Plugin runtime symlink tests cover doctor detection of stale global symlinks.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
collectStalePluginRuntimeSymlinkHealthFindings,
|
||||
collectStalePluginRuntimeSymlinks,
|
||||
stalePluginRuntimeSymlinkToHealthFinding,
|
||||
} from "./plugin-runtime-symlinks.js";
|
||||
|
||||
async function expectSymlinkPresent(targetPath: string): Promise<void> {
|
||||
expect((await fs.lstat(targetPath)).isSymbolicLink()).toBe(true);
|
||||
}
|
||||
|
||||
async function canCreateDirectorySymlink(root: string): Promise<boolean> {
|
||||
const target = path.join(root, "symlink-capability-target");
|
||||
const link = path.join(root, "symlink-capability-link");
|
||||
await fs.mkdir(target, { recursive: true });
|
||||
try {
|
||||
await fs.symlink(target, link, "dir");
|
||||
return (await fs.lstat(link)).isSymbolicLink();
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
||||
if (code === "EPERM" || code === "EACCES" || code === "ENOTSUP") {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
await fs.rm(link, { recursive: true, force: true });
|
||||
await fs.rm(target, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe("plugin runtime symlink health findings", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-runtime-symlinks-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("maps dangling plugin-runtime symlinks to read-only lint findings", async () => {
|
||||
if (!(await canCreateDirectorySymlink(tempDir))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const packageRoot = path.join(tempDir, "prefix", "lib", "node_modules", "openclaw");
|
||||
const nodeModulesRoot = path.dirname(packageRoot);
|
||||
const legacyRoot = path.join(tempDir, "state", "plugin-runtime-deps");
|
||||
const missingTarget = path.join(
|
||||
legacyRoot,
|
||||
"openclaw-slack",
|
||||
"node_modules",
|
||||
"@slack",
|
||||
"web-api",
|
||||
);
|
||||
const scopeRoot = path.join(nodeModulesRoot, "@slack");
|
||||
const staleLink = path.join(scopeRoot, "web-api");
|
||||
const liveTarget = path.join(tempDir, "live", "@slack", "bolt");
|
||||
const liveLink = path.join(scopeRoot, "bolt");
|
||||
|
||||
await fs.mkdir(packageRoot, { recursive: true });
|
||||
await fs.mkdir(scopeRoot, { recursive: true });
|
||||
await fs.mkdir(liveTarget, { recursive: true });
|
||||
await fs.symlink(missingTarget, staleLink, "dir");
|
||||
await fs.symlink(liveTarget, liveLink, "dir");
|
||||
|
||||
const [stale] = await collectStalePluginRuntimeSymlinks(packageRoot);
|
||||
if (!stale) {
|
||||
throw new Error("expected stale plugin-runtime symlink finding");
|
||||
}
|
||||
|
||||
expect(stale).toEqual({
|
||||
name: "@slack/web-api",
|
||||
path: staleLink,
|
||||
target: missingTarget,
|
||||
});
|
||||
expect(stalePluginRuntimeSymlinkToHealthFinding(stale)).toEqual({
|
||||
checkId: "core/doctor/stale-plugin-runtime-symlinks",
|
||||
severity: "warning",
|
||||
message: `Stale plugin-runtime symlink @slack/web-api points at ${missingTarget}.`,
|
||||
path: staleLink,
|
||||
target: staleLink,
|
||||
requirement: "stale-plugin-runtime-symlink-removed",
|
||||
fixHint: "Run `openclaw doctor --fix` to remove stale plugin-runtime symlinks.",
|
||||
});
|
||||
expect(await collectStalePluginRuntimeSymlinkHealthFindings({ packageRoot })).toEqual([
|
||||
expect.objectContaining({
|
||||
checkId: "core/doctor/stale-plugin-runtime-symlinks",
|
||||
path: staleLink,
|
||||
}),
|
||||
]);
|
||||
await expectSymlinkPresent(staleLink);
|
||||
await expectSymlinkPresent(liveLink);
|
||||
});
|
||||
|
||||
it("reports symlinks that point inside classified stale roots", async () => {
|
||||
if (!(await canCreateDirectorySymlink(tempDir))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const packageRoot = path.join(tempDir, "prefix", "lib", "node_modules", "openclaw");
|
||||
const nodeModulesRoot = path.dirname(packageRoot);
|
||||
const legacyRoot = path.join(tempDir, "state", "plugin-runtime-deps");
|
||||
const existingTarget = path.join(legacyRoot, "openclaw-demo", "node_modules", "left-pad");
|
||||
const staleLink = path.join(nodeModulesRoot, "left-pad");
|
||||
|
||||
await fs.mkdir(packageRoot, { recursive: true });
|
||||
await fs.mkdir(existingTarget, { recursive: true });
|
||||
await fs.symlink(existingTarget, staleLink, "dir");
|
||||
|
||||
await expect(collectStalePluginRuntimeSymlinks(packageRoot)).resolves.toEqual([]);
|
||||
await expect(
|
||||
collectStalePluginRuntimeSymlinkHealthFindings({
|
||||
packageRoot,
|
||||
staleRoots: [legacyRoot],
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
checkId: "core/doctor/stale-plugin-runtime-symlinks",
|
||||
path: staleLink,
|
||||
target: staleLink,
|
||||
}),
|
||||
]);
|
||||
await expectSymlinkPresent(staleLink);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,8 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { sortUniqueStrings } from "@openclaw/normalization-core/string-normalization";
|
||||
import { note } from "../../../../packages/terminal-core/src/note.js";
|
||||
import type { HealthFinding } from "../../../flows/health-checks.js";
|
||||
import { resolveOpenClawPackageRootSync } from "../../../infra/openclaw-root.js";
|
||||
import { shortenHomePath } from "../../../utils.js";
|
||||
|
||||
const PLUGIN_RUNTIME_DEPS_MARKER = "plugin-runtime-deps";
|
||||
@@ -99,6 +101,35 @@ export async function collectStalePluginRuntimeSymlinks(
|
||||
return stale.toSorted((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
export function stalePluginRuntimeSymlinkToHealthFinding(
|
||||
item: StalePluginRuntimeSymlink,
|
||||
): HealthFinding {
|
||||
return {
|
||||
checkId: "core/doctor/stale-plugin-runtime-symlinks",
|
||||
severity: "warning",
|
||||
message: `Stale plugin-runtime symlink ${item.name} points at ${item.target}.`,
|
||||
path: item.path,
|
||||
target: item.path,
|
||||
requirement: "stale-plugin-runtime-symlink-removed",
|
||||
fixHint: "Run `openclaw doctor --fix` to remove stale plugin-runtime symlinks.",
|
||||
};
|
||||
}
|
||||
|
||||
export async function collectStalePluginRuntimeSymlinkHealthFindings(
|
||||
params: { packageRoot?: string | null } & PluginRuntimeSymlinkOptions = {},
|
||||
): Promise<HealthFinding[]> {
|
||||
const packageRoot =
|
||||
params.packageRoot ??
|
||||
resolveOpenClawPackageRootSync({
|
||||
argv1: process.argv[1],
|
||||
moduleUrl: import.meta.url,
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
return (await collectStalePluginRuntimeSymlinks(packageRoot, params)).map(
|
||||
stalePluginRuntimeSymlinkToHealthFinding,
|
||||
);
|
||||
}
|
||||
|
||||
/** Emit a doctor note describing stale plugin-runtime symlinks, if any exist. */
|
||||
export async function noteStalePluginRuntimeSymlinks(
|
||||
packageRoot: string | null | undefined,
|
||||
|
||||
@@ -114,6 +114,7 @@ const mocks = vi.hoisted(() => ({
|
||||
requirement: hit.reason,
|
||||
}),
|
||||
),
|
||||
collectStalePluginRuntimeSymlinkHealthFindings: vi.fn(async () => [] as unknown[]),
|
||||
applyWizardMetadata: vi.fn((cfg: unknown) => cfg),
|
||||
logConfigUpdated: vi.fn(),
|
||||
isRecord: vi.fn(
|
||||
@@ -130,6 +131,11 @@ vi.mock("../commands/doctor/shared/release-configured-plugin-installs.js", () =>
|
||||
maybeRunConfiguredPluginInstallReleaseStep: mocks.maybeRunConfiguredPluginInstallReleaseStep,
|
||||
}));
|
||||
|
||||
vi.mock("../commands/doctor/shared/plugin-runtime-symlinks.js", () => ({
|
||||
collectStalePluginRuntimeSymlinkHealthFindings:
|
||||
mocks.collectStalePluginRuntimeSymlinkHealthFindings,
|
||||
}));
|
||||
|
||||
vi.mock("./bundled-health-checks.js", () => ({
|
||||
registerBundledHealthChecks: mocks.registerBundledHealthChecks,
|
||||
}));
|
||||
@@ -541,6 +547,8 @@ describe("doctor health contributions", () => {
|
||||
mocks.scanConfiguredChannelPluginBlockers.mockReset();
|
||||
mocks.scanConfiguredChannelPluginBlockers.mockReturnValue([]);
|
||||
mocks.channelPluginBlockerHitToHealthFinding.mockClear();
|
||||
mocks.collectStalePluginRuntimeSymlinkHealthFindings.mockReset();
|
||||
mocks.collectStalePluginRuntimeSymlinkHealthFindings.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -1351,9 +1359,9 @@ describe("doctor health contributions", () => {
|
||||
expect(contributionIds).toContain("core/doctor/plugin-registry");
|
||||
expect(contributionIds).toContain("core/doctor/configured-plugin-installs");
|
||||
expect(contributionIds).toContain("core/doctor/legacy-plugin-dependencies");
|
||||
expect(contributionIds).toContain("core/doctor/stale-plugin-runtime-symlinks");
|
||||
expect(contributionIds).toContain("core/doctor/disk-space");
|
||||
expect(contributionIds).toContain("core/doctor/heartbeat-template");
|
||||
expect(contributionIds).toContain("core/doctor/disk-space");
|
||||
expect(contributionIds).toContain("core/doctor/device-pairing");
|
||||
expect(contributionIds).toContain("core/doctor/channel-plugin-blockers");
|
||||
expect(contributionIds).toContain("core/doctor/tool-result-cap");
|
||||
@@ -1403,6 +1411,53 @@ describe("doctor health contributions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps stale plugin-runtime symlinks opt-in for structured lint selection", async () => {
|
||||
const contributionChecks = await resolveDoctorContributionHealthChecks();
|
||||
const check = contributionChecks.find(
|
||||
(entry) => entry.id === "core/doctor/stale-plugin-runtime-symlinks",
|
||||
);
|
||||
expect(check).toMatchObject({ defaultEnabled: false });
|
||||
expect(check).toBeDefined();
|
||||
mocks.collectStalePluginRuntimeSymlinkHealthFindings.mockResolvedValueOnce([
|
||||
{
|
||||
checkId: "core/doctor/stale-plugin-runtime-symlinks",
|
||||
severity: "warning",
|
||||
message: "Stale plugin-runtime symlink left-pad points at plugin-runtime-deps.",
|
||||
path: "/tmp/node_modules/left-pad",
|
||||
target: "/tmp/node_modules/left-pad",
|
||||
},
|
||||
]);
|
||||
|
||||
const ctx = {
|
||||
cfg: {},
|
||||
mode: "lint",
|
||||
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
|
||||
} as const;
|
||||
|
||||
await expect(runDoctorLintChecks(ctx, { checks: [check!] })).resolves.toMatchObject({
|
||||
checksRun: 0,
|
||||
checksSkipped: 1,
|
||||
});
|
||||
expect(mocks.collectStalePluginRuntimeSymlinkHealthFindings).not.toHaveBeenCalled();
|
||||
|
||||
await expect(
|
||||
runDoctorLintChecks(ctx, {
|
||||
checks: [check!],
|
||||
onlyIds: ["core/doctor/stale-plugin-runtime-symlinks"],
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
checksRun: 1,
|
||||
checksSkipped: 0,
|
||||
findings: [
|
||||
expect.objectContaining({
|
||||
checkId: "core/doctor/stale-plugin-runtime-symlinks",
|
||||
path: "/tmp/node_modules/left-pad",
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(mocks.collectStalePluginRuntimeSymlinkHealthFindings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reports agent findings for inherited default tool result caps", async () => {
|
||||
const contributionChecks = await resolveDoctorContributionHealthChecks();
|
||||
const toolResultCapCheck = contributionChecks.find(
|
||||
|
||||
@@ -1462,6 +1462,21 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] {
|
||||
},
|
||||
run: async () => {},
|
||||
}),
|
||||
createDoctorHealthContribution({
|
||||
id: "doctor:stale-plugin-runtime-symlinks",
|
||||
label: "Stale plugin runtime symlinks",
|
||||
healthChecks: {
|
||||
id: "core/doctor/stale-plugin-runtime-symlinks",
|
||||
description: "Stale plugin-runtime symlinks are represented as findings.",
|
||||
defaultEnabled: false,
|
||||
async detect() {
|
||||
const { collectStalePluginRuntimeSymlinkHealthFindings } =
|
||||
await import("../commands/doctor/shared/plugin-runtime-symlinks.js");
|
||||
return await collectStalePluginRuntimeSymlinkHealthFindings();
|
||||
},
|
||||
},
|
||||
run: async () => {},
|
||||
}),
|
||||
createDoctorHealthContribution({
|
||||
id: "doctor:release-configured-plugin-installs",
|
||||
label: "Configured plugin repair",
|
||||
|
||||
Reference in New Issue
Block a user