|
1 | 1 | import { defined } from "../../tests/helpers/defined.js"; |
2 | 2 | import { describe, test, expect } from "bun:test"; |
3 | | -import { dedupePluginModules, type PluginModule } from "./loader.js"; |
| 3 | +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; |
| 4 | +import { tmpdir } from "node:os"; |
| 5 | +import { join } from "node:path"; |
| 6 | +import { createPluginLoadDiagnostics } from "./diagnostics.js"; |
| 7 | +import { |
| 8 | + dedupePluginModules, |
| 9 | + loadPluginEntry, |
| 10 | + loadPluginsFromPaths, |
| 11 | + type PluginModule, |
| 12 | +} from "./loader.js"; |
4 | 13 | import { isPluginModuleEnabled } from "./register.js"; |
5 | 14 | import { disablePluginSettings } from "./uninstall.js"; |
6 | 15 |
|
@@ -101,3 +110,135 @@ describe("isPluginModuleEnabled with dedupe shadowing", () => { |
101 | 110 | expect(isPluginModuleEnabled(user, {})).toBe(false); |
102 | 111 | }); |
103 | 112 | }); |
| 113 | + |
| 114 | +async function makeJsPlugin(files: Record<string, string>): Promise<string> { |
| 115 | + const dir = await mkdtemp(join(tmpdir(), "manifest-plugin-")); |
| 116 | + for (const [rel, body] of Object.entries(files)) { |
| 117 | + const abs = join(dir, rel); |
| 118 | + await mkdir(join(abs, ".."), { recursive: true }); |
| 119 | + await writeFile(abs, body); |
| 120 | + } |
| 121 | + return dir; |
| 122 | +} |
| 123 | + |
| 124 | +describe("readManifestJson malformed vs missing", () => { |
| 125 | + test("malformed manifest.json warns with path and parse error", async () => { |
| 126 | + const dir = await makeJsPlugin({ |
| 127 | + "index.js": "export {};\n", |
| 128 | + "manifest.json": "{not-json", |
| 129 | + }); |
| 130 | + const warnings: string[] = []; |
| 131 | + await loadPluginEntry(dir, { onWarning: (msg) => warnings.push(msg) }); |
| 132 | + const manifestPath = join(dir, "manifest.json"); |
| 133 | + expect(warnings.length).toBeGreaterThan(0); |
| 134 | + expect(warnings.some((w) => w.includes(manifestPath))).toBe(true); |
| 135 | + expect( |
| 136 | + warnings.some( |
| 137 | + (w) => w.includes(manifestPath) && w.includes("failed to parse"), |
| 138 | + ), |
| 139 | + ).toBe(true); |
| 140 | + }); |
| 141 | + |
| 142 | + test("invalid manifest.json schema warns with path and validation error", async () => { |
| 143 | + const dir = await makeJsPlugin({ |
| 144 | + "index.js": "export {};\n", |
| 145 | + "manifest.json": JSON.stringify({ id: "x", name: "X" }), |
| 146 | + }); |
| 147 | + const warnings: string[] = []; |
| 148 | + await loadPluginEntry(dir, { onWarning: (msg) => warnings.push(msg) }); |
| 149 | + const manifestPath = join(dir, "manifest.json"); |
| 150 | + expect(warnings.some((w) => w.includes(manifestPath))).toBe(true); |
| 151 | + expect( |
| 152 | + warnings.some((w) => w.includes(manifestPath) && w.includes("kind")), |
| 153 | + ).toBe(true); |
| 154 | + }); |
| 155 | + |
| 156 | + test("missing manifest.json stays silent", async () => { |
| 157 | + const dir = await makeJsPlugin({ |
| 158 | + "index.js": "export {};\n", |
| 159 | + }); |
| 160 | + const warnings: string[] = []; |
| 161 | + await loadPluginEntry(dir, { onWarning: (msg) => warnings.push(msg) }); |
| 162 | + expect(warnings).toEqual([]); |
| 163 | + }); |
| 164 | + |
| 165 | + test("malformed .claude-plugin/manifest.json warns on metadata-only load", async () => { |
| 166 | + const dir = await makeJsPlugin({ |
| 167 | + ".claude-plugin/manifest.json": "{not-json", |
| 168 | + }); |
| 169 | + const diag = createPluginLoadDiagnostics(); |
| 170 | + const cwd = await mkdtemp(join(tmpdir(), "manifest-cwd-")); |
| 171 | + const mods = await loadPluginsFromPaths([dir], cwd, { |
| 172 | + isPluginTrusted: () => false, |
| 173 | + diagnostics: diag, |
| 174 | + }); |
| 175 | + expect(mods).toEqual([]); |
| 176 | + const manifestPath = join(dir, ".claude-plugin", "manifest.json"); |
| 177 | + expect(diag.warnings.some((w) => w.includes(manifestPath))).toBe(true); |
| 178 | + expect( |
| 179 | + diag.warnings.some( |
| 180 | + (w) => w.includes(manifestPath) && w.includes("failed to parse"), |
| 181 | + ), |
| 182 | + ).toBe(true); |
| 183 | + }); |
| 184 | + |
| 185 | + test("missing manifest on metadata-only load stays silent", async () => { |
| 186 | + const dir = await mkdtemp(join(tmpdir(), "manifest-empty-")); |
| 187 | + const diag = createPluginLoadDiagnostics(); |
| 188 | + const cwd = await mkdtemp(join(tmpdir(), "manifest-cwd-")); |
| 189 | + const mods = await loadPluginsFromPaths([dir], cwd, { |
| 190 | + isPluginTrusted: () => false, |
| 191 | + diagnostics: diag, |
| 192 | + }); |
| 193 | + expect(mods).toEqual([]); |
| 194 | + expect(diag.warnings).toEqual([]); |
| 195 | + }); |
| 196 | + |
| 197 | + test("malformed native manifest.json on data-only plugin warns and does not silently infer kind", async () => { |
| 198 | + const dir = await makeJsPlugin({ |
| 199 | + "agents/a.md": "---\nname: a\n---\nbody\n", |
| 200 | + "manifest.json": "{not-json", |
| 201 | + }); |
| 202 | + const warnings: string[] = []; |
| 203 | + const mod = await loadPluginEntry(dir, { |
| 204 | + onWarning: (msg) => warnings.push(msg), |
| 205 | + }); |
| 206 | + const manifestPath = join(dir, "manifest.json"); |
| 207 | + expect( |
| 208 | + warnings.some( |
| 209 | + (w) => w.includes(manifestPath) && w.includes("failed to parse"), |
| 210 | + ), |
| 211 | + ).toBe(true); |
| 212 | + expect(mod).not.toBeNull(); |
| 213 | + expect(mod?.agentPlugin).toBeDefined(); |
| 214 | + }); |
| 215 | + |
| 216 | + test("Claude-format .claude-plugin/manifest.json does not warn missing id/kind on metadata-only load", async () => { |
| 217 | + const dir = await makeJsPlugin({ |
| 218 | + ".claude-plugin/manifest.json": JSON.stringify({ |
| 219 | + name: "cmo", |
| 220 | + description: "Marketing ops", |
| 221 | + }), |
| 222 | + }); |
| 223 | + const diag = createPluginLoadDiagnostics(); |
| 224 | + const cwd = await mkdtemp(join(tmpdir(), "manifest-cwd-")); |
| 225 | + const mods = await loadPluginsFromPaths([dir], cwd, { |
| 226 | + isPluginTrusted: () => false, |
| 227 | + diagnostics: diag, |
| 228 | + }); |
| 229 | + expect( |
| 230 | + diag.warnings.some((w) => w.includes("invalid plugin manifest")), |
| 231 | + ).toBe(false); |
| 232 | + expect( |
| 233 | + diag.warnings.some( |
| 234 | + (w) => |
| 235 | + w.includes(join(dir, ".claude-plugin", "manifest.json")) && |
| 236 | + (w.includes("id") || w.includes("kind")), |
| 237 | + ), |
| 238 | + ).toBe(false); |
| 239 | + const mod = mods.find((m) => m.manifest?.id === "cmo"); |
| 240 | + expect(mod?.metadataOnly).toBe(true); |
| 241 | + expect(mod?.manifest?.name).toBe("cmo"); |
| 242 | + expect(mod?.manifest?.description).toBe("Marketing ops"); |
| 243 | + }); |
| 244 | +}); |
0 commit comments