From 5c289cbd57a21c26248199d4dc20562ae9f9ac2a Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Mon, 28 Sep 2026 21:49:21 -0500 Subject: [PATCH] feat(workbench): add informational plugin renderer --- .../workbench/InformationalRenderer.jsx | 21 +++++++++++++ .../components/workbench/rendererRegistry.jsx | 4 ++- src/ui/lib/pluginApi.js | 31 +++++++++++++++++-- src/ui/pages/PluginPage.jsx | 3 +- tests/ui/plugin-api.test.js | 16 ++++++++++ tests/ui/plugin-page.test.jsx | 7 +++++ tests/ui/renderer-registry.test.js | 7 +++-- 7 files changed, 82 insertions(+), 7 deletions(-) create mode 100644 src/ui/components/workbench/InformationalRenderer.jsx diff --git a/src/ui/components/workbench/InformationalRenderer.jsx b/src/ui/components/workbench/InformationalRenderer.jsx new file mode 100644 index 0000000..5663701 --- /dev/null +++ b/src/ui/components/workbench/InformationalRenderer.jsx @@ -0,0 +1,21 @@ +import React from "react"; + +/** + * Render the finite, read-only informational descriptor vocabulary. + * Content is text data only; this component intentionally has no HTML or URL + * execution path. + */ +export default function InformationalRenderer({ descriptor }) { + const content = descriptor?.content || {}; + return ( +
+ {content.summary &&

{content.summary}

} + {(content.sections || []).map(section => ( +
+

{section.heading}

+ {section.paragraphs.map(paragraph =>

{paragraph}

)} +
+ ))} +
+ ); +} diff --git a/src/ui/components/workbench/rendererRegistry.jsx b/src/ui/components/workbench/rendererRegistry.jsx index 1733555..eb9da1b 100644 --- a/src/ui/components/workbench/rendererRegistry.jsx +++ b/src/ui/components/workbench/rendererRegistry.jsx @@ -1,4 +1,5 @@ import AsyncAnalysisRenderer from "./AsyncAnalysisRenderer"; +import InformationalRenderer from "./InformationalRenderer"; import QueryRenderer from "./QueryRenderer"; // Only a browser lifecycle with evidence in the current generic family is @@ -6,13 +7,14 @@ import QueryRenderer from "./QueryRenderer"; // it is not a second implementation or renderer family. export const RENDERER_REGISTRY = Object.freeze({ async_analysis: AsyncAnalysisRenderer, + informational: InformationalRenderer, query: QueryRenderer, }); const LEGACY_RENDERER_ALIASES = Object.freeze({ generic_runner: "async_analysis" }); export function normalizeRendererName(renderer) { - if (renderer === "async_analysis" || renderer === "query") return renderer; + if (renderer === "async_analysis" || renderer === "informational" || renderer === "query") return renderer; return typeof renderer === "string" && Object.prototype.hasOwnProperty.call(LEGACY_RENDERER_ALIASES, renderer) ? LEGACY_RENDERER_ALIASES[renderer] : null; diff --git a/src/ui/lib/pluginApi.js b/src/ui/lib/pluginApi.js index d852999..2b073f5 100644 --- a/src/ui/lib/pluginApi.js +++ b/src/ui/lib/pluginApi.js @@ -3,7 +3,7 @@ import { isElectron } from "./session"; const BASE = "/_svc/workbench"; const SLUG = /^[a-z0-9][a-z0-9_-]*$/; const ENDPOINT = /^\/plugins\/[a-z0-9][a-z0-9_-]*\/(?:api\/)?(?:run|status|log|artifacts|file|search|studies|experiments|variants|pathways|genes)\/(?:[A-Za-z0-9_.:-]+\/)?(?:\?[^#]*)?$/; -const NATIVE_RENDERERS = new Set(["async_analysis", "generic_runner", "query"]); +const NATIVE_RENDERERS = new Set(["async_analysis", "generic_runner", "informational", "query"]); const ASYNC_CAPABILITIES = ["submit", "status", "logs", "artifacts", "downloads"]; const ASYNC_ENDPOINTS = ["submit", "status", "logs", "artifacts", "download"]; const QUERY_CAPABILITIES = ["query", "detail"]; @@ -117,6 +117,26 @@ function validateQueryDescriptor(data) { if (detailEnabled) validatePluginEndpoint(data.endpoints.detail.replace("{detail_id}", "placeholder"), data.plugin.slug); } +function validateInformationalDescriptor(data) { + if (!Array.isArray(data.inputs) || data.inputs.length !== 0 || + !Array.isArray(data.outputs) || data.outputs.length !== 0 || + !data.capabilities || Object.keys(data.capabilities).join(",") !== "read_only" || + data.capabilities.read_only !== true || + !data.content || typeof data.content !== "object" || Array.isArray(data.content) || + Object.keys(data.content).sort().join(",") !== "sections,summary" || + typeof data.content.summary !== "string" || !Array.isArray(data.content.sections)) { + throw new PluginDescriptorError("Invalid informational descriptor."); + } + data.content.sections.forEach(section => { + if (!section || typeof section !== "object" || Array.isArray(section) || + Object.keys(section).sort().join(",") !== "heading,paragraphs" || + typeof section.heading !== "string" || !Array.isArray(section.paragraphs) || + section.paragraphs.some(paragraph => typeof paragraph !== "string")) { + throw new PluginDescriptorError("Invalid informational descriptor."); + } + }); +} + function validatePluginEndpoint(path, slug) { endpointUrl(path); const match = path.match(/^\/plugins\/([^/]+)\//); @@ -128,13 +148,20 @@ export function validatePluginDescriptor(data, slug) { throw new PluginDescriptorError("Workbench returned an invalid plugin descriptor."); } if (!data.native_supported) return data; - if (!NATIVE_RENDERERS.has(data.renderer) || !Array.isArray(data.inputs) || !Array.isArray(data.outputs) || !data.endpoints) { + if (!NATIVE_RENDERERS.has(data.renderer)) { throw new PluginDescriptorError("Unsupported plugin renderer."); } const metadata = data.plugin; if (["name", "version", "description", "category"].some(key => typeof metadata[key] !== "string")) { throw new PluginDescriptorError("Invalid plugin metadata."); } + if (data.renderer === "informational") { + validateInformationalDescriptor(data); + return data; + } + if (!Array.isArray(data.inputs) || !Array.isArray(data.outputs) || !data.endpoints) { + throw new PluginDescriptorError("Unsupported plugin renderer."); + } if (data.renderer === "query") { validateQueryDescriptor(data); return data; diff --git a/src/ui/pages/PluginPage.jsx b/src/ui/pages/PluginPage.jsx index 7c34d1f..ba18a9f 100644 --- a/src/ui/pages/PluginPage.jsx +++ b/src/ui/pages/PluginPage.jsx @@ -21,7 +21,8 @@ export default function PluginPage({ slug, url, label, onBack, backLabel = "Back }, [slug, attempt]); const Renderer = descriptor?.native_supported ? resolveWorkbenchRenderer(descriptor.renderer) : null; - if (error?.status === 404 || (descriptor && (descriptor.native_supported === false || !Renderer))) { + const invalidDescriptor = error?.name === "PluginDescriptorError" && error.status === 0; + if (error?.status === 404 || invalidDescriptor || (descriptor && (descriptor.native_supported === false || !Renderer))) { return ; } if (error) { diff --git a/tests/ui/plugin-api.test.js b/tests/ui/plugin-api.test.js index f78edb6..1cfb009 100644 --- a/tests/ui/plugin-api.test.js +++ b/tests/ui/plugin-api.test.js @@ -43,6 +43,14 @@ const noDetailQueryDescriptor = { result: { presentation: "table", rows_path: "results", columns: [{ key: "query", label: "Query" }] }, }; +const informationalDescriptor = { + schema_version: 1, + plugin: { slug: "biosamples", name: "BioSamples", version: "1.0.0", description: "desc", category: "reference_db" }, + renderer: "informational", native_supported: true, + inputs: [], outputs: [], capabilities: { read_only: true }, + content: { summary: "Read-only sample records.", sections: [] }, +}; + beforeEach(() => vi.stubGlobal("fetch", vi.fn())); afterEach(() => vi.unstubAllGlobals()); @@ -126,6 +134,14 @@ describe("plugin descriptor API boundary", () => { .toThrow("Invalid query result schema"); }); + it("accepts the finite informational contract and rejects executable or structured-content extensions", () => { + expect(validatePluginDescriptor(informationalDescriptor, "biosamples").renderer).toBe("informational"); + expect(() => validatePluginDescriptor({ ...informationalDescriptor, content: { ...informationalDescriptor.content, html: "