Emdash source with visual editor image upload fix
Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
393
packages/admin/tests/components/PluginManager.test.tsx
Normal file
393
packages/admin/tests/components/PluginManager.test.tsx
Normal file
@@ -0,0 +1,393 @@
|
||||
import { Toasty } from "@cloudflare/kumo";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import type { PluginInfo, AdminManifest } from "../../src/lib/api";
|
||||
import type { PluginUpdateInfo } from "../../src/lib/api/marketplace";
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
// Mock router
|
||||
vi.mock("@tanstack/react-router", async () => {
|
||||
const actual = await vi.importActual("@tanstack/react-router");
|
||||
return {
|
||||
...actual,
|
||||
Link: ({ children, to, params, ...props }: any) => {
|
||||
let href = String(to ?? "");
|
||||
if (params && typeof params === "object") {
|
||||
for (const [key, value] of Object.entries(params as Record<string, unknown>)) {
|
||||
const stringified =
|
||||
value == null
|
||||
? ""
|
||||
: typeof value === "string" || typeof value === "number"
|
||||
? String(value)
|
||||
: "";
|
||||
if (key === "_splat") {
|
||||
href = href.replace("$", stringified);
|
||||
} else {
|
||||
href = href.replace(`$${key}`, stringified);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
useNavigate: () => vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockFetchPlugins = vi.fn<() => Promise<PluginInfo[]>>();
|
||||
const mockEnablePlugin = vi.fn();
|
||||
const mockDisablePlugin = vi.fn();
|
||||
|
||||
vi.mock("../../src/lib/api", async () => {
|
||||
const actual = await vi.importActual("../../src/lib/api");
|
||||
return {
|
||||
...actual,
|
||||
fetchPlugins: (...args: unknown[]) => mockFetchPlugins(...(args as [])),
|
||||
enablePlugin: (...args: unknown[]) => mockEnablePlugin(...(args as [])),
|
||||
disablePlugin: (...args: unknown[]) => mockDisablePlugin(...(args as [])),
|
||||
};
|
||||
});
|
||||
|
||||
const mockCheckPluginUpdates = vi.fn<() => Promise<PluginUpdateInfo[]>>();
|
||||
const mockUpdateMarketplacePlugin = vi.fn<() => Promise<void>>();
|
||||
const mockUninstallMarketplacePlugin = vi.fn<() => Promise<void>>();
|
||||
|
||||
vi.mock("../../src/lib/api/marketplace", async () => {
|
||||
const actual = await vi.importActual("../../src/lib/api/marketplace");
|
||||
return {
|
||||
...actual,
|
||||
checkPluginUpdates: (...args: unknown[]) => mockCheckPluginUpdates(...(args as [])),
|
||||
updateMarketplacePlugin: (...args: unknown[]) => mockUpdateMarketplacePlugin(...(args as [])),
|
||||
uninstallMarketplacePlugin: (...args: unknown[]) =>
|
||||
mockUninstallMarketplacePlugin(...(args as [])),
|
||||
};
|
||||
});
|
||||
|
||||
// Import after mocks
|
||||
const { PluginManager } = await import("../../src/components/PluginManager");
|
||||
|
||||
function makePlugin(overrides: Partial<PluginInfo> = {}): PluginInfo {
|
||||
return {
|
||||
id: "test-plugin",
|
||||
name: "Test Plugin",
|
||||
version: "1.0.0",
|
||||
enabled: true,
|
||||
status: "active",
|
||||
capabilities: ["hooks"],
|
||||
hasAdminPages: false,
|
||||
hasDashboardWidgets: false,
|
||||
hasHooks: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeManifest(overrides: Partial<AdminManifest> = {}): AdminManifest {
|
||||
return {
|
||||
version: "1.0.0",
|
||||
hash: "abc",
|
||||
collections: {},
|
||||
plugins: {},
|
||||
taxonomies: [],
|
||||
authMode: "passkey",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return (
|
||||
<QueryClientProvider client={qc}>
|
||||
<Toasty>{children}</Toasty>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("PluginManager", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchPlugins.mockResolvedValue([
|
||||
makePlugin({
|
||||
id: "audit-log",
|
||||
name: "Audit Log",
|
||||
version: "1.0.0",
|
||||
enabled: true,
|
||||
hasAdminPages: true,
|
||||
capabilities: ["hooks", "pages"],
|
||||
}),
|
||||
makePlugin({
|
||||
id: "seo",
|
||||
name: "SEO Helper",
|
||||
version: "2.0.0",
|
||||
enabled: false,
|
||||
status: "inactive",
|
||||
hasAdminPages: false,
|
||||
capabilities: ["hooks"],
|
||||
}),
|
||||
]);
|
||||
mockEnablePlugin.mockResolvedValue({});
|
||||
mockDisablePlugin.mockResolvedValue({});
|
||||
mockCheckPluginUpdates.mockResolvedValue([]);
|
||||
mockUpdateMarketplacePlugin.mockResolvedValue(undefined);
|
||||
mockUninstallMarketplacePlugin.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("displays plugin list with names and versions", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<PluginManager />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Audit Log")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("v1.0.0")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("SEO Helper")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("v2.0.0")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("enabled plugins show toggle in on state", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<PluginManager />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Audit Log")).toBeInTheDocument();
|
||||
const enableToggle = screen.getByRole("switch", { name: "Disable plugin" });
|
||||
await expect.element(enableToggle).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disabled plugins show toggle in off state", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<PluginManager />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("SEO Helper")).toBeInTheDocument();
|
||||
const disableToggle = screen.getByRole("switch", { name: "Enable plugin" });
|
||||
await expect.element(disableToggle).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("settings link shown only for enabled plugins with admin pages", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<PluginManager />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Audit Log")).toBeInTheDocument();
|
||||
const settingsButtons = screen.getByRole("button", { name: "Settings" }).all();
|
||||
expect(settingsButtons.length).toBe(1);
|
||||
});
|
||||
|
||||
it("settings link points to the plugin root, not a /settings sub-path", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<PluginManager />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Audit Log")).toBeInTheDocument();
|
||||
const settingsButton = screen.getByRole("button", { name: "Settings" });
|
||||
await expect.element(settingsButton).toBeInTheDocument();
|
||||
const anchor = settingsButton.element().closest("a");
|
||||
expect(anchor).not.toBeNull();
|
||||
// Plugins are not required to expose a `/settings` sub-page; the gear
|
||||
// icon should land on the plugin's primary admin page.
|
||||
expect(anchor!.getAttribute("href")).toMatch(/^\/plugins\/audit-log\/?$/);
|
||||
});
|
||||
|
||||
it("expand/collapse shows plugin details", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<PluginManager />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Audit Log")).toBeInTheDocument();
|
||||
const expandButtons = screen.getByRole("button", { name: "Expand details" }).all();
|
||||
expect(expandButtons.length).toBeGreaterThan(0);
|
||||
await expandButtons[0]!.click();
|
||||
await expect.element(screen.getByText("Capabilities")).toBeInTheDocument();
|
||||
await vi.waitFor(() => {
|
||||
const badges = document.querySelectorAll(".inline-flex.items-center.rounded-md.bg-kumo-tint");
|
||||
expect(badges.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("empty state when no plugins", async () => {
|
||||
mockFetchPlugins.mockResolvedValue([]);
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<PluginManager />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("No plugins configured")).toBeInTheDocument();
|
||||
await expect
|
||||
.element(
|
||||
screen.getByText("Add plugins to your astro.config.mjs to extend EmDash functionality."),
|
||||
)
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Marketplace features
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it("shows Marketplace link when manifest has marketplace URL", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<PluginManager
|
||||
manifest={makeManifest({ marketplace: "https://marketplace.emdashcms.com" })}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Audit Log")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Marketplace")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides Marketplace link when no marketplace configured", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<PluginManager manifest={makeManifest()} />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Audit Log")).toBeInTheDocument();
|
||||
const marketplaceLink = screen.getByText("Marketplace");
|
||||
await expect.element(marketplaceLink).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Marketplace badge on marketplace-installed plugins", async () => {
|
||||
mockFetchPlugins.mockResolvedValue([
|
||||
makePlugin({
|
||||
id: "mp-plugin",
|
||||
name: "Marketplace Plugin",
|
||||
source: "marketplace",
|
||||
marketplaceVersion: "1.2.0",
|
||||
}),
|
||||
]);
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<PluginManager />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Marketplace Plugin")).toBeInTheDocument();
|
||||
// Look for the "Marketplace" badge
|
||||
const badges = screen.getByText("Marketplace").all();
|
||||
// At least one should be the source badge on the card (not the nav link)
|
||||
expect(badges.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("shows 'Check for updates' button when marketplace plugins exist", async () => {
|
||||
mockFetchPlugins.mockResolvedValue([
|
||||
makePlugin({
|
||||
id: "mp-plugin",
|
||||
name: "MP Plugin",
|
||||
source: "marketplace",
|
||||
}),
|
||||
]);
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<PluginManager />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Check for updates")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides 'Check for updates' button when no marketplace plugins", async () => {
|
||||
mockFetchPlugins.mockResolvedValue([
|
||||
makePlugin({ id: "config-plugin", name: "Config Plugin", source: "config" }),
|
||||
]);
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<PluginManager />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Config Plugin")).toBeInTheDocument();
|
||||
const checkBtn = screen.getByText("Check for updates");
|
||||
await expect.element(checkBtn).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows marketplace source in expanded details", async () => {
|
||||
mockFetchPlugins.mockResolvedValue([
|
||||
makePlugin({
|
||||
id: "mp-plugin",
|
||||
name: "MP Plugin",
|
||||
source: "marketplace",
|
||||
marketplaceVersion: "1.5.0",
|
||||
}),
|
||||
]);
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<PluginManager />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("MP Plugin")).toBeInTheDocument();
|
||||
// Expand
|
||||
const expandBtn = screen.getByRole("button", { name: "Expand details" });
|
||||
await expandBtn.click();
|
||||
await expect
|
||||
.element(screen.getByText("Installed from marketplace (v1.5.0)"))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows uninstall button for marketplace plugins in expanded details", async () => {
|
||||
mockFetchPlugins.mockResolvedValue([
|
||||
makePlugin({
|
||||
id: "mp-plugin",
|
||||
name: "MP Plugin",
|
||||
source: "marketplace",
|
||||
}),
|
||||
]);
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<PluginManager />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("MP Plugin")).toBeInTheDocument();
|
||||
// Expand
|
||||
const expandBtn = screen.getByRole("button", { name: "Expand details" });
|
||||
await expandBtn.click();
|
||||
await expect.element(screen.getByText("Uninstall")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uninstall button opens confirmation dialog", async () => {
|
||||
mockFetchPlugins.mockResolvedValue([
|
||||
makePlugin({
|
||||
id: "mp-plugin",
|
||||
name: "MP Plugin",
|
||||
source: "marketplace",
|
||||
}),
|
||||
]);
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<PluginManager />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("MP Plugin")).toBeInTheDocument();
|
||||
const expandBtn = screen.getByRole("button", { name: "Expand details" });
|
||||
await expandBtn.click();
|
||||
await screen.getByText("Uninstall").click();
|
||||
// Confirm dialog
|
||||
await expect.element(screen.getByText("Uninstall MP Plugin?")).toBeInTheDocument();
|
||||
await expect
|
||||
.element(screen.getByText("This will remove the plugin and its bundle from your site."))
|
||||
.toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Also delete plugin storage data")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("empty state mentions marketplace when configured", async () => {
|
||||
mockFetchPlugins.mockResolvedValue([]);
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<PluginManager
|
||||
manifest={makeManifest({ marketplace: "https://marketplace.emdashcms.com" })}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("No plugins configured")).toBeInTheDocument();
|
||||
// The empty state links to the marketplace
|
||||
await expect.element(screen.getByText("marketplace", { exact: true })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user