Extract plugin manifest shape validation seam
This commit is contained in:
parent
70336c6e30
commit
161ec56bf0
7 changed files with 174 additions and 109 deletions
|
|
@ -113,6 +113,7 @@ const behaviorTests = [
|
|||
".test-dist/tests/plugin-service-local-support.test.js",
|
||||
".test-dist/tests/plugin-service-ui-helpers.test.js",
|
||||
".test-dist/tests/plugin-manifest-validation-seams.test.js",
|
||||
".test-dist/tests/plugin-manifest-shape-validation.test.js",
|
||||
".test-dist/tests/plugins-view-config-list-editor.test.js",
|
||||
".test-dist/tests/plugins-view-config-fields.test.js",
|
||||
".test-dist/tests/plugin-ui-static.test.js",
|
||||
|
|
|
|||
|
|
@ -223,6 +223,7 @@ const pluginServiceSnapshotSource = readFileSync(join(appDir, "src", "plugin-ser
|
|||
const pluginServiceTextHelpersSource = readFileSync(join(appDir, "src", "plugin-service-text-helpers.ts"), "utf8");
|
||||
const pluginManifestSource = readFileSync(join(appDir, "src", "plugin-manifest.ts"), "utf8");
|
||||
const pluginManifestConfigValidationSource = readFileSync(join(appDir, "src", "plugin-manifest-config-validation.ts"), "utf8");
|
||||
const pluginManifestShapeValidationSource = readFileSync(join(appDir, "src", "plugin-manifest-shape-validation.ts"), "utf8");
|
||||
const loggerSource = readFileSync(join(appDir, "src", "logger.ts"), "utf8");
|
||||
const mainSource = readFileSync(join(appDir, "src", "main.ts"), "utf8");
|
||||
const localIpcSourceForLogging = readFileSync(join(appDir, "src", "local-ipc.ts"), "utf8");
|
||||
|
|
@ -587,8 +588,19 @@ assert.match(pluginServiceSnapshotSource, /export function validatePluginService
|
|||
assert.match(pluginServiceTextHelpersSource, /export async function ensurePluginCommandLocale/, "plugin-service text helper must export plugin locale preloading.");
|
||||
assert.match(pluginServiceTextHelpersSource, /export function safeCommandError/, "plugin-service text helper must export command error sanitization.");
|
||||
assert.match(pluginManifestSource, /from "\.\/plugin-manifest-config-validation(?:\.js)?"/, "plugin-manifest must import the extracted config/action validation seam.");
|
||||
assert.match(pluginManifestSource, /from "\.\/plugin-manifest-shape-validation(?:\.js)?"/, "plugin-manifest must import the extracted shape validation seam.");
|
||||
assert.match(pluginManifestConfigValidationSource, /export function validateConfigSchema/, "plugin-manifest config validation seam must export config schema validation.");
|
||||
assert.match(pluginManifestConfigValidationSource, /export function validateTriggers/, "plugin-manifest config validation seam must export trigger/action validation.");
|
||||
assert.match(pluginManifestShapeValidationSource, /export function validateAssets/, "plugin-manifest shape validation seam must export asset validation.");
|
||||
assert.match(pluginManifestShapeValidationSource, /export function validatePanels/, "plugin-manifest shape validation seam must export panel validation.");
|
||||
assert.match(pluginManifestShapeValidationSource, /export function validatePluginIcon/, "plugin-manifest shape validation seam must export icon validation.");
|
||||
assert.match(pluginManifestShapeValidationSource, /export function validateEntryPath/, "plugin-manifest shape validation seam must export entry path validation.");
|
||||
assert.match(pluginManifestShapeValidationSource, /export function validateNetwork/, "plugin-manifest shape validation seam must export network validation.");
|
||||
assert.match(pluginManifestShapeValidationSource, /export function validatePermissions/, "plugin-manifest shape validation seam must export shared permission validation.");
|
||||
assert.match(pluginManifestShapeValidationSource, /export function rejectUnknownFields/, "plugin-manifest shape validation seam must export unknown-field rejection.");
|
||||
assert.match(pluginManifestShapeValidationSource, /export function validateString/, "plugin-manifest shape validation seam must export string validation.");
|
||||
assert.match(pluginManifestShapeValidationSource, /export function addError/, "plugin-manifest shape validation seam must export manifest error shaping.");
|
||||
assert.match(pluginManifestShapeValidationSource, /export function isRecord/, "plugin-manifest shape validation seam must export record guards.");
|
||||
assert.match(controlCenterPluginsViewSource, /from "\.\/plugins-view-presentation"/, "plugins route must import the extracted presentation seam.");
|
||||
assert.match(controlCenterPluginsViewSource, /from "\.\/plugins-view-config-helpers"/, "plugins route must import the extracted config helper seam.");
|
||||
assert.match(controlCenterPluginsViewSource, /from "\.\/plugins-view-state"/, "plugins route must import the extracted route state seam.");
|
||||
|
|
|
|||
|
|
@ -290,8 +290,9 @@ plugin-service-local-support.ts → plugin-local-loader.ts validates selected fo
|
|||
- `zip-safety.ts`: ZIP entry path validation (traversal prevention, case collision detection)
|
||||
|
||||
**Plugins**:
|
||||
- `plugin-manifest.ts`: Manifest V1/V2 schema/types plus the top-level declarative/JavaScript entrypoint validation, permissions, asset/panel limits, and exported manifest constants.
|
||||
- `plugin-manifest.ts`: Manifest V1/V2 schema/types plus the top-level declarative/JavaScript entrypoint validation, permissions, and exported manifest constants.
|
||||
- `plugin-manifest-config-validation.ts`: Extracted config-schema plus timer trigger/action validation for plugin-manifest, including reaction-select config checks.
|
||||
- `plugin-manifest-shape-validation.ts`: Extracted manifest shape validation for shared string/object guards, entry/icon/network checks, and v3 asset/panel declarations.
|
||||
- `plugin-manifest-reader.ts`: Safe manifest reader with realpath/allowed-root checks, root filename enforcement, size limit, and expected id/version matching.
|
||||
- `plugin-config.ts`: Config defaulting, replacement validation, and runtime resolution for string/number config references.
|
||||
- `plugin-state.ts`: Persistent plugin state store (`familiaros-plugin-state.json`) with atomic temp+rename writes, normalized records, approved permissions, config, source, and broken reason.
|
||||
|
|
|
|||
119
apps/desktop/src/plugin-manifest-shape-validation.ts
Normal file
119
apps/desktop/src/plugin-manifest-shape-validation.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
type PluginAssetKind = "icons" | "images" | "svgs" | "sprites" | "sounds";
|
||||
type PluginManifestValidationError = {
|
||||
path: string;
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
type PluginAssetValidationOptions = {
|
||||
readonly assetKinds: readonly PluginAssetKind[];
|
||||
readonly assetExtensions: Record<PluginAssetKind, readonly string[]>;
|
||||
readonly assetNamePattern: RegExp;
|
||||
};
|
||||
|
||||
export function validateAssets(value: unknown, errors: PluginManifestValidationError[], options: PluginAssetValidationOptions): void {
|
||||
if (value === undefined) return;
|
||||
if (!isRecord(value)) return addError(errors, "$.assets", "invalid_assets", "assets must be an object.");
|
||||
rejectUnknownFields(value, new Set(options.assetKinds), "$.assets", errors);
|
||||
for (const kind of options.assetKinds) {
|
||||
const group = value[kind];
|
||||
if (group === undefined) continue;
|
||||
if (!isRecord(group)) {
|
||||
addError(errors, `$.assets.${kind}`, "invalid_assets", `assets.${kind} must be an object.`);
|
||||
continue;
|
||||
}
|
||||
const entries = Object.entries(group);
|
||||
if (entries.length > 32) addError(errors, `$.assets.${kind}`, "too_many_assets", `assets.${kind} may declare at most 32 entries.`);
|
||||
for (const [name, assetPath] of entries) {
|
||||
const path = `$.assets.${kind}.${name}`;
|
||||
if (!options.assetNamePattern.test(name)) addError(errors, path, "invalid_asset_name", "Asset names must be simple lowercase identifiers.");
|
||||
validateRelativeAssetPath(assetPath, options.assetExtensions[kind], path, errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function validatePanels(value: unknown, errors: PluginManifestValidationError[], assetNamePattern: RegExp): void {
|
||||
if (value === undefined) return;
|
||||
if (!isRecord(value)) return addError(errors, "$.panels", "invalid_panels", "panels must be an object.");
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length > 8) addError(errors, "$.panels", "too_many_panels", "panels may declare at most 8 entries.");
|
||||
for (const [name, panelPath] of entries) {
|
||||
const path = `$.panels.${name}`;
|
||||
if (!assetNamePattern.test(name)) addError(errors, path, "invalid_panel_name", "Panel names must be simple lowercase identifiers.");
|
||||
validateRelativeAssetPath(panelPath, [".html"], path, errors);
|
||||
}
|
||||
}
|
||||
|
||||
export function validatePluginIcon(value: unknown, errors: PluginManifestValidationError[], supportedPluginIcons: ReadonlySet<string>): void {
|
||||
if (value === undefined) return;
|
||||
if (typeof value !== "string" || !supportedPluginIcons.has(value)) addError(errors, "$.icon", "invalid_icon", "icon must be one of plugin, bell, timer, github, heart, sparkles, coffee, focus, or droplet.");
|
||||
}
|
||||
|
||||
export function validateEntryPath(value: unknown, errors: PluginManifestValidationError[]): void {
|
||||
validateString(value, "$.entry", "entry", errors);
|
||||
if (typeof value !== "string") return;
|
||||
if (value.startsWith("/") || value.includes("\\") || value.split("/").includes("..") || !/\.(?:mjs|js)$/.test(value)) addError(errors, "$.entry", "invalid_entry", "entry must be a relative .js or .mjs path.");
|
||||
}
|
||||
|
||||
export function validateNetwork(value: unknown, errors: PluginManifestValidationError[]): void {
|
||||
if (value === undefined) return;
|
||||
if (!isRecord(value) || !Array.isArray(value.hosts)) return addError(errors, "$.network.hosts", "invalid_network_hosts", "network.hosts must be an array.");
|
||||
rejectUnknownFields(value, new Set(["hosts"]), "$.network", errors);
|
||||
const hosts = value.hosts;
|
||||
hosts.forEach((host, index) => {
|
||||
if (typeof host !== "string" || !/^[a-z0-9.-]+(?::\d{1,5})?$/i.test(host) || host.includes("*") || host.trim() !== host) {
|
||||
addError(errors, `$.network.hosts[${index}]`, "invalid_network_host", "network hosts must be exact host names.");
|
||||
} else if (hosts.indexOf(host) !== index) {
|
||||
addError(errors, `$.network.hosts[${index}]`, "duplicate_network_host", "Duplicate network host.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function validatePermissions(value: unknown, errors: PluginManifestValidationError[], allowedPermissions: ReadonlySet<string>): Set<string> {
|
||||
const permissions = new Set<string>();
|
||||
if (!Array.isArray(value)) {
|
||||
addError(errors, "$.permissions", "invalid_permissions", "permissions must be an array.");
|
||||
return permissions;
|
||||
}
|
||||
value.forEach((permission, index) => {
|
||||
if (typeof permission !== "string" || !allowedPermissions.has(permission)) {
|
||||
addError(errors, `$.permissions[${index}]`, "invalid_permission", `Permission must be one of ${[...allowedPermissions].join(", ")}.`);
|
||||
return;
|
||||
}
|
||||
if (permissions.has(permission)) {
|
||||
addError(errors, `$.permissions[${index}]`, "duplicate_permission", `Duplicate permission ${permission}.`);
|
||||
return;
|
||||
}
|
||||
permissions.add(permission);
|
||||
});
|
||||
return permissions;
|
||||
}
|
||||
|
||||
export function rejectUnknownFields(record: Record<string, unknown>, allowed: Set<string>, path: string, errors: PluginManifestValidationError[]): void {
|
||||
for (const key of Object.keys(record)) {
|
||||
if (!allowed.has(key)) addError(errors, `${path}.${key}`, "unknown_field", `Unknown field ${key}.`);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateString(value: unknown, path: string, label: string, errors: PluginManifestValidationError[], pattern?: RegExp): void {
|
||||
if (typeof value !== "string" || value.trim() === "") return addError(errors, path, "invalid_string", `${label} must be a non-empty string.`);
|
||||
if (pattern && !pattern.test(value)) addError(errors, path, "invalid_format", `${label} has an invalid format.`);
|
||||
}
|
||||
|
||||
export function addError(errors: PluginManifestValidationError[], path: string, code: string, message: string): void {
|
||||
errors.push({ path, code, message });
|
||||
}
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function validateRelativeAssetPath(value: unknown, allowedExtensions: readonly string[], path: string, errors: PluginManifestValidationError[]): void {
|
||||
if (typeof value !== "string" || value.trim() === "") return addError(errors, path, "invalid_asset_path", "Asset path must be a non-empty string.");
|
||||
if (value.startsWith("/") || value.includes("\\") || value.split("/").includes("..") || value.split("/").includes(".")) {
|
||||
return addError(errors, path, "invalid_asset_path", "Asset path must be a safe relative path.");
|
||||
}
|
||||
if (!allowedExtensions.some((extension) => value.toLowerCase().endsWith(extension))) {
|
||||
addError(errors, path, "invalid_asset_format", `Asset must end with one of ${allowedExtensions.join(", ")}.`);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,18 @@ import {
|
|||
validateConfigSchema,
|
||||
validateTriggers,
|
||||
} from "./plugin-manifest-config-validation.js";
|
||||
import {
|
||||
addError,
|
||||
isRecord,
|
||||
rejectUnknownFields,
|
||||
validateAssets,
|
||||
validateEntryPath,
|
||||
validateNetwork,
|
||||
validatePanels,
|
||||
validatePermissions,
|
||||
validatePluginIcon,
|
||||
validateString,
|
||||
} from "./plugin-manifest-shape-validation.js";
|
||||
|
||||
export const FAMILIAROS_PLUGIN_MANIFEST_FILENAME = "familiaros.plugin.json";
|
||||
export const openPetsPluginManifestFilename = FAMILIAROS_PLUGIN_MANIFEST_FILENAME;
|
||||
|
|
@ -195,7 +207,7 @@ export function validatePluginManifest(input: unknown): PluginManifestValidation
|
|||
validateString(input.name, "$.name", "name", errors);
|
||||
if (input.description !== undefined) validateString(input.description, "$.description", "description", errors);
|
||||
validateString(input.version, "$.version", "version", errors, /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/);
|
||||
validatePluginIcon(input.icon, errors);
|
||||
validatePluginIcon(input.icon, errors, supportedPluginIcons);
|
||||
|
||||
if (input.runtime === "javascript") {
|
||||
addError(errors, "$.runtime", "unsupported_runtime", 'Runtime "javascript" is recognized but unsupported in manifest v1. Use "declarative".');
|
||||
|
|
@ -218,7 +230,7 @@ function validateJavascriptPluginManifest(input: Record<string, unknown>, manife
|
|||
validateString(input.name, "$.name", "name", errors);
|
||||
if (input.description !== undefined) validateString(input.description, "$.description", "description", errors);
|
||||
validateString(input.version, "$.version", "version", errors, /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/);
|
||||
validatePluginIcon(input.icon, errors);
|
||||
validatePluginIcon(input.icon, errors, supportedPluginIcons);
|
||||
if (input.runtime !== "javascript") addError(errors, "$.runtime", "invalid_runtime", `manifestVersion ${manifestVersion} runtime must be "javascript".`);
|
||||
validateString(input.sdkVersion, "$.sdkVersion", "sdkVersion", errors, /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/);
|
||||
if (manifestVersion === 3 && typeof input.sdkVersion === "string" && !input.sdkVersion.startsWith("3.")) {
|
||||
|
|
@ -229,8 +241,8 @@ function validateJavascriptPluginManifest(input: Record<string, unknown>, manife
|
|||
validateConfigSchema(input.configSchema, errors, manifestVersion);
|
||||
validateNetwork(input.network, errors);
|
||||
if (manifestVersion === 3) {
|
||||
validateAssets(input.assets, errors);
|
||||
validatePanels(input.panels, errors);
|
||||
validateAssets(input.assets, errors, { assetKinds: pluginAssetKinds, assetExtensions: pluginAssetExtensions, assetNamePattern });
|
||||
validatePanels(input.panels, errors, assetNamePattern);
|
||||
}
|
||||
if (errors.length > 0) return { ok: false, errors };
|
||||
return { ok: true, manifest: input as FamiliarOSPluginManifest, errors: [] };
|
||||
|
|
@ -238,109 +250,7 @@ function validateJavascriptPluginManifest(input: Record<string, unknown>, manife
|
|||
|
||||
function validateJavascriptPermissions(value: unknown, manifestVersion: 2 | 3, errors: PluginManifestValidationError[]): Set<string> {
|
||||
const allowed = new Set<string>(manifestVersion === 3 ? javascriptPluginPermissionsV3 : javascriptPluginPermissionsV2);
|
||||
const permissions = validatePermissions(value, errors);
|
||||
const permissions = validatePermissions(value, errors, allowed);
|
||||
for (const permission of permissions) if (!allowed.has(permission)) addError(errors, "$.permissions", "invalid_permission", `Permission ${permission} is not valid for manifestVersion ${manifestVersion} javascript plugins.`);
|
||||
return permissions;
|
||||
}
|
||||
|
||||
function validateRelativeAssetPath(value: unknown, allowedExtensions: readonly string[], path: string, errors: PluginManifestValidationError[]): void {
|
||||
if (typeof value !== "string" || value.trim() === "") return addError(errors, path, "invalid_asset_path", "Asset path must be a non-empty string.");
|
||||
if (value.startsWith("/") || value.includes("\\") || value.split("/").includes("..") || value.split("/").includes(".")) {
|
||||
return addError(errors, path, "invalid_asset_path", "Asset path must be a safe relative path.");
|
||||
}
|
||||
if (!allowedExtensions.some((extension) => value.toLowerCase().endsWith(extension))) {
|
||||
addError(errors, path, "invalid_asset_format", `Asset must end with one of ${allowedExtensions.join(", ")}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateAssets(value: unknown, errors: PluginManifestValidationError[]): void {
|
||||
if (value === undefined) return;
|
||||
if (!isRecord(value)) return addError(errors, "$.assets", "invalid_assets", "assets must be an object.");
|
||||
rejectUnknownFields(value, new Set(pluginAssetKinds), "$.assets", errors);
|
||||
for (const kind of pluginAssetKinds) {
|
||||
const group = value[kind];
|
||||
if (group === undefined) continue;
|
||||
if (!isRecord(group)) { addError(errors, `$.assets.${kind}`, "invalid_assets", `assets.${kind} must be an object.`); continue; }
|
||||
const entries = Object.entries(group);
|
||||
if (entries.length > 32) addError(errors, `$.assets.${kind}`, "too_many_assets", `assets.${kind} may declare at most 32 entries.`);
|
||||
for (const [name, assetPath] of entries) {
|
||||
const path = `$.assets.${kind}.${name}`;
|
||||
if (!assetNamePattern.test(name)) addError(errors, path, "invalid_asset_name", "Asset names must be simple lowercase identifiers.");
|
||||
validateRelativeAssetPath(assetPath, pluginAssetExtensions[kind], path, errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validatePanels(value: unknown, errors: PluginManifestValidationError[]): void {
|
||||
if (value === undefined) return;
|
||||
if (!isRecord(value)) return addError(errors, "$.panels", "invalid_panels", "panels must be an object.");
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length > 8) addError(errors, "$.panels", "too_many_panels", "panels may declare at most 8 entries.");
|
||||
for (const [name, panelPath] of entries) {
|
||||
const path = `$.panels.${name}`;
|
||||
if (!assetNamePattern.test(name)) addError(errors, path, "invalid_panel_name", "Panel names must be simple lowercase identifiers.");
|
||||
validateRelativeAssetPath(panelPath, [".html"], path, errors);
|
||||
}
|
||||
}
|
||||
|
||||
function validatePluginIcon(value: unknown, errors: PluginManifestValidationError[]): void {
|
||||
if (value === undefined) return;
|
||||
if (typeof value !== "string" || !supportedPluginIcons.has(value)) addError(errors, "$.icon", "invalid_icon", "icon must be one of plugin, bell, timer, github, heart, sparkles, coffee, focus, or droplet.");
|
||||
}
|
||||
|
||||
function validateEntryPath(value: unknown, errors: PluginManifestValidationError[]): void {
|
||||
validateString(value, "$.entry", "entry", errors);
|
||||
if (typeof value !== "string") return;
|
||||
if (value.startsWith("/") || value.includes("\\") || value.split("/").includes("..") || !/\.(?:mjs|js)$/.test(value)) addError(errors, "$.entry", "invalid_entry", "entry must be a relative .js or .mjs path.");
|
||||
}
|
||||
|
||||
function validateNetwork(value: unknown, errors: PluginManifestValidationError[]): void {
|
||||
if (value === undefined) return;
|
||||
if (!isRecord(value) || !Array.isArray(value.hosts)) return addError(errors, "$.network.hosts", "invalid_network_hosts", "network.hosts must be an array.");
|
||||
rejectUnknownFields(value, new Set(["hosts"]), "$.network", errors);
|
||||
const seen = new Set<string>();
|
||||
value.hosts.forEach((host, index) => {
|
||||
if (typeof host !== "string" || !/^[a-z0-9.-]+(?::\d{1,5})?$/i.test(host) || host.includes("*") || host.trim() !== host) addError(errors, `$.network.hosts[${index}]`, "invalid_network_host", "network hosts must be exact host names.");
|
||||
else if (seen.has(host)) addError(errors, `$.network.hosts[${index}]`, "duplicate_network_host", "Duplicate network host.");
|
||||
seen.add(String(host));
|
||||
});
|
||||
}
|
||||
|
||||
function validatePermissions(value: unknown, errors: PluginManifestValidationError[], allowedPermissions: ReadonlySet<string> = pluginPermissionSet): Set<string> {
|
||||
const permissions = new Set<string>();
|
||||
if (!Array.isArray(value)) {
|
||||
addError(errors, "$.permissions", "invalid_permissions", "permissions must be an array.");
|
||||
return permissions;
|
||||
}
|
||||
value.forEach((permission, index) => {
|
||||
if (typeof permission !== "string" || !allowedPermissions.has(permission)) {
|
||||
addError(errors, `$.permissions[${index}]`, "invalid_permission", `Permission must be one of ${[...allowedPermissions].join(", ")}.`);
|
||||
return;
|
||||
}
|
||||
if (permissions.has(permission)) {
|
||||
addError(errors, `$.permissions[${index}]`, "duplicate_permission", `Duplicate permission ${permission}.`);
|
||||
return;
|
||||
}
|
||||
permissions.add(permission);
|
||||
});
|
||||
return permissions;
|
||||
}
|
||||
|
||||
function rejectUnknownFields(record: Record<string, unknown>, allowed: Set<string>, path: string, errors: PluginManifestValidationError[]): void {
|
||||
for (const key of Object.keys(record)) {
|
||||
if (!allowed.has(key)) addError(errors, `${path}.${key}`, "unknown_field", `Unknown field ${key}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateString(value: unknown, path: string, label: string, errors: PluginManifestValidationError[], pattern?: RegExp): void {
|
||||
if (typeof value !== "string" || value.trim() === "") return addError(errors, path, "invalid_string", `${label} must be a non-empty string.`);
|
||||
if (pattern && !pattern.test(value)) addError(errors, path, "invalid_format", `${label} has an invalid format.`);
|
||||
}
|
||||
|
||||
function addError(errors: PluginManifestValidationError[], path: string, code: string, message: string): void {
|
||||
errors.push({ path, code, message });
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
|
|
|||
22
apps/desktop/tests/plugin-manifest-shape-validation.test.ts
Normal file
22
apps/desktop/tests/plugin-manifest-shape-validation.test.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const desktopRoot = process.env.FAMILIAROS_DESKTOP_ROOT ?? resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const pluginManifestSource = readFileSync(resolve(desktopRoot, "src/plugin-manifest.ts"), "utf8");
|
||||
const pluginManifestShapeValidationSource = readFileSync(resolve(desktopRoot, "src/plugin-manifest-shape-validation.ts"), "utf8");
|
||||
|
||||
assert.match(pluginManifestSource, /from "\.\/plugin-manifest-shape-validation(?:\.js)?"/, "plugin-manifest must import the extracted shape validation seam.");
|
||||
assert.match(pluginManifestShapeValidationSource, /export function validateAssets/, "plugin-manifest shape validation seam must export asset validation.");
|
||||
assert.match(pluginManifestShapeValidationSource, /export function validatePanels/, "plugin-manifest shape validation seam must export panel validation.");
|
||||
assert.match(pluginManifestShapeValidationSource, /export function validatePluginIcon/, "plugin-manifest shape validation seam must export icon validation.");
|
||||
assert.match(pluginManifestShapeValidationSource, /export function validateEntryPath/, "plugin-manifest shape validation seam must export entry path validation.");
|
||||
assert.match(pluginManifestShapeValidationSource, /export function validateNetwork/, "plugin-manifest shape validation seam must export network validation.");
|
||||
assert.match(pluginManifestShapeValidationSource, /export function validatePermissions/, "plugin-manifest shape validation seam must export shared permission validation.");
|
||||
assert.match(pluginManifestShapeValidationSource, /export function rejectUnknownFields/, "plugin-manifest shape validation seam must export unknown-field rejection.");
|
||||
assert.match(pluginManifestShapeValidationSource, /export function validateString/, "plugin-manifest shape validation seam must export string validation.");
|
||||
assert.match(pluginManifestShapeValidationSource, /export function addError/, "plugin-manifest shape validation seam must export manifest error shaping.");
|
||||
assert.match(pluginManifestShapeValidationSource, /export function isRecord/, "plugin-manifest shape validation seam must export record guards.");
|
||||
|
||||
console.error("Plugin manifest shape validation seam passed.");
|
||||
|
|
@ -12,4 +12,4 @@ assert.match(pluginManifestConfigValidationSource, /export function validateConf
|
|||
assert.match(pluginManifestConfigValidationSource, /export function validateTriggers/, "plugin-manifest config validation seam must export trigger and action validation.");
|
||||
assert.match(pluginManifestConfigValidationSource, /allowedReactions/, "plugin-manifest config validation seam must own reaction-select validation.");
|
||||
|
||||
console.error("Plugin manifest validation seam passed.");
|
||||
console.error("Plugin manifest config validation seam passed.");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue