feat(FN-4128): fix bundled plugin entry resolution and surface load errors
Bundled plugin entry resolution is fixed to use proper import.meta.url path normalization, with plugin load errors now surfaced in the PluginManager UI and comprehensive regression tests added for the bundled plugin path migration and state handling. Fusion-Task-Id: FN-4128
This commit is contained in:
5
.changeset/fn-4128-dependency-graph-error-state.md
Normal file
5
.changeset/fn-4128-dependency-graph-error-state.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix the bundled dependency-graph plugin so it no longer lands in a phantom error state in Settings when the Graph view still works, and show the underlying plugin error message in Plugin Manager whenever a plugin is in the error state.
|
||||
@@ -3,18 +3,24 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
// ── Mocks ────────────────────────────────────────────────────────────
|
||||
// vi.mock factories are hoisted, so we use vi.hoisted() for mock references.
|
||||
|
||||
const { mockExistsSync, mockReadFile, mockValidatePluginManifest } = vi.hoisted(() => ({
|
||||
const { mockExistsSync, mockStatSync, mockReadFile, mockFsStat, mockCopyFile, mockValidatePluginManifest } = vi.hoisted(() => ({
|
||||
mockExistsSync: vi.fn<(path: string) => boolean>(),
|
||||
mockStatSync: vi.fn<(path: string) => { isDirectory: () => boolean }>(),
|
||||
mockReadFile: vi.fn<(path: string, encoding: string) => Promise<string>>(),
|
||||
mockFsStat: vi.fn<(path: string) => Promise<{ isDirectory: () => boolean }>>(),
|
||||
mockCopyFile: vi.fn<(src: string, dest: string) => Promise<void>>(),
|
||||
mockValidatePluginManifest: vi.fn<(manifest: unknown) => { valid: boolean; errors: string[] }>(),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: mockExistsSync,
|
||||
statSync: mockStatSync,
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", () => ({
|
||||
readFile: mockReadFile,
|
||||
stat: mockFsStat,
|
||||
copyFile: mockCopyFile,
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/core", () => ({
|
||||
@@ -194,6 +200,9 @@ async function getResolvedBundledPath(): Promise<string> {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockStatSync.mockImplementation(() => ({ isDirectory: () => false }));
|
||||
mockFsStat.mockImplementation(async () => ({ isDirectory: () => false }));
|
||||
mockCopyFile.mockResolvedValue();
|
||||
});
|
||||
|
||||
describe("resolvePluginEntryPath", () => {
|
||||
@@ -216,6 +225,11 @@ describe("resolvePluginEntryPath", () => {
|
||||
mockExistsSync.mockImplementation((p: string) => p.endsWith("/src/index.ts"));
|
||||
expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/src/index.ts");
|
||||
});
|
||||
|
||||
it("returns null when no loadable entry file exists", () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
expect(resolvePluginEntryPath("/tmp/plugin")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureBundledDependencyGraphPluginInstalled", () => {
|
||||
@@ -276,7 +290,7 @@ describe("ensureBundledDependencyGraphPluginInstalled", () => {
|
||||
|
||||
it("already installed with stale path → updates path to current bundled path", async () => {
|
||||
const bundledPath = await getResolvedBundledPath();
|
||||
const OLD_PATH = "/old/cli/dist/plugins/fusion-plugin-dependency-graph";
|
||||
const OLD_PATH = "/old/cli/dist/plugins/fusion-plugin-dependency-graph/bundled.js";
|
||||
|
||||
vi.clearAllMocks();
|
||||
const manifest = setupBundleExists();
|
||||
@@ -344,6 +358,51 @@ describe("ensureBundledDependencyGraphPluginInstalled", () => {
|
||||
expect(loader.loadPlugin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("migrates an existing directory-backed install to the resolved entry file", async () => {
|
||||
const bundledPath = await getResolvedBundledPath();
|
||||
const staleDirectoryPath = "/old/cli/dist/plugins/fusion-plugin-dependency-graph";
|
||||
|
||||
vi.clearAllMocks();
|
||||
setupBundleExists();
|
||||
mockStatSync.mockImplementation((path: string) => ({
|
||||
isDirectory: () => path === staleDirectoryPath,
|
||||
}));
|
||||
const store = makePluginStore();
|
||||
const loader = makePluginLoader();
|
||||
|
||||
store._inject(makePlugin({ path: staleDirectoryPath }));
|
||||
|
||||
const result = await ensureBundledDependencyGraphPluginInstalled(
|
||||
store as unknown as import("@fusion/core").PluginStore,
|
||||
loader as unknown as import("@fusion/core").PluginLoader,
|
||||
);
|
||||
|
||||
expect(result).toBe("updated");
|
||||
expect(store.updatePlugin).toHaveBeenCalledWith(
|
||||
BUNDLED_PLUGIN_ID,
|
||||
expect.objectContaining({ path: bundledPath }),
|
||||
);
|
||||
expect(loader.loadPlugin).toHaveBeenCalledWith(BUNDLED_PLUGIN_ID);
|
||||
});
|
||||
|
||||
it("returns missing-bundle when manifest exists but no loadable entry file exists", async () => {
|
||||
mockExistsSync.mockImplementation((p: string) => typeof p === "string" && p.endsWith("manifest.json") && p.includes("dist"));
|
||||
mockReadFile.mockResolvedValue(JSON.stringify(makeManifest()));
|
||||
mockValidatePluginManifest.mockReturnValue({ valid: true, errors: [] });
|
||||
const store = makePluginStore();
|
||||
const loader = makePluginLoader();
|
||||
|
||||
const result = await ensureBundledDependencyGraphPluginInstalled(
|
||||
store as unknown as import("@fusion/core").PluginStore,
|
||||
loader as unknown as import("@fusion/core").PluginLoader,
|
||||
);
|
||||
|
||||
expect(result).toBe("missing-bundle");
|
||||
expect(store.registerPlugin).not.toHaveBeenCalled();
|
||||
expect(store.updatePlugin).not.toHaveBeenCalled();
|
||||
expect(loader.loadPlugin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("missing bundle (no bundled manifest found) → returns missing-bundle without error", async () => {
|
||||
setupBundleMissing();
|
||||
const store = makePluginStore();
|
||||
@@ -474,4 +533,65 @@ describe("ensureBundledDependencyGraphPluginInstalled", () => {
|
||||
const registerCall = store.registerPlugin.mock.calls[0]?.[0] as { path: string };
|
||||
expect(registerCall.path).toContain(`${HERMES_PLUGIN_ID}/bundled.js`);
|
||||
});
|
||||
|
||||
it("loads the real bundled dependency graph plugin and persists a started state", async () => {
|
||||
const { existsSync, mkdtempSync, statSync } = await vi.importActual<typeof import("node:fs")>("node:fs");
|
||||
const { cp, mkdir, readFile, rm, stat, copyFile } = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
const { fileURLToPath } = await import("node:url");
|
||||
const { buildSync } = await import("esbuild");
|
||||
const { PluginLoader } = await import("../../../../core/src/plugin-loader.ts");
|
||||
const { PluginStore } = await import("../../../../core/src/plugin-store.ts");
|
||||
|
||||
const repoRoot = fileURLToPath(new URL("../../../../../", import.meta.url));
|
||||
const sourceRoot = fileURLToPath(new URL("../../../../../plugins/fusion-plugin-dependency-graph", import.meta.url));
|
||||
const stagedRoot = fileURLToPath(new URL("../../../plugins/fusion-plugin-dependency-graph", import.meta.url));
|
||||
const pluginStateRoot = mkdtempSync(join(tmpdir(), "fn4128-bundled-plugin-"));
|
||||
|
||||
await rm(stagedRoot, { recursive: true, force: true });
|
||||
await mkdir(stagedRoot, { recursive: true });
|
||||
await cp(join(sourceRoot, "manifest.json"), join(stagedRoot, "manifest.json"));
|
||||
|
||||
buildSync({
|
||||
entryPoints: [join(sourceRoot, "src", "index.ts")],
|
||||
outfile: join(stagedRoot, "bundled.js"),
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
alias: {
|
||||
"@fusion/plugin-sdk": join(repoRoot, "packages", "plugin-sdk", "src", "index.ts"),
|
||||
},
|
||||
logLevel: "silent",
|
||||
});
|
||||
|
||||
mockExistsSync.mockImplementation((path: string) => existsSync(path));
|
||||
mockStatSync.mockImplementation((path: string) => statSync(path));
|
||||
mockReadFile.mockImplementation((path: string, encoding: string) => readFile(path, encoding as BufferEncoding));
|
||||
mockFsStat.mockImplementation((path: string) => stat(path));
|
||||
mockCopyFile.mockImplementation((src: string, dest: string) => copyFile(src, dest));
|
||||
mockValidatePluginManifest.mockReturnValue({ valid: true, errors: [] });
|
||||
|
||||
try {
|
||||
const pluginStore = new PluginStore(pluginStateRoot, { inMemoryDb: true, centralGlobalDir: pluginStateRoot });
|
||||
await pluginStore.init();
|
||||
const taskStore = {
|
||||
getRootDir: () => repoRoot,
|
||||
logActivity: vi.fn(),
|
||||
getPluginStore: () => pluginStore,
|
||||
} as any;
|
||||
const loader = new PluginLoader({ pluginStore, taskStore });
|
||||
|
||||
const result = await ensureBundledDependencyGraphPluginInstalled(pluginStore, loader);
|
||||
const storedPlugin = await pluginStore.getPlugin(BUNDLED_PLUGIN_ID);
|
||||
|
||||
expect(result).toBe("installed");
|
||||
expect(storedPlugin.path.endsWith("/fusion-plugin-dependency-graph/bundled.js")).toBe(true);
|
||||
expect(storedPlugin.state).toBe("started");
|
||||
expect(storedPlugin.error ?? null).toBeNull();
|
||||
} finally {
|
||||
await rm(stagedRoot, { recursive: true, force: true });
|
||||
await rm(pluginStateRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -69,9 +69,12 @@ function resolveBundledPluginDir(pluginId: string): string | null {
|
||||
* 1. ./bundled.js (esbuild-bundled, shipped in npm tarball)
|
||||
* 2. ./dist/index.js (legacy prebuilt fallback)
|
||||
* 3. ./src/index.ts (workspace/dev fallback when no bundle exists)
|
||||
* 4. fall back to the directory itself
|
||||
*
|
||||
* Returns null when the directory exists but none of the loadable entry files
|
||||
* are present. Callers must treat that as a missing bundle rather than
|
||||
* persisting a directory path that Node cannot import.
|
||||
*/
|
||||
export function resolvePluginEntryPath(pluginDir: string): string {
|
||||
export function resolvePluginEntryPath(pluginDir: string): string | null {
|
||||
const candidates = [
|
||||
join(pluginDir, "bundled.js"),
|
||||
join(pluginDir, "dist", "index.js"),
|
||||
@@ -82,7 +85,15 @@ export function resolvePluginEntryPath(pluginDir: string): string {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return pluginDir;
|
||||
return null;
|
||||
}
|
||||
|
||||
function isDirectoryPath(path: string): boolean {
|
||||
try {
|
||||
return statSync(path).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureBundledPluginInstalled(
|
||||
@@ -105,16 +116,22 @@ export async function ensureBundledPluginInstalled(
|
||||
const manifest = await loadManifest(bundledDir);
|
||||
const entryPath = resolvePluginEntryPath(bundledDir);
|
||||
|
||||
if (!entryPath) {
|
||||
console.warn(`[plugins] Bundled plugin "${pluginId}" is missing a loadable entry file in ${bundledDir}`);
|
||||
return "missing-bundle";
|
||||
}
|
||||
|
||||
if (existingPlugin) {
|
||||
const pathChanged = existingPlugin.path !== entryPath;
|
||||
const existingPathIsDirectory = isDirectoryPath(existingPlugin.path);
|
||||
const pathChanged = existingPathIsDirectory || existingPlugin.path !== entryPath;
|
||||
const versionChanged = existingPlugin.version !== manifest.version;
|
||||
|
||||
if (!pathChanged && !versionChanged) {
|
||||
if (existingPlugin.enabled) {
|
||||
try {
|
||||
await pluginLoader.loadPlugin(existingPlugin.id);
|
||||
} catch {
|
||||
// best-effort
|
||||
} catch (err) {
|
||||
console.warn("[plugins] failed to load bundled plugin", existingPlugin.id, err);
|
||||
}
|
||||
}
|
||||
return "already-installed";
|
||||
@@ -128,8 +145,8 @@ export async function ensureBundledPluginInstalled(
|
||||
if (existingPlugin.enabled) {
|
||||
try {
|
||||
await pluginLoader.loadPlugin(existingPlugin.id);
|
||||
} catch {
|
||||
// best-effort
|
||||
} catch (err) {
|
||||
console.warn("[plugins] failed to load bundled plugin", existingPlugin.id, err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,8 +161,8 @@ export async function ensureBundledPluginInstalled(
|
||||
if (plugin.enabled) {
|
||||
try {
|
||||
await pluginLoader.loadPlugin(plugin.id);
|
||||
} catch {
|
||||
// best-effort
|
||||
} catch (err) {
|
||||
console.warn("[plugins] failed to load bundled plugin", plugin.id, err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -425,6 +425,31 @@ describe("PluginLoader", () => {
|
||||
expect(updated.state).toBe("started");
|
||||
});
|
||||
|
||||
it("recovers a previously errored plugin to started and clears the stored error", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
const plugin = makePlugin(makeManifest({ id: "recover-error-test" }));
|
||||
const pluginDir = join(rootDir, "plugins");
|
||||
const pluginPath = await writePluginModule(pluginDir, "recover-error.js", plugin);
|
||||
|
||||
await pluginStore.registerPlugin({
|
||||
manifest: plugin.manifest,
|
||||
path: pluginPath,
|
||||
});
|
||||
await pluginStore.updatePluginState("recover-error-test", "error", "previous load failed");
|
||||
|
||||
const loader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: mockTaskStore,
|
||||
});
|
||||
|
||||
await loader.loadPlugin("recover-error-test");
|
||||
|
||||
const updated = await pluginStore.getPlugin("recover-error-test");
|
||||
expect(updated.state).toBe("started");
|
||||
expect(updated.error ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it("skips disabled plugins", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
|
||||
import { basename, dirname, extname, isAbsolute, resolve } from "node:path";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { copyFile } from "node:fs/promises";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { EventEmitter } from "node:events";
|
||||
@@ -313,6 +314,18 @@ export class PluginLoader extends EventEmitter<{
|
||||
return this.loadedModules.get(path)!;
|
||||
}
|
||||
|
||||
let pathStats;
|
||||
try {
|
||||
pathStats = await stat(path);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`Plugin entry does not exist: ${path} (${errorMessage})`);
|
||||
}
|
||||
|
||||
if (pathStats.isDirectory()) {
|
||||
throw new Error(`Plugin entry must be a file, got directory: ${path}`);
|
||||
}
|
||||
|
||||
// Dynamic import - normalize to file URL so query params are honored
|
||||
// consistently across Node + Vitest environments.
|
||||
const moduleUrl = pathToFileURL(path).href;
|
||||
|
||||
@@ -87,6 +87,22 @@
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.plugin-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.plugin-copy-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.plugin-name {
|
||||
@@ -136,10 +152,35 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.plugin-detail-title-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.plugin-detail-name {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.plugin-error-text {
|
||||
margin: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size-xs, 0.8rem);
|
||||
font-family: var(--font-mono);
|
||||
color: var(--color-error);
|
||||
background: color-mix(in srgb, var(--color-error) 10%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
}
|
||||
|
||||
.plugin-error-text--detail {
|
||||
max-width: min(100%, 32rem);
|
||||
}
|
||||
|
||||
.plugin-detail-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -196,7 +196,17 @@ function groupSettingsSchema(settingsSchema: Record<string, PluginSettingSchema>
|
||||
return { grouped, ungrouped };
|
||||
}
|
||||
|
||||
function renderPluginError(plugin: PluginInstallation, className = "plugin-error-text") {
|
||||
if (plugin.state !== "error" || !plugin.error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p className={className} title={plugin.error}>
|
||||
{plugin.error}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function PluginManager({ addToast, projectId }: PluginManagerProps) {
|
||||
const [plugins, setPlugins] = useState<PluginInstallation[]>([]);
|
||||
@@ -538,7 +548,10 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
|
||||
<X size={16} />
|
||||
</button>
|
||||
<div className="plugin-detail-title">
|
||||
<div className="plugin-detail-title-copy">
|
||||
<h4 className="plugin-detail-name">{selectedPlugin.name}</h4>
|
||||
{renderPluginError(selectedPlugin, "plugin-error-text plugin-error-text--detail")}
|
||||
</div>
|
||||
<span className="plugin-state-badge" style={{ color: STATE_COLORS[selectedPlugin.state] || STATE_COLORS.installed }}>
|
||||
{selectedPlugin.state}
|
||||
</span>
|
||||
@@ -982,12 +995,17 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
|
||||
{installedPlugins.map((plugin) => (
|
||||
<div key={plugin.id} className="plugin-item">
|
||||
<div className="plugin-info">
|
||||
<div className="plugin-copy">
|
||||
<div className="plugin-copy-header">
|
||||
<span className="plugin-name">{plugin.name}</span>
|
||||
<span className="plugin-version text-muted">v{plugin.version}</span>
|
||||
<span className="plugin-state-badge" style={{ color: STATE_COLORS[plugin.state] || STATE_COLORS.installed }}>
|
||||
{plugin.state}
|
||||
</span>
|
||||
</div>
|
||||
{renderPluginError(plugin)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="plugin-actions">
|
||||
{plugin.state === "started" && (
|
||||
<button
|
||||
|
||||
@@ -285,6 +285,78 @@ describe("PluginManager", () => {
|
||||
expect(within(builtInCard as HTMLElement).getByText("Built-in metadata only")).toBeTruthy();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "renders plugin error text in list and detail when plugin is errored",
|
||||
plugin: {
|
||||
...mockPlugins[0],
|
||||
id: "plugin-error-with-message",
|
||||
name: "Broken Plugin",
|
||||
state: "error" as const,
|
||||
error: "Plugin entry must be a file, got directory: /plugins/broken-plugin",
|
||||
},
|
||||
expectMessage: true,
|
||||
},
|
||||
{
|
||||
name: "does not render an error block when the error state has no message",
|
||||
plugin: {
|
||||
...mockPlugins[0],
|
||||
id: "plugin-error-without-message",
|
||||
name: "Broken Plugin Without Message",
|
||||
state: "error" as const,
|
||||
error: undefined,
|
||||
},
|
||||
expectMessage: false,
|
||||
},
|
||||
{
|
||||
name: "does not render an error block for non-error states",
|
||||
plugin: {
|
||||
...mockPlugins[0],
|
||||
id: "plugin-started-with-message",
|
||||
name: "Healthy Plugin",
|
||||
state: "started" as const,
|
||||
error: "stale error should stay hidden",
|
||||
},
|
||||
expectMessage: false,
|
||||
},
|
||||
])("$name", async ({ plugin, expectMessage }) => {
|
||||
vi.mocked(fetchPlugins).mockResolvedValueOnce([plugin]);
|
||||
|
||||
const expectedError = plugin.error;
|
||||
render(<PluginManager addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(plugin.name)).toBeTruthy();
|
||||
});
|
||||
|
||||
const listItem = screen.getByText(plugin.name).closest(".plugin-item");
|
||||
expect(listItem).toBeTruthy();
|
||||
|
||||
if (expectMessage && expectedError) {
|
||||
expect(within(listItem as HTMLElement).getByText(expectedError)).toHaveAttribute("title", expectedError);
|
||||
} else if (expectedError) {
|
||||
expect(within(listItem as HTMLElement).queryByText(expectedError)).toBeNull();
|
||||
} else {
|
||||
expect((listItem as HTMLElement).querySelector(".plugin-error-text")).toBeNull();
|
||||
}
|
||||
|
||||
const settingsButton = (listItem as HTMLElement).querySelector('button[title="Settings"]');
|
||||
expect(settingsButton).toBeTruthy();
|
||||
await userEvent.click(settingsButton as HTMLElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("plugin-manager-detail")).toBeTruthy();
|
||||
});
|
||||
|
||||
if (expectMessage && expectedError) {
|
||||
expect(screen.getByText(expectedError)).toHaveAttribute("title", expectedError);
|
||||
} else if (expectedError) {
|
||||
expect(screen.queryByText(expectedError)).toBeNull();
|
||||
} else {
|
||||
expect(screen.getByTestId("plugin-manager-detail").querySelector(".plugin-error-text")).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("shows setup-required action for installed built-in agent browser", async () => {
|
||||
vi.mocked(fetchPlugins).mockResolvedValueOnce([
|
||||
{
|
||||
|
||||
@@ -27,6 +27,9 @@ vi.mock("@fusion-plugin-examples/cli-printing-press/manage-view", () => ({
|
||||
CliPrintingPressManageView: (...args: unknown[]) => MockCliPrintingPressManageView(...args),
|
||||
}));
|
||||
|
||||
// The dashboard statically registers bundled views client-side, so these views can
|
||||
// render even when engine-side PluginLoader startup failed and the persisted
|
||||
// installation row is in an error state.
|
||||
describe("registerBundledPluginViews", () => {
|
||||
beforeEach(() => {
|
||||
__test_clearPluginViewRegistry();
|
||||
@@ -36,6 +39,9 @@ describe("registerBundledPluginViews", () => {
|
||||
it("registers dependency graph, roadmap, and cli printing press bundled views", () => {
|
||||
registerBundledPluginViews();
|
||||
|
||||
// This registration is independent of engine-side plugin load success; the
|
||||
// dashboard can still render the Graph view while the plugin install row is errored.
|
||||
expect(isPluginViewRegistered("fusion-plugin-dependency-graph", "graph")).toBe(true);
|
||||
expect(getPluginViewComponent("fusion-plugin-dependency-graph", "graph")).toBeTruthy();
|
||||
expect(getPluginViewComponent("fusion-plugin-roadmap", "roadmaps")).toBeTruthy();
|
||||
expect(getPluginViewComponent("fusion-plugin-cli-printing-press", "wizard")).toBeTruthy();
|
||||
|
||||
Reference in New Issue
Block a user