feat(FN-1779): merge fusion/fn-1779

This commit is contained in:
gsxdsm
2026-04-14 21:30:56 -07:00
parent 2fbb22ee10
commit 9a14947a2a
12 changed files with 728 additions and 13 deletions

View File

@@ -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);

View File

@@ -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,

View File

@@ -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",

View File

@@ -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`);
}
}
}
}