feat(FN-1779): merge fusion/fn-1779
This commit is contained in:
@@ -110,6 +110,40 @@ describe("PluginStore", () => {
|
||||
expect(plugin.settingsSchema!.count.defaultValue).toBe(5);
|
||||
});
|
||||
|
||||
it("applies default values from settingsSchema when registering", async () => {
|
||||
const manifest = makeManifest({
|
||||
settingsSchema: {
|
||||
apiKey: { type: "string", defaultValue: "default-key" },
|
||||
count: { type: "number", defaultValue: 10 },
|
||||
enabled: { type: "boolean", defaultValue: true },
|
||||
},
|
||||
});
|
||||
const plugin = await store.registerPlugin({ manifest, path: "/path/to/plugin" });
|
||||
|
||||
// Defaults should be applied
|
||||
expect(plugin.settings.apiKey).toBe("default-key");
|
||||
expect(plugin.settings.count).toBe(10);
|
||||
expect(plugin.settings.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("overrides defaults with explicit settings", async () => {
|
||||
const manifest = makeManifest({
|
||||
settingsSchema: {
|
||||
apiKey: { type: "string", defaultValue: "default-key" },
|
||||
count: { type: "number", defaultValue: 10 },
|
||||
},
|
||||
});
|
||||
const plugin = await store.registerPlugin({
|
||||
manifest,
|
||||
path: "/path/to/plugin",
|
||||
settings: { apiKey: "custom-key", count: 20 },
|
||||
});
|
||||
|
||||
// Explicit settings should win over defaults
|
||||
expect(plugin.settings.apiKey).toBe("custom-key");
|
||||
expect(plugin.settings.count).toBe(20);
|
||||
});
|
||||
|
||||
it("rejects missing manifest id", async () => {
|
||||
const manifest = makeManifest({ id: "" });
|
||||
await expect(
|
||||
@@ -509,6 +543,83 @@ describe("PluginStore", () => {
|
||||
).rejects.toThrow('Setting "color" must be one of');
|
||||
});
|
||||
|
||||
it("validates password type as string", async () => {
|
||||
const manifest = makeManifest({
|
||||
settingsSchema: {
|
||||
apiSecret: { type: "password" },
|
||||
},
|
||||
});
|
||||
await store.registerPlugin({
|
||||
manifest,
|
||||
path: "/path/to/plugin",
|
||||
settings: {},
|
||||
});
|
||||
|
||||
// Valid: string value for password
|
||||
const plugin1 = await store.updatePluginSettings("test-plugin", {
|
||||
apiSecret: "valid-secret",
|
||||
});
|
||||
expect(plugin1.settings.apiSecret).toBe("valid-secret");
|
||||
|
||||
// Invalid: non-string value for password
|
||||
await expect(
|
||||
store.updatePluginSettings("test-plugin", { apiSecret: 12345 }),
|
||||
).rejects.toThrow('Setting "apiSecret" must be a string');
|
||||
});
|
||||
|
||||
it("validates array type", async () => {
|
||||
const manifest = makeManifest({
|
||||
settingsSchema: {
|
||||
tags: { type: "array", itemType: "string" },
|
||||
},
|
||||
});
|
||||
await store.registerPlugin({
|
||||
manifest,
|
||||
path: "/path/to/plugin",
|
||||
settings: {},
|
||||
});
|
||||
|
||||
// Valid: array of strings
|
||||
const plugin1 = await store.updatePluginSettings("test-plugin", {
|
||||
tags: ["bug", "feature"],
|
||||
});
|
||||
expect(plugin1.settings.tags).toEqual(["bug", "feature"]);
|
||||
|
||||
// Invalid: non-array value
|
||||
await expect(
|
||||
store.updatePluginSettings("test-plugin", { tags: "not-an-array" }),
|
||||
).rejects.toThrow('Setting "tags" must be an array');
|
||||
|
||||
// Invalid: array with wrong item type
|
||||
await expect(
|
||||
store.updatePluginSettings("test-plugin", { tags: [1, 2, 3] }),
|
||||
).rejects.toThrow('Setting "tags" must be an array of string');
|
||||
});
|
||||
|
||||
it("validates number array type", async () => {
|
||||
const manifest = makeManifest({
|
||||
settingsSchema: {
|
||||
scores: { type: "array", itemType: "number" },
|
||||
},
|
||||
});
|
||||
await store.registerPlugin({
|
||||
manifest,
|
||||
path: "/path/to/plugin",
|
||||
settings: {},
|
||||
});
|
||||
|
||||
// Valid: array of numbers
|
||||
const plugin1 = await store.updatePluginSettings("test-plugin", {
|
||||
scores: [10, 20, 30],
|
||||
});
|
||||
expect(plugin1.settings.scores).toEqual([10, 20, 30]);
|
||||
|
||||
// Invalid: array with wrong item type
|
||||
await expect(
|
||||
store.updatePluginSettings("test-plugin", { scores: ["a", "b"] }),
|
||||
).rejects.toThrow('Setting "scores" must be an array of number');
|
||||
});
|
||||
|
||||
it("emits plugin:updated event", async () => {
|
||||
const listener = vi.fn();
|
||||
store.on("plugin:updated", listener);
|
||||
|
||||
@@ -120,6 +120,8 @@ export class PluginStore extends EventEmitter<PluginStoreEvents> {
|
||||
const expectedType = settingSchema.type;
|
||||
if (expectedType === "string" && typeof value !== "string") {
|
||||
errors.push(`Setting "${key}" must be a string`);
|
||||
} else if (expectedType === "password" && typeof value !== "string") {
|
||||
errors.push(`Setting "${key}" must be a string`);
|
||||
} else if (expectedType === "number" && typeof value !== "number") {
|
||||
errors.push(`Setting "${key}" must be a number`);
|
||||
} else if (expectedType === "boolean" && typeof value !== "boolean") {
|
||||
@@ -130,6 +132,22 @@ export class PluginStore extends EventEmitter<PluginStoreEvents> {
|
||||
`Setting "${key}" must be one of: ${settingSchema.enumValues?.join(", ")}`,
|
||||
);
|
||||
}
|
||||
} else if (expectedType === "array") {
|
||||
if (!Array.isArray(value)) {
|
||||
errors.push(`Setting "${key}" must be an array`);
|
||||
} else {
|
||||
// Validate item types
|
||||
const itemType = settingSchema.itemType;
|
||||
for (const item of value) {
|
||||
if (itemType === "string" && typeof item !== "string") {
|
||||
errors.push(`Setting "${key}" must be an array of string`);
|
||||
break;
|
||||
} else if (itemType === "number" && typeof item !== "number") {
|
||||
errors.push(`Setting "${key}" must be an array of number`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +190,17 @@ export class PluginStore extends EventEmitter<PluginStoreEvents> {
|
||||
});
|
||||
}
|
||||
|
||||
// Compute defaults from settingsSchema and merge with provided settings
|
||||
const defaultSettings: Record<string, unknown> = {};
|
||||
if (manifest.settingsSchema) {
|
||||
for (const [key, schema] of Object.entries(manifest.settingsSchema)) {
|
||||
if (schema.defaultValue !== undefined) {
|
||||
defaultSettings[key] = schema.defaultValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
const mergedSettings = { ...defaultSettings, ...settings };
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const plugin: PluginInstallation = {
|
||||
id: manifest.id,
|
||||
@@ -183,7 +212,7 @@ export class PluginStore extends EventEmitter<PluginStoreEvents> {
|
||||
path: path.trim(),
|
||||
enabled: true,
|
||||
state: "installed",
|
||||
settings,
|
||||
settings: mergedSettings,
|
||||
settingsSchema: manifest.settingsSchema,
|
||||
dependencies: manifest.dependencies || [],
|
||||
createdAt: now,
|
||||
|
||||
@@ -96,6 +96,36 @@ describe("validatePluginManifest", () => {
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts password and array types in settingsSchema", () => {
|
||||
const manifest = {
|
||||
id: "test",
|
||||
name: "Test",
|
||||
version: "1.0.0",
|
||||
settingsSchema: {
|
||||
apiSecret: { type: "password", label: "API Secret" },
|
||||
tags: { type: "array", label: "Tags", itemType: "string" },
|
||||
scores: { type: "array", label: "Scores", itemType: "number" },
|
||||
},
|
||||
};
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts string with multiline option", () => {
|
||||
const manifest = {
|
||||
id: "test",
|
||||
name: "Test",
|
||||
version: "1.0.0",
|
||||
settingsSchema: {
|
||||
description: { type: "string", label: "Description", multiline: true },
|
||||
},
|
||||
};
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Missing Required Fields ─────────────────────────────────────────
|
||||
@@ -301,7 +331,7 @@ describe("validatePluginManifest", () => {
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
"settingsSchema.setting1.type must be one of: string, number, boolean, enum",
|
||||
"settingsSchema.setting1.type must be one of: string, number, boolean, enum, password, array",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -333,6 +363,34 @@ describe("validatePluginManifest", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects array type without itemType", () => {
|
||||
const manifest = {
|
||||
id: "test",
|
||||
name: "Test",
|
||||
version: "1.0.0",
|
||||
settingsSchema: { setting1: { type: "array" } },
|
||||
};
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
"settingsSchema.setting1.itemType is required and must be \"string\" or \"number\" when type is array",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects array type with invalid itemType", () => {
|
||||
const manifest = {
|
||||
id: "test",
|
||||
name: "Test",
|
||||
version: "1.0.0",
|
||||
settingsSchema: { setting1: { type: "array", itemType: "boolean" } },
|
||||
};
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
"settingsSchema.setting1.itemType is required and must be \"string\" or \"number\" when type is array",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects multiple invalid settings", () => {
|
||||
const manifest = {
|
||||
id: "test",
|
||||
|
||||
@@ -41,7 +41,7 @@ export interface PluginManifest {
|
||||
|
||||
// ── Plugin Setting Schema ──────────────────────────────────────────────
|
||||
|
||||
export type PluginSettingType = "string" | "number" | "boolean" | "enum";
|
||||
export type PluginSettingType = "string" | "number" | "boolean" | "enum" | "password" | "array";
|
||||
|
||||
/**
|
||||
* Schema for a single plugin setting.
|
||||
@@ -55,6 +55,10 @@ export interface PluginSettingSchema {
|
||||
required?: boolean;
|
||||
/** Only when type is "enum" */
|
||||
enumValues?: string[];
|
||||
/** Only when type is "string" - renders as textarea when true */
|
||||
multiline?: boolean;
|
||||
/** Only when type is "array" - type of items in the array */
|
||||
itemType?: "string" | "number";
|
||||
}
|
||||
|
||||
// ── Plugin Hooks ─────────────────────────────────────────────────────
|
||||
@@ -250,12 +254,15 @@ export function validatePluginManifest(manifest: unknown): { valid: boolean; err
|
||||
continue;
|
||||
}
|
||||
const setting = schema as Record<string, unknown>;
|
||||
if (!setting.type || !["string", "number", "boolean", "enum"].includes(setting.type as string)) {
|
||||
errors.push(`settingsSchema.${key}.type must be one of: string, number, boolean, enum`);
|
||||
if (!setting.type || !["string", "number", "boolean", "enum", "password", "array"].includes(setting.type as string)) {
|
||||
errors.push(`settingsSchema.${key}.type must be one of: string, number, boolean, enum, password, array`);
|
||||
}
|
||||
if (setting.type === "enum" && (!Array.isArray(setting.enumValues) || setting.enumValues.length === 0)) {
|
||||
errors.push(`settingsSchema.${key}.enumValues is required and must be a non-empty array when type is enum`);
|
||||
}
|
||||
if (setting.type === "array" && (!setting.itemType || !["string", "number"].includes(setting.itemType as string))) {
|
||||
errors.push(`settingsSchema.${key}.itemType is required and must be "string" or "number" when type is array`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user