mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: load plugin frontends at runtime and remove plugin ids from the shell
This commit is contained in:
1 parent
49f0286a14
commit
e88d5a52e3
721 files changed
+54265
-44416
No files matched your search
@@ -30,6 +30,8 @@ import syncRoutes from "./routes/sync.js";
|
||||
import pluginApiRoutes from "./routes/plugin-api-routes.js";
|
||||
import { attachPluginWebSockets } from "../plugins/ws.js";
|
||||
import pluginRoutes from "./routes/plugins.js";
|
||||
import { createPluginAssetsRouter } from "../plugins/assets.js";
|
||||
import { getPluginRuntime } from "../plugins/index.js";
|
||||
import { createCorsMiddleware } from "../utils/cors-config.js";
|
||||
import { createCompressionMiddleware } from "../utils/compression-config.js";
|
||||
import fs from "fs";
|
||||
@@ -1779,6 +1781,10 @@ app.use("/secret-sources", secretSourceRoutes);
|
||||
app.use("/", notificationChannelsRoutes);
|
||||
app.use("/sync", syncRoutes);
|
||||
app.use("/plugins", pluginRoutes);
|
||||
app.use(
|
||||
"/plugin-assets",
|
||||
createPluginAssetsRouter((id) => getPluginRuntime().loader.get(id)),
|
||||
);
|
||||
app.use("/plugin-api", authenticateJWT, pluginApiRoutes);
|
||||
|
||||
const frontendDistPaths = [
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
createCurrentPluginRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import { getPluginRuntime } from "../../plugins/index.js";
|
||||
import { describePluginFrontend } from "../../plugins/assets.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import {
|
||||
findField,
|
||||
@@ -44,6 +45,65 @@ const requireManagePlugins = permissionManager.requirePermission(
|
||||
"admin.plugins.manage",
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/public:
|
||||
* get:
|
||||
* summary: List the plugin frontends anonymous guest pages need
|
||||
* description: >
|
||||
* No auth. A shared-session or collab guest link has no session, yet its
|
||||
* page still draws surfaces a plugin provides (a remote desktop stream).
|
||||
* Returns only enabled plugins whose manifest sets contributes.guest,
|
||||
* with the fields the browser loader needs and nothing operational.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Guest-capable plugins.
|
||||
*/
|
||||
router.get("/public", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const records = await createCurrentPluginRepository().listAll();
|
||||
const { loader } = getPluginRuntime();
|
||||
const plugins = records.flatMap((record) => {
|
||||
if (record.state !== "enabled") return [];
|
||||
const loaded = loader.get(record.id);
|
||||
let manifest: {
|
||||
contributes?: { guest?: boolean };
|
||||
dependencies?: Record<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
};
|
||||
try {
|
||||
manifest = JSON.parse(record.manifestJson);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
if (manifest?.contributes?.guest !== true) return [];
|
||||
return [
|
||||
{
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
version: record.version,
|
||||
enabled: true,
|
||||
state: loaded?.state ?? record.state,
|
||||
contributes: { guest: true },
|
||||
dependencies: manifest.dependencies ?? {},
|
||||
optionalDependencies: manifest.optionalDependencies ?? {},
|
||||
...describePluginFrontend(loaded),
|
||||
},
|
||||
];
|
||||
});
|
||||
res.json(plugins);
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to list guest plugins",
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
{ operation: "plugin_list_public" },
|
||||
);
|
||||
res.status(500).json({ error: "Failed to list plugins" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins:
|
||||
@@ -61,6 +121,13 @@ const requireManagePlugins = permissionManager.requirePermission(
|
||||
* do, what has been granted to it and why it failed are operational
|
||||
* details, so capabilities, grants and lastError are included only for
|
||||
* holders of admin.plugins.manage.
|
||||
*
|
||||
* The frontend fields drive the browser's plugin loader: `frontend`
|
||||
* says there is a bundle at /plugin-assets/<id>/frontend.js,
|
||||
* `assetVersion` is its cache key, `css` says a stylesheet sits beside
|
||||
* it, and `locales` lists the languages it ships ("en" and xx_YY).
|
||||
* `dependencies` and `optionalDependencies` let the loader activate
|
||||
* frontends in the same order the server does.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* responses:
|
||||
@@ -100,17 +167,23 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
let contributes: unknown = null;
|
||||
let capabilities: string[] = [];
|
||||
let icon: string | undefined;
|
||||
let dependencies: Record<string, string> = {};
|
||||
let optionalDependencies: Record<string, string> = {};
|
||||
try {
|
||||
const manifest = JSON.parse(record.manifestJson) as {
|
||||
contributes?: unknown;
|
||||
capabilities?: unknown;
|
||||
icon?: unknown;
|
||||
dependencies?: Record<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
};
|
||||
contributes = manifest?.contributes ?? null;
|
||||
capabilities = Array.isArray(manifest?.capabilities)
|
||||
? (manifest.capabilities as string[])
|
||||
: [];
|
||||
icon = typeof manifest?.icon === "string" ? manifest.icon : undefined;
|
||||
dependencies = manifest?.dependencies ?? {};
|
||||
optionalDependencies = manifest?.optionalDependencies ?? {};
|
||||
} catch {
|
||||
contributes = null;
|
||||
}
|
||||
@@ -123,6 +196,9 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
state: loaded?.state ?? record.state,
|
||||
contributes,
|
||||
icon,
|
||||
dependencies,
|
||||
optionalDependencies,
|
||||
...describePluginFrontend(loaded),
|
||||
};
|
||||
|
||||
if (!canManage) return summary;
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
// Serves a plugin's built frontend: its bundle, stylesheet and locales.
|
||||
//
|
||||
// Unauthenticated on purpose, the same as /assets: import() cannot carry the
|
||||
// bearer header, and nothing here is secret. It only ever serves files from
|
||||
// the two directories a plugin ships for the browser, so a request can never
|
||||
// reach the plugin's backend bundle, migrations or anything outside it.
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import {
|
||||
DEFAULT_FRONTEND_ENTRY,
|
||||
DEFAULT_LOCALES_DIR,
|
||||
type PluginManifest,
|
||||
} from "@termix/plugin-sdk/manifest";
|
||||
import type { LoadedPlugin } from "./loader.js";
|
||||
|
||||
type AssetPlugin = Pick<LoadedPlugin, "id" | "dir" | "manifest">;
|
||||
|
||||
export interface PluginFrontendInfo {
|
||||
frontend: boolean;
|
||||
css: boolean;
|
||||
/** Changes whenever the built bundle does, for cache busting. */
|
||||
assetVersion: string | null;
|
||||
/** Language codes with a locale file, "en" plus xx_YY names. */
|
||||
locales: string[];
|
||||
}
|
||||
|
||||
function frontendEntry(
|
||||
plugin: AssetPlugin,
|
||||
): { dir: string; file: string } | null {
|
||||
const manifest = plugin.manifest as PluginManifest;
|
||||
const entry = path.resolve(
|
||||
plugin.dir,
|
||||
manifest.frontend ?? DEFAULT_FRONTEND_ENTRY,
|
||||
);
|
||||
if (!isInside(plugin.dir, entry)) return null;
|
||||
return { dir: path.dirname(entry), file: entry };
|
||||
}
|
||||
|
||||
function localesDir(plugin: AssetPlugin): string | null {
|
||||
const manifest = plugin.manifest as PluginManifest;
|
||||
const dir = path.resolve(plugin.dir, manifest.locales ?? DEFAULT_LOCALES_DIR);
|
||||
return isInside(plugin.dir, dir) ? dir : null;
|
||||
}
|
||||
|
||||
function isInside(root: string, target: string): boolean {
|
||||
const relative = path.relative(path.resolve(root), path.resolve(target));
|
||||
return (
|
||||
relative === "" ||
|
||||
(!relative.startsWith("..") && !path.isAbsolute(relative))
|
||||
);
|
||||
}
|
||||
|
||||
function fileExists(file: string): boolean {
|
||||
try {
|
||||
return fs.statSync(file).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function describePluginFrontend(
|
||||
plugin: AssetPlugin | undefined,
|
||||
): PluginFrontendInfo {
|
||||
const none: PluginFrontendInfo = {
|
||||
frontend: false,
|
||||
css: false,
|
||||
assetVersion: null,
|
||||
locales: [],
|
||||
};
|
||||
if (!plugin) return none;
|
||||
|
||||
const locales: string[] = [];
|
||||
const dir = localesDir(plugin);
|
||||
if (dir && fileExists(path.join(dir, "en.json"))) locales.push("en");
|
||||
if (dir) {
|
||||
try {
|
||||
for (const name of fs.readdirSync(path.join(dir, "translated"))) {
|
||||
if (/^[a-z]{2}_[A-Z]{2}\.json$/.test(name)) {
|
||||
locales.push(name.slice(0, -".json".length));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// No translations yet.
|
||||
}
|
||||
}
|
||||
|
||||
const entry = frontendEntry(plugin);
|
||||
if (!entry || !fileExists(entry.file)) return { ...none, locales };
|
||||
|
||||
const stat = fs.statSync(entry.file);
|
||||
const assetVersion = crypto
|
||||
.createHash("sha256")
|
||||
.update(`${plugin.manifest.version}:${stat.size}:${stat.mtimeMs}`)
|
||||
.digest("hex")
|
||||
.slice(0, 12);
|
||||
|
||||
return {
|
||||
frontend: true,
|
||||
css: fileExists(path.join(entry.dir, "frontend.css")),
|
||||
assetVersion,
|
||||
locales: locales.sort(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a request path under /plugin-assets/<id>/ to a file, or null.
|
||||
*
|
||||
* `locales/...` reads the plugin's locales directory; anything else reads the
|
||||
* directory the frontend bundle sits in. Only .js, .css, .json and .map are
|
||||
* served, and never a path that escapes either directory.
|
||||
*/
|
||||
export function resolvePluginAsset(
|
||||
plugin: AssetPlugin,
|
||||
requestPath: string,
|
||||
): string | null {
|
||||
const clean = requestPath.replace(/^\/+/, "");
|
||||
if (!clean || clean.includes("\0") || clean.includes("\\")) return null;
|
||||
if (!/\.(js|css|json|map)$/.test(clean)) return null;
|
||||
|
||||
let root: string | null;
|
||||
let rest: string;
|
||||
if (clean.startsWith("locales/")) {
|
||||
root = localesDir(plugin);
|
||||
rest = clean.slice("locales/".length);
|
||||
} else {
|
||||
root = frontendEntry(plugin)?.dir ?? null;
|
||||
rest = clean;
|
||||
// The backend bundle sits beside the frontend one in dist/.
|
||||
if (/^backend\.js(\.map)?$/.test(rest)) return null;
|
||||
}
|
||||
if (!root) return null;
|
||||
|
||||
const file = path.resolve(root, rest);
|
||||
if (!isInside(root, file) || !fileExists(file)) return null;
|
||||
return file;
|
||||
}
|
||||
|
||||
const CONTENT_TYPES: Record<string, string> = {
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".map": "application/json; charset=utf-8",
|
||||
};
|
||||
|
||||
export function createPluginAssetsRouter(
|
||||
getPlugin: (id: string) => AssetPlugin | undefined,
|
||||
): express.Router {
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-assets/{pluginId}/{path}:
|
||||
* get:
|
||||
* summary: Serve a plugin's frontend bundle, stylesheet or locale file
|
||||
* description: >
|
||||
* Public, like the app's own /assets, because a module import cannot
|
||||
* send an auth header and plugin frontends are not secret. Serves only
|
||||
* the directory holding the plugin's frontend bundle and its locales
|
||||
* directory. A `v` query parameter (the assetVersion from GET
|
||||
* /plugins) makes the response cacheable for a year.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: pluginId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: path
|
||||
* name: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: v
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The file.
|
||||
* 404:
|
||||
* description: Unknown plugin or file.
|
||||
*/
|
||||
router.get(
|
||||
/^\/([a-z][a-z0-9-]{1,39})\/(.+)$/,
|
||||
(req: Request, res: Response) => {
|
||||
const params = req.params as unknown as Record<string, string>;
|
||||
const pluginId = params[0];
|
||||
const plugin = getPlugin(pluginId);
|
||||
const file = plugin ? resolvePluginAsset(plugin, params[1]) : null;
|
||||
|
||||
// Loaded as a module from Electron's file:// page and the Vite dev
|
||||
// server, so any origin may read it. No credentials are involved.
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.removeHeader("Access-Control-Allow-Credentials");
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
|
||||
if (!file) {
|
||||
res.status(404).json({ error: "Not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
res.setHeader(
|
||||
"Cache-Control",
|
||||
typeof req.query.v === "string" && req.query.v
|
||||
? "public, max-age=31536000, immutable"
|
||||
: "no-cache",
|
||||
);
|
||||
res.type(CONTENT_TYPES[path.extname(file)] ?? "application/octet-stream");
|
||||
res.sendFile(file);
|
||||
},
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -92,7 +92,9 @@ vi.mock("../../../utils/permission-manager.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../../plugins/index.js", () => ({
|
||||
getPluginRuntime: () => ({ loader: { list: () => [] } }),
|
||||
getPluginRuntime: () => ({
|
||||
loader: { list: () => [], get: () => undefined },
|
||||
}),
|
||||
activatePlugin: vi.fn(),
|
||||
deactivatePlugin: vi.fn(),
|
||||
}));
|
||||
@@ -180,6 +182,50 @@ describe("plugins route", () => {
|
||||
server = null;
|
||||
});
|
||||
|
||||
describe("GET /plugins/public", () => {
|
||||
it("lists only enabled plugins that opt into guest pages", async () => {
|
||||
state.plugins.set(
|
||||
"guest-plugin",
|
||||
makePlugin({
|
||||
id: "guest-plugin",
|
||||
manifestJson: JSON.stringify({
|
||||
capabilities: ["hosts:read"],
|
||||
contributes: { guest: true },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
state.plugins.set(
|
||||
"off-guest",
|
||||
makePlugin({
|
||||
id: "off-guest",
|
||||
state: "disabled",
|
||||
manifestJson: JSON.stringify({ contributes: { guest: true } }),
|
||||
}),
|
||||
);
|
||||
state.plugins.set("sample-plugin", makePlugin());
|
||||
|
||||
const res = await fetch(`${baseUrl}/plugins/public`);
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.map((plugin: { id: string }) => plugin.id)).toEqual([
|
||||
"guest-plugin",
|
||||
]);
|
||||
expect(body[0].contributes).toEqual({ guest: true });
|
||||
expect(body[0]).not.toHaveProperty("capabilities");
|
||||
expect(body[0]).not.toHaveProperty("lastError");
|
||||
});
|
||||
|
||||
it("skips a plugin whose manifest does not parse", async () => {
|
||||
state.plugins.set(
|
||||
"broken",
|
||||
makePlugin({ id: "broken", manifestJson: "{" }),
|
||||
);
|
||||
const res = await fetch(`${baseUrl}/plugins/public`);
|
||||
expect(await res.json()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("lists a plugin's declared and granted capabilities", async () => {
|
||||
state.plugins.set("sample-plugin", makePlugin());
|
||||
state.grants.push({
|
||||
@@ -335,6 +381,12 @@ describe("plugins route", () => {
|
||||
enabled: true,
|
||||
state: "enabled",
|
||||
contributes: null,
|
||||
dependencies: {},
|
||||
optionalDependencies: {},
|
||||
frontend: false,
|
||||
css: false,
|
||||
assetVersion: null,
|
||||
locales: [],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { Server } from "node:http";
|
||||
import express from "express";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createPluginAssetsRouter,
|
||||
describePluginFrontend,
|
||||
resolvePluginAsset,
|
||||
} from "../../plugins/assets.js";
|
||||
|
||||
let root = "";
|
||||
let plugin: { id: string; dir: string; manifest: never };
|
||||
let server: Server;
|
||||
let baseUrl = "";
|
||||
|
||||
function write(relative: string, content: string) {
|
||||
const file = path.join(root, "gizmo", relative);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, content);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-assets-"));
|
||||
write("dist/frontend.js", "export function activate() {}");
|
||||
write("dist/frontend.css", ".gizmo{}");
|
||||
write("dist/backend.js", "secret()");
|
||||
write("dist/notes.txt", "text");
|
||||
write("locales/en.json", "{}");
|
||||
write("locales/translated/de_DE.json", "{}");
|
||||
write("manifest.json", "{}");
|
||||
fs.writeFileSync(path.join(root, "outside.js"), "nope");
|
||||
plugin = {
|
||||
id: "gizmo",
|
||||
dir: path.join(root, "gizmo"),
|
||||
manifest: { id: "gizmo", version: "1.0.0" } as never,
|
||||
};
|
||||
|
||||
const app = express();
|
||||
app.use(
|
||||
"/plugin-assets",
|
||||
createPluginAssetsRouter((id) => (id === "gizmo" ? plugin : undefined)),
|
||||
);
|
||||
server = await new Promise<Server>((resolve) => {
|
||||
const created = app.listen(0, "127.0.0.1", () => resolve(created));
|
||||
});
|
||||
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("describePluginFrontend", () => {
|
||||
it("reports the bundle, stylesheet, version and locales", () => {
|
||||
const info = describePluginFrontend(plugin);
|
||||
expect(info.frontend).toBe(true);
|
||||
expect(info.css).toBe(true);
|
||||
expect(info.assetVersion).toMatch(/^[0-9a-f]{12}$/);
|
||||
expect(info.locales).toEqual(["de_DE", "en"]);
|
||||
});
|
||||
|
||||
it("reports nothing for an unknown plugin", () => {
|
||||
expect(describePluginFrontend(undefined)).toEqual({
|
||||
frontend: false,
|
||||
css: false,
|
||||
assetVersion: null,
|
||||
locales: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePluginAsset", () => {
|
||||
it("serves the bundle and locale files", () => {
|
||||
expect(resolvePluginAsset(plugin, "frontend.js")).toBe(
|
||||
path.join(root, "gizmo", "dist", "frontend.js"),
|
||||
);
|
||||
expect(resolvePluginAsset(plugin, "locales/translated/de_DE.json")).toBe(
|
||||
path.join(root, "gizmo", "locales", "translated", "de_DE.json"),
|
||||
);
|
||||
});
|
||||
|
||||
it("never serves the backend bundle", () => {
|
||||
expect(resolvePluginAsset(plugin, "backend.js")).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses paths that escape the plugin's directories", () => {
|
||||
expect(resolvePluginAsset(plugin, "../manifest.json")).toBeNull();
|
||||
expect(resolvePluginAsset(plugin, "../../outside.js")).toBeNull();
|
||||
expect(resolvePluginAsset(plugin, "locales/../dist/backend.js")).toBeNull();
|
||||
expect(resolvePluginAsset(plugin, "..\\manifest.json")).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses file types it does not serve", () => {
|
||||
expect(resolvePluginAsset(plugin, "notes.txt")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /plugin-assets", () => {
|
||||
it("serves a bundle as a module any origin can load", async () => {
|
||||
const res = await fetch(`${baseUrl}/plugin-assets/gizmo/frontend.js`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toContain("text/javascript");
|
||||
expect(res.headers.get("access-control-allow-origin")).toBe("*");
|
||||
expect(res.headers.get("cache-control")).toBe("no-cache");
|
||||
expect(await res.text()).toContain("activate");
|
||||
});
|
||||
|
||||
it("caches a versioned request for good", async () => {
|
||||
const res = await fetch(`${baseUrl}/plugin-assets/gizmo/frontend.js?v=abc`);
|
||||
expect(res.headers.get("cache-control")).toContain("immutable");
|
||||
});
|
||||
|
||||
it("404s an unknown plugin", async () => {
|
||||
const res = await fetch(`${baseUrl}/plugin-assets/nobody/frontend.js`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("404s the backend bundle and traversal attempts", async () => {
|
||||
for (const target of [
|
||||
"gizmo/backend.js",
|
||||
"gizmo/%2e%2e/manifest.json",
|
||||
"gizmo/..%2f..%2foutside.js",
|
||||
]) {
|
||||
const res = await fetch(`${baseUrl}/plugin-assets/${target}`);
|
||||
expect(res.status, target).toBe(404);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -119,11 +119,9 @@ describe("unknown field rejection", () => {
|
||||
});
|
||||
|
||||
it("rejects an unknown field inside contributes", () => {
|
||||
const errors = validateManifest(
|
||||
base({ contributes: { dashboardCards: [] } }),
|
||||
);
|
||||
const errors = validateManifest(base({ contributes: { widgets: [] } }));
|
||||
|
||||
expect(errors.join()).toMatch(/Unknown field "dashboardCards"/);
|
||||
expect(errors.join()).toMatch(/Unknown field "widgets"/);
|
||||
});
|
||||
|
||||
it("rejects an unknown field inside a tab", () => {
|
||||
@@ -557,3 +555,50 @@ describe("tabs and host capabilities", () => {
|
||||
).toMatch(/duplicates/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("panels, dashboard cards and guest pages", () => {
|
||||
it("accepts declared panels, cards and the guest flag", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
base({
|
||||
contributes: {
|
||||
panels: [{ id: "sample-panel", titleKey: "nav.panel" }],
|
||||
dashboardCards: [
|
||||
{ id: "sample-card", titleKey: "cards.sample", icon: "Box" },
|
||||
],
|
||||
guest: true,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects duplicate view ids and missing title keys", () => {
|
||||
const errors = validateManifest(
|
||||
base({
|
||||
contributes: {
|
||||
panels: [
|
||||
{ id: "p", titleKey: "a" },
|
||||
{ id: "p", titleKey: "b" },
|
||||
],
|
||||
dashboardCards: [{ id: "c" }],
|
||||
},
|
||||
}),
|
||||
).join();
|
||||
expect(errors).toMatch(/panels\[1\]\.id duplicates "p"/);
|
||||
expect(errors).toMatch(/dashboardCards\[0\]\.titleKey/);
|
||||
});
|
||||
|
||||
it("rejects an unknown field inside a view and a non-boolean guest", () => {
|
||||
const errors = validateManifest(
|
||||
base({
|
||||
contributes: {
|
||||
panels: [{ id: "p", titleKey: "a", width: 3 }],
|
||||
guest: "yes",
|
||||
},
|
||||
}),
|
||||
).join();
|
||||
expect(errors).toMatch(/Unknown field "width"/);
|
||||
expect(errors).toMatch(/contributes\.guest" must be a boolean/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user