


























@@ -1,3 +1,4 @@
1+import fsSync from "node:fs";
12import fs from "node:fs/promises";
23import path from "node:path";
34import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
@@ -8,6 +9,7 @@ import {
89import type { OpenClawConfig } from "../../config/config.js";
910import type { PluginManifestRegistry } from "../../plugins/manifest-registry.js";
1011import { createTrackedTempDirs } from "../../test-utils/tracked-temp-dirs.js";
12+import { __testing } from "./plugin-skills.js";
11131214const hoisted = vi.hoisted(() => {
1315const loadManifestRegistry = vi.fn();
@@ -279,6 +281,31 @@ describe("resolvePluginSkillDirs", () => {
279281expect(dirs).toEqual([]);
280282});
281283284+it("cleans up generated plugin skill links when the plugin registry is empty", async () => {
285+const workspaceDir = await tempDirs.make("openclaw-");
286+const pluginSkillsDir = await tempDirs.make("managed-plugin-skills-");
287+const staleRoot = await tempDirs.make("stale-plugin-skills-");
288+const staleSkill = path.join(staleRoot, "stale-skill");
289+await fs.mkdir(staleSkill, { recursive: true });
290+fsSync.symlinkSync(staleSkill, path.join(pluginSkillsDir, "stale-skill"), "dir");
291+292+hoisted.loadPluginManifestRegistryForInstalledIndex.mockReturnValue({
293+diagnostics: [],
294+plugins: [],
295+});
296+297+const dirs = resolvePluginSkillDirs({
298+ workspaceDir,
299+config: {} as OpenClawConfig,
300+ pluginSkillsDir,
301+});
302+303+expect(dirs).toEqual([]);
304+await expect(fs.lstat(path.join(pluginSkillsDir, "stale-skill"))).rejects.toMatchObject({
305+code: "ENOENT",
306+});
307+});
308+282309it("resolves Claude bundle command roots through the normal plugin skill path", async () => {
283310const workspaceDir = await tempDirs.make("openclaw-");
284311const pluginRoot = await tempDirs.make("openclaw-claude-bundle-");
@@ -337,3 +364,191 @@ describe("resolvePluginSkillDirs", () => {
337364expect(dirs).toEqual([path.resolve(pluginRoot, "skills")]);
338365});
339366});
367+368+describe("publishPluginSkills", () => {
369+const { publishPluginSkills } = __testing;
370+371+async function writeSkillDir(
372+parentDir: string,
373+name: string,
374+description = `${name} description`,
375+) {
376+const dir = path.join(parentDir, name);
377+await fs.mkdir(dir, { recursive: true });
378+await fs.writeFile(
379+path.join(dir, "SKILL.md"),
380+`---\nname: ${name}\ndescription: ${description}\n---\n\n# ${name}\n`,
381+);
382+return dir;
383+}
384+385+it("creates symlinks for each plugin skill dir", async () => {
386+const skillParent = await tempDirs.make("plugin-skills-");
387+const managedDir = await tempDirs.make("managed-skills-");
388+389+const dirA = await writeSkillDir(skillParent, "skill-a");
390+const dirB = await writeSkillDir(skillParent, "skill-b");
391+392+publishPluginSkills([dirA, dirB], {
393+pluginSkillsDir: managedDir,
394+});
395+396+const linkA = path.join(managedDir, "skill-a");
397+const linkB = path.join(managedDir, "skill-b");
398+expect(fsSync.readlinkSync(linkA)).toBe(dirA);
399+expect(fsSync.readlinkSync(linkB)).toBe(dirB);
400+});
401+402+it("is idempotent: skips symlinks that already point to the same target", async () => {
403+const skillParent = await tempDirs.make("plugin-skills-");
404+const managedDir = await tempDirs.make("managed-skills-");
405+406+const dir = await writeSkillDir(skillParent, "my-skill");
407+408+publishPluginSkills([dir], { pluginSkillsDir: managedDir });
409+const mtimeAfterFirst = (await fs.lstat(path.join(managedDir, "my-skill"))).mtimeMs;
410+411+// Second call with same input should preserve the existing symlink.
412+publishPluginSkills([dir], { pluginSkillsDir: managedDir });
413+const mtimeAfterSecond = (await fs.lstat(path.join(managedDir, "my-skill"))).mtimeMs;
414+415+expect(mtimeAfterSecond).toBe(mtimeAfterFirst);
416+expect(fsSync.readlinkSync(path.join(managedDir, "my-skill"))).toBe(dir);
417+});
418+419+it("replaces owned generated symlinks when a plugin skill target moves", async () => {
420+const skillParent1 = await tempDirs.make("plugin-skills-1-");
421+const skillParent2 = await tempDirs.make("plugin-skills-2-");
422+const managedDir = await tempDirs.make("managed-skills-");
423+424+const dir1 = await writeSkillDir(skillParent1, "my-skill", "old");
425+const dir2 = await writeSkillDir(skillParent2, "my-skill", "new");
426+427+fsSync.symlinkSync(dir1, path.join(managedDir, "my-skill"), "dir");
428+429+publishPluginSkills([dir2], { pluginSkillsDir: managedDir });
430+431+expect(fsSync.readlinkSync(path.join(managedDir, "my-skill"))).toBe(dir2);
432+});
433+434+it("cleans up stale symlinks whose targets still exist", async () => {
435+const skillParent = await tempDirs.make("plugin-skills-");
436+const managedDir = await tempDirs.make("managed-skills-");
437+438+const dir = await writeSkillDir(skillParent, "current-skill");
439+const staleDir = await writeSkillDir(skillParent, "stale-skill");
440+441+fsSync.symlinkSync(staleDir, path.join(managedDir, "stale-skill"), "dir");
442+443+publishPluginSkills([dir], { pluginSkillsDir: managedDir });
444+445+expect(fsSync.existsSync(path.join(managedDir, "current-skill"))).toBe(true);
446+expect(fsSync.existsSync(path.join(managedDir, "stale-skill"))).toBe(false);
447+});
448+449+it("cleans up broken symlinks (dangling)", async () => {
450+const skillParent = await tempDirs.make("plugin-skills-");
451+const managedDir = await tempDirs.make("managed-skills-");
452+453+const dir = await writeSkillDir(skillParent, "current-skill");
454+const nonexistentDir = path.join(skillParent, "nonexistent");
455+456+// Create a symlink to a nonexistent directory.
457+fsSync.symlinkSync(nonexistentDir, path.join(managedDir, "broken-skill"), "dir");
458+459+publishPluginSkills([dir], { pluginSkillsDir: managedDir });
460+461+expect(fsSync.existsSync(path.join(managedDir, "current-skill"))).toBe(true);
462+// Broken symlink pointing to nonexistent target should be removed.
463+expect(fsSync.existsSync(path.join(managedDir, "broken-skill"))).toBe(false);
464+});
465+466+it.runIf(process.platform !== "win32")(
467+"skips child skill directories whose SKILL.md symlinks outside the declared root",
468+async () => {
469+const skillParent = await tempDirs.make("plugin-skills-");
470+const managedDir = await tempDirs.make("managed-skills-");
471+const outsideDir = await tempDirs.make("outside-skill-file-");
472+const parentDir = path.join(skillParent, "skills");
473+const leakDir = path.join(parentDir, "leak");
474+await fs.mkdir(leakDir, { recursive: true });
475+await fs.writeFile(
476+path.join(outsideDir, "SKILL.md"),
477+"---\nname: leak\ndescription: Outside\n---\n",
478+);
479+await fs.symlink(path.join(outsideDir, "SKILL.md"), path.join(leakDir, "SKILL.md"));
480+const validDir = await writeSkillDir(parentDir, "valid");
481+482+publishPluginSkills([parentDir], { pluginSkillsDir: managedDir });
483+484+expect(fsSync.existsSync(path.join(managedDir, "leak"))).toBe(false);
485+expect(fsSync.readlinkSync(path.join(managedDir, "valid"))).toBe(validDir);
486+},
487+);
488+489+it("does not create managed skills dir when skill dirs list is empty", async () => {
490+const parent = await tempDirs.make("parent-");
491+const managedDir = path.join(parent, "does-not-exist");
492+publishPluginSkills([], { pluginSkillsDir: managedDir });
493+expect(fsSync.existsSync(managedDir)).toBe(false);
494+});
495+496+it("skips directories that do not contain a SKILL.md and have no skill children", async () => {
497+const skillParent = await tempDirs.make("plugin-skills-");
498+const managedDir = await tempDirs.make("managed-skills-");
499+500+// Create a dir without SKILL.md – should be skipped.
501+const emptyDir = path.join(skillParent, "empty-dir");
502+await fs.mkdir(emptyDir, { recursive: true });
503+504+publishPluginSkills([emptyDir], {
505+pluginSkillsDir: managedDir,
506+});
507+508+expect(fsSync.existsSync(path.join(managedDir, "empty-dir"))).toBe(false);
509+});
510+511+it("expands parent skill containers to child directories that contain SKILL.md", async () => {
512+const skillParent = await tempDirs.make("plugin-skills-");
513+const managedDir = await tempDirs.make("managed-skills-");
514+515+// Create a parent skills dir with child skill dirs (the layout used by
516+// bundled plugins like browser and memory-wiki).
517+const parentDir = path.join(skillParent, "skills");
518+const childA = await writeSkillDir(parentDir, "browser");
519+const childB = await writeSkillDir(parentDir, "memory");
520+521+publishPluginSkills([parentDir], {
522+pluginSkillsDir: managedDir,
523+});
524+525+// Child skill dirs should be published under their basenames.
526+expect(fsSync.readlinkSync(path.join(managedDir, "browser"))).toBe(childA);
527+expect(fsSync.readlinkSync(path.join(managedDir, "memory"))).toBe(childB);
528+529+// The parent dir itself should NOT be published (no SKILL.md there).
530+expect(fsSync.existsSync(path.join(managedDir, "skills"))).toBe(false);
531+});
532+533+it("handles empty skill dirs list without error", async () => {
534+const managedDir = await tempDirs.make("managed-skills-");
535+publishPluginSkills([], { pluginSkillsDir: managedDir });
536+// No error expected. The managed dir may or may not be created.
537+});
538+539+it("handles collision: same basename from different plugins uses first one", async () => {
540+const skillParent1 = await tempDirs.make("plugin-skills-1-");
541+const skillParent2 = await tempDirs.make("plugin-skills-2-");
542+const managedDir = await tempDirs.make("managed-skills-");
543+544+const dir1 = await writeSkillDir(skillParent1, "shared-name", "first");
545+const dir2 = await writeSkillDir(skillParent2, "shared-name", "second");
546+547+publishPluginSkills([dir1, dir2], {
548+pluginSkillsDir: managedDir,
549+});
550+551+// First one wins.
552+expect(fsSync.readlinkSync(path.join(managedDir, "shared-name"))).toBe(dir1);
553+});
554+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。