feat(FN-3072): fix design tokens, mobile touch targets, and plugin settings

This merge addresses FN-3072 design feedback by updating CSS design tokens and mobile touch targets in ModelOnboardingModal, PluginSlot, and SettingsModal components, while also adding tests for droid settings plugin integration states. FN-3137 refines MissionManager's back-button navigation test as

Fusion-Task-Id: FN-3072
This commit is contained in:
Fusion
2026-05-04 05:25:52 -07:00
committed by gsxdsm
parent 743cc38c0f
commit 6ea1384631
9 changed files with 187 additions and 46 deletions

View File

@@ -448,6 +448,8 @@ Plugins declare `uiSlots` in their `FusionPlugin` definition. The dashboard disc
| `task-detail-tab` | Task detail modal | Tab added to the task detail view | Available | | `task-detail-tab` | Task detail modal | Tab added to the task detail view | Available |
| `header-action` | Dashboard header | Action button in the header toolbar | Available | | `header-action` | Dashboard header | Action button in the header toolbar | Available |
| `settings-section` | Settings modal | Section added to the settings panel | Available | | `settings-section` | Settings modal | Section added to the settings panel | Available |
| `settings-provider-card` | Settings → Authentication | Provider card contribution in Authentication section | Available |
| `settings-integration-card` | Settings → Authentication | Integration/help card contribution in Authentication section | Available |
| `task-card-badge` | Task card on the board | Small badge displayed on task cards (e.g., CI status indicator) | Planned | | `task-card-badge` | Task card on the board | Small badge displayed on task cards (e.g., CI status indicator) | Planned |
| `board-column-footer` | Board column | Footer area below the last card in a column | Planned | | `board-column-footer` | Board column | Footer area below the last card in a column | Planned |

View File

@@ -1121,7 +1121,7 @@
} }
.model-onboarding-complete .success-icon { .model-onboarding-complete .success-icon {
color: var(--success, #22c55e); color: var(--color-success);
} }
.model-onboarding-complete p { .model-onboarding-complete p {
@@ -1191,11 +1191,11 @@
} }
.model-onboarding-header { .model-onboarding-header {
padding: 14px 14px 10px; padding: var(--space-md) var(--space-md) calc(var(--space-sm) + var(--space-xs) / 2);
} }
.model-onboarding-steps { .model-onboarding-steps {
padding: 12px 14px; padding: var(--space-md);
} }
.model-onboarding-step-connector { .model-onboarding-step-connector {
@@ -1204,7 +1204,7 @@
} }
.model-onboarding-content { .model-onboarding-content {
padding: 14px; padding: var(--space-md);
} }
.onboarding-provider-row { .onboarding-provider-row {
@@ -1270,7 +1270,7 @@
name + description + status fit in ~3 short lines. */ name + description + status fit in ~3 short lines. */
.onboarding-provider-card__body { .onboarding-provider-card__body {
flex: 1 1 0; flex: 1 1 0;
gap: 2px; gap: calc(var(--space-xs) / 2);
} }
.onboarding-provider-card__name { .onboarding-provider-card__name {
@@ -1339,12 +1339,12 @@
flex: 1 1 auto; flex: 1 1 auto;
width: auto; width: auto;
min-width: 0; min-width: 0;
min-height: 32px; min-height: calc(var(--space-md) * 3);
} }
.onboarding-apikey-input-row .btn { .onboarding-apikey-input-row .btn {
flex: 0 0 auto; flex: 0 0 auto;
min-height: 32px; min-height: calc(var(--space-md) * 3);
/* Compact label so "Save" doesn't bloat the row. */ /* Compact label so "Save" doesn't bloat the row. */
padding-inline: var(--space-md); padding-inline: var(--space-md);
} }
@@ -1374,7 +1374,7 @@
} }
.model-onboarding-footer { .model-onboarding-footer {
padding: 12px 14px; padding: var(--space-md);
flex-wrap: wrap; flex-wrap: wrap;
gap: var(--space-sm); gap: var(--space-sm);
} }
@@ -1414,12 +1414,12 @@
} }
.onboarding-cta-card .cta-icon { .onboarding-cta-card .cta-icon {
width: 40px; width: calc(var(--space-lg) * 2 + var(--space-sm));
height: 40px; height: calc(var(--space-lg) * 2 + var(--space-sm));
} }
.onboarding-cta-card .cta-content strong { .onboarding-cta-card .cta-content strong {
font-size: 14px; font-size: calc(var(--space-sm) + var(--space-xs) + var(--space-xs) / 2);
} }
.onboarding-cta-card .cta-content span { .onboarding-cta-card .cta-content span {

View File

@@ -1,5 +1,6 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { ErrorBoundary } from "./ErrorBoundary"; import { ErrorBoundary } from "./ErrorBoundary";
import { DroidCliProviderCard } from "./DroidCliProviderCard";
import { usePluginUiSlots } from "../hooks/usePluginUiSlots"; import { usePluginUiSlots } from "../hooks/usePluginUiSlots";
import "./PluginSlot.css"; import "./PluginSlot.css";
@@ -14,6 +15,14 @@ interface PluginSlotProps {
renderPlaceholder?: boolean; renderPlaceholder?: boolean;
} }
function renderKnownPluginSlot(slotId: string, pluginId: string): ReactNode | null {
if (pluginId === "fusion-plugin-droid-runtime" && slotId === "settings-provider-card") {
return <DroidCliProviderCard compact authenticated={false} />;
}
return null;
}
/** /**
* Renders plugin slot registrations for a host surface. * Renders plugin slot registrations for a host surface.
*/ */
@@ -28,26 +37,37 @@ export function PluginSlot({ slotId, projectId, pluginIds, renderPlaceholder = t
pluginIds && pluginIds.length > 0 ? pluginIds.includes(entry.pluginId) : true, pluginIds && pluginIds.length > 0 ? pluginIds.includes(entry.pluginId) : true,
); );
if (matchingEntries.length === 0 || !renderPlaceholder) { if (matchingEntries.length === 0) {
return null; return null;
} }
return ( return (
<ErrorBoundary level="page"> <ErrorBoundary level="page">
<> <>
{matchingEntries.map((entry, index) => ( {matchingEntries.map((entry, index) => {
<section const knownSlot = renderKnownPluginSlot(entry.slot.slotId, entry.pluginId);
key={`${entry.pluginId}-${entry.slot.slotId}-${index}`} if (knownSlot) {
className="plugin-slot-shell" return <div key={`${entry.pluginId}-${entry.slot.slotId}-${index}`}>{knownSlot}</div>;
data-plugin-slot }
data-slot-id={entry.slot.slotId}
data-plugin-id={entry.pluginId} if (!renderPlaceholder) {
aria-label={entry.slot.label} return null;
> }
<p className="plugin-slot-shell__title">{entry.slot.label}</p>
<p className="plugin-slot-shell__message">Extension content available.</p> return (
</section> <section
))} key={`${entry.pluginId}-${entry.slot.slotId}-${index}`}
className="plugin-slot-shell"
data-plugin-slot
data-slot-id={entry.slot.slotId}
data-plugin-id={entry.pluginId}
aria-label={entry.slot.label}
>
<p className="plugin-slot-shell__title">{entry.slot.label}</p>
<p className="plugin-slot-shell__message">Extension content available.</p>
</section>
);
})}
</> </>
</ErrorBoundary> </ErrorBoundary>
); );

View File

@@ -15,16 +15,15 @@
their differing icon sizes (provider icon 16px vs. HelpCircle 13px). */ their differing icon sizes (provider icon 16px vs. HelpCircle 13px). */
.settings-header-actions > .settings-github-star-btn, .settings-header-actions > .settings-github-star-btn,
.settings-header-actions > .btn { .settings-header-actions > .btn {
height: 26px; height: calc(var(--space-md) * 2 + var(--space-xs) / 2);
box-sizing: border-box; box-sizing: border-box;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
/* Keep settings header actions compact; override global mobile /* Preserve mobile touch targets while keeping visuals compact via icon sizing/padding. */
`.btn-icon { min-height/min-width: 36px; }` inflation. */
.settings-header-actions > .btn-icon { .settings-header-actions > .btn-icon {
min-height: 26px; min-height: calc(var(--space-md) * 3);
min-width: 26px; min-width: calc(var(--space-md) * 3);
} }
} }
@@ -59,14 +58,14 @@
.settings-github-star-btn__action { .settings-github-star-btn__action {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 5px; gap: var(--space-xs);
padding: 4px 10px; padding: var(--space-xs) calc(var(--space-sm) + var(--space-xs) / 2);
} }
.settings-github-star-btn__count { .settings-github-star-btn__count {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
padding: 4px 9px; padding: var(--space-xs) calc(var(--space-sm) + var(--space-xs) / 4);
border-left: var(--btn-border-width) solid var(--border); border-left: var(--btn-border-width) solid var(--border);
background: color-mix(in srgb, var(--surface) 60%, var(--card)); background: color-mix(in srgb, var(--surface) 60%, var(--card));
color: var(--text-muted); color: var(--text-muted);
@@ -91,6 +90,7 @@
overlay's default top padding so the modal actually fills the viewport, overlay's default top padding so the modal actually fills the viewport,
and disable resize (touchscreen users can't drag the grip anyway). */ and disable resize (touchscreen users can't drag the grip anyway). */
@media (max-width: 768px) { @media (max-width: 768px) {
.modal-overlay.settings-modal-overlay,
.modal-overlay:has(.settings-modal) { .modal-overlay:has(.settings-modal) {
padding-top: 0; padding-top: 0;
align-items: stretch; align-items: stretch;

View File

@@ -43,6 +43,7 @@ import { useConfirm } from "../hooks/useConfirm";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard"; import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useNodes } from "../hooks/useNodes"; import { useNodes } from "../hooks/useNodes";
import { usePluginUiSlots } from "../hooks/usePluginUiSlots";
import { useViewportMode } from "../hooks/useViewportMode"; import { useViewportMode } from "../hooks/useViewportMode";
import { NodeHealthDot } from "./NodeHealthDot"; import { NodeHealthDot } from "./NodeHealthDot";
import { filterVisibleOnboardingAndSettingsProviders } from "./providerVisibility"; import { filterVisibleOnboardingAndSettingsProviders } from "./providerVisibility";
@@ -437,6 +438,7 @@ export function SettingsModal({
} = useWorkspaceFileBrowser("project", overlapPathPickerIndex !== null, projectId); } = useWorkspaceFileBrowser("project", overlapPathPickerIndex !== null, projectId);
const { nodes } = useNodes(); const { nodes } = useNodes();
const { getSlotsForId } = usePluginUiSlots(projectId);
const experimentalFeatures = form.experimentalFeatures ?? {}; const experimentalFeatures = form.experimentalFeatures ?? {};
const remoteAccessEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "remoteAccess"); const remoteAccessEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "remoteAccess");
const researchViewEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "researchView"); const researchViewEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "researchView");
@@ -5025,6 +5027,9 @@ export function SettingsModal({
// auth state (Authenticated when signed in, Available otherwise). // auth state (Authenticated when signed in, Available otherwise).
const claudeCliProvider = cliAuthProviders.find((p) => p.id === "claude-cli"); const claudeCliProvider = cliAuthProviders.find((p) => p.id === "claude-cli");
const droidCliProvider = cliAuthProviders.find((p) => p.id === "droid-cli"); const droidCliProvider = cliAuthProviders.find((p) => p.id === "droid-cli");
const hasDroidPluginSlot = getSlotsForId("settings-provider-card").some(
(entry) => entry.pluginId === "fusion-plugin-droid-runtime",
);
const claudeCliCard = claudeCliProvider ? ( const claudeCliCard = claudeCliProvider ? (
<ClaudeCliProviderCard <ClaudeCliProviderCard
compact compact
@@ -5034,7 +5039,7 @@ export function SettingsModal({
}} }}
/> />
) : null; ) : null;
const droidCliCard = droidCliProvider ? ( const droidCliCard = droidCliProvider && !hasDroidPluginSlot ? (
<DroidCliProviderCard <DroidCliProviderCard
compact compact
authenticated={droidCliProvider.authenticated} authenticated={droidCliProvider.authenticated}
@@ -5046,12 +5051,11 @@ export function SettingsModal({
const showAuthenticatedGroup = const showAuthenticatedGroup =
authenticatedProviders.length > 0 authenticatedProviders.length > 0
|| (claudeCliProvider?.authenticated ?? false) || (claudeCliProvider?.authenticated ?? false)
|| (droidCliProvider?.authenticated ?? false); || ((droidCliProvider?.authenticated ?? false) && !hasDroidPluginSlot);
const showAvailableGroup = const showAvailableGroup =
unauthenticatedProviders.length > 0 unauthenticatedProviders.length > 0
|| (claudeCliProvider && !claudeCliProvider.authenticated) || (claudeCliProvider && !claudeCliProvider.authenticated)
|| (droidCliProvider && !droidCliProvider.authenticated); || (droidCliProvider && !droidCliProvider.authenticated && !hasDroidPluginSlot);
return ( return (
<> <>
<h4 className="settings-section-heading">Authentication</h4> <h4 className="settings-section-heading">Authentication</h4>
@@ -5314,7 +5318,7 @@ export function SettingsModal({
}; };
return ( return (
<div className="modal-overlay open" {...overlayDismissProps} role="dialog" aria-modal="true"> <div className="modal-overlay open settings-modal-overlay" {...overlayDismissProps} role="dialog" aria-modal="true">
<div className="modal modal-lg settings-modal" ref={modalRef} style={keyboardStyle}> <div className="modal modal-lg settings-modal" ref={modalRef} style={keyboardStyle}>
<div className="modal-header"> <div className="modal-header">
<div className="settings-modal-heading"> <div className="settings-modal-heading">

View File

@@ -1028,7 +1028,11 @@ describe("MissionManager", () => {
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Mission event 50")).toBeDefined(); expect(screen.getByText("Mission event 50")).toBeDefined();
expect(screen.getByText("50 of 65")).toBeDefined(); expect(
screen.getByText("50 of 65", {
selector: ".mission-detail__activity-count",
}),
).toBeDefined();
expect(screen.getByTestId("mission-activity-load-more")).toBeDefined(); expect(screen.getByTestId("mission-activity-load-more")).toBeDefined();
}); });
@@ -1036,7 +1040,9 @@ describe("MissionManager", () => {
await waitFor(() => { await waitFor(() => {
expect( expect(
screen.getByText((_, element) => element?.textContent?.includes("65 of 65") ?? false), screen.getByText("65 of 65", {
selector: ".mission-detail__activity-count",
}),
).toBeDefined(); ).toBeDefined();
expect(screen.queryByTestId("mission-activity-load-more")).toBeNull(); expect(screen.queryByTestId("mission-activity-load-more")).toBeNull();
}); });

View File

@@ -5,6 +5,9 @@ import type { PluginUiSlotEntry } from "../../api";
import { usePluginUiSlots } from "../../hooks/usePluginUiSlots"; import { usePluginUiSlots } from "../../hooks/usePluginUiSlots";
vi.mock("../../hooks/usePluginUiSlots"); vi.mock("../../hooks/usePluginUiSlots");
vi.mock("../DroidCliProviderCard", () => ({
DroidCliProviderCard: () => <div data-testid="droid-cli-provider-card" />,
}));
function createSlotEntry(slotId: string, pluginId = "test-plugin"): PluginUiSlotEntry { function createSlotEntry(slotId: string, pluginId = "test-plugin"): PluginUiSlotEntry {
return { return {
@@ -119,7 +122,7 @@ describe("PluginSlot", () => {
expect(vi.mocked(usePluginUiSlots)).toHaveBeenCalledWith("proj-1"); expect(vi.mocked(usePluginUiSlots)).toHaveBeenCalledWith("proj-1");
}); });
it("suppresses placeholder rendering when renderPlaceholder is false", () => { it("suppresses placeholder rendering when renderPlaceholder is false for unknown slots", () => {
const entry = createSlotEntry("settings-provider-card", "plugin-droid"); const entry = createSlotEntry("settings-provider-card", "plugin-droid");
vi.mocked(usePluginUiSlots).mockReturnValue({ vi.mocked(usePluginUiSlots).mockReturnValue({
slots: [entry], slots: [entry],
@@ -135,6 +138,22 @@ describe("PluginSlot", () => {
expect(container.firstChild).toBeNull(); expect(container.firstChild).toBeNull();
}); });
it("renders known droid settings slot even when placeholders are disabled", () => {
const entry = createSlotEntry("settings-provider-card", "fusion-plugin-droid-runtime");
vi.mocked(usePluginUiSlots).mockReturnValue({
slots: [entry],
getSlotsForId: vi.fn(() => [entry]),
loading: false,
error: null,
});
const { container } = render(
<PluginSlot slotId="settings-provider-card" renderPlaceholder={false} />,
);
expect(container.querySelector('[data-testid="droid-cli-provider-card"]')).not.toBeNull();
});
it("returns null for empty string slotId", () => { it("returns null for empty string slotId", () => {
const getSlotsForId = vi.fn(() => []); const getSlotsForId = vi.fn(() => []);
vi.mocked(usePluginUiSlots).mockReturnValue({ vi.mocked(usePluginUiSlots).mockReturnValue({

View File

@@ -49,6 +49,9 @@ const mockGenerateShortLivedRemoteToken = vi.fn();
const mockFetchRemoteQr = vi.fn(); const mockFetchRemoteQr = vi.fn();
const mockFetchRemoteUrl = vi.fn(); const mockFetchRemoteUrl = vi.fn();
const mockTriggerMemoryDreams = vi.fn(); const mockTriggerMemoryDreams = vi.fn();
const mockFetchPluginUiSlots = vi.fn();
const mockFetchDroidCliStatus = vi.fn();
const mockSetDroidCliEnabled = vi.fn();
const mockUseWorkspaceFileBrowser = vi.fn(); const mockUseWorkspaceFileBrowser = vi.fn();
vi.mock("../../api", async (importOriginal) => { vi.mock("../../api", async (importOriginal) => {
@@ -98,6 +101,9 @@ vi.mock("../../api", async (importOriginal) => {
fetchRemoteQr: (...args: unknown[]) => mockFetchRemoteQr(...args), fetchRemoteQr: (...args: unknown[]) => mockFetchRemoteQr(...args),
fetchRemoteUrl: (...args: unknown[]) => mockFetchRemoteUrl(...args), fetchRemoteUrl: (...args: unknown[]) => mockFetchRemoteUrl(...args),
triggerMemoryDreams: (...args: unknown[]) => mockTriggerMemoryDreams(...args), triggerMemoryDreams: (...args: unknown[]) => mockTriggerMemoryDreams(...args),
fetchPluginUiSlots: (...args: unknown[]) => mockFetchPluginUiSlots(...args),
fetchDroidCliStatus: (...args: unknown[]) => mockFetchDroidCliStatus(...args),
setDroidCliEnabled: (...args: unknown[]) => mockSetDroidCliEnabled(...args),
}); });
}); });
@@ -136,9 +142,6 @@ vi.mock("../PiExtensionsManager", () => ({
PiExtensionsManager: () => <div data-testid="pi-extensions-manager">Pi extensions content</div>, PiExtensionsManager: () => <div data-testid="pi-extensions-manager">Pi extensions content</div>,
})); }));
vi.mock("../PluginSlot", () => ({
PluginSlot: () => <div data-testid="plugin-slot">Plugin slot content</div>,
}));
vi.mock("../../hooks/useWorkspaceFileBrowser", () => ({ vi.mock("../../hooks/useWorkspaceFileBrowser", () => ({
useWorkspaceFileBrowser: (...args: unknown[]) => mockUseWorkspaceFileBrowser(...args), useWorkspaceFileBrowser: (...args: unknown[]) => mockUseWorkspaceFileBrowser(...args),
@@ -361,6 +364,14 @@ describe("SettingsModal", () => {
mockFetchRemoteQr.mockResolvedValue({ url: "https://remote.example.com", tokenType: "persistent", expiresAt: null, format: "image/svg", data: "<svg></svg>" }); mockFetchRemoteQr.mockResolvedValue({ url: "https://remote.example.com", tokenType: "persistent", expiresAt: null, format: "image/svg", data: "<svg></svg>" });
mockFetchRemoteUrl.mockResolvedValue({ url: "https://remote.example.com", tokenType: "persistent", expiresAt: null }); mockFetchRemoteUrl.mockResolvedValue({ url: "https://remote.example.com", tokenType: "persistent", expiresAt: null });
mockTriggerMemoryDreams.mockResolvedValue({ success: true, summary: "done" }); mockTriggerMemoryDreams.mockResolvedValue({ success: true, summary: "done" });
mockFetchPluginUiSlots.mockResolvedValue([]);
mockFetchDroidCliStatus.mockResolvedValue({
binary: { available: true, version: "1.2.3", binaryPath: "/usr/local/bin/droid", probeDurationMs: 9 },
enabled: false,
extension: { status: "ok" },
ready: false,
});
mockSetDroidCliEnabled.mockResolvedValue({ enabled: true, restartRequired: true });
mockUseWorkspaceFileBrowser.mockReturnValue({ mockUseWorkspaceFileBrowser.mockReturnValue({
entries: [], entries: [],
currentPath: ".", currentPath: ".",
@@ -1056,6 +1067,78 @@ describe("SettingsModal", () => {
}); });
}); });
describe("Droid plugin Settings integration", () => {
it.each([
{
name: "unavailable/not enabled",
status: {
binary: { available: false, reason: "`droid` not found on PATH", probeDurationMs: 9 },
enabled: false,
extension: { status: "ok" },
ready: false,
},
expectedText: "not found on PATH",
},
{
name: "enabled but not ready",
status: {
binary: { available: true, version: "1.2.3", binaryPath: "/usr/local/bin/droid", probeDurationMs: 9 },
enabled: true,
extension: { status: "ok" },
ready: false,
},
expectedText: "Enabled. Validating…",
},
{
name: "connected and ready",
status: {
binary: { available: true, version: "1.2.3", binaryPath: "/usr/local/bin/droid", probeDurationMs: 9 },
enabled: true,
extension: { status: "ok" },
ready: true,
},
expectedText: "✓ Connected — 1.2.3",
},
])("renders plugin-driven droid card state: $name", async ({ status, expectedText }) => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [{ id: "droid-cli", name: "Factory AI (via Droid CLI)", authenticated: false, type: "cli" }],
});
mockFetchPluginUiSlots.mockResolvedValueOnce([
{
pluginId: "fusion-plugin-droid-runtime",
slot: {
slotId: "settings-provider-card",
label: "Droid CLI Provider",
componentPath: "./components/settings-provider-card.js",
},
},
]);
mockFetchDroidCliStatus.mockResolvedValueOnce(status);
renderModal();
await waitForSettingsModalReady();
expect(screen.getByRole("heading", { name: "Authentication" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument();
expect(await screen.findByTestId("droid-cli-provider-card")).toBeInTheDocument();
expect(screen.getAllByTestId("droid-cli-provider-card")).toHaveLength(1);
expect(screen.getByText(new RegExp(expectedText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")))).toBeInTheDocument();
});
it("renders legacy droid auth card only when plugin slot is not present", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [{ id: "droid-cli", name: "Factory AI (via Droid CLI)", authenticated: false, type: "cli" }],
});
mockFetchPluginUiSlots.mockResolvedValueOnce([]);
renderModal();
await waitForSettingsModalReady();
expect(await screen.findByTestId("droid-cli-provider-card")).toBeInTheDocument();
expect(screen.getAllByTestId("droid-cli-provider-card")).toHaveLength(1);
});
});
describe("Plugins section navigation", () => { describe("Plugins section navigation", () => {
it("does not render a standalone Pi Extensions sidebar item", async () => { it("does not render a standalone Pi Extensions sidebar item", async () => {
renderModal(); renderModal();
@@ -1090,7 +1173,6 @@ describe("SettingsModal", () => {
await userEvent.click(await screen.findByRole("button", { name: /Plugins$/ })); await userEvent.click(await screen.findByRole("button", { name: /Plugins$/ }));
expect(await screen.findByTestId("plugin-manager")).toBeInTheDocument(); expect(await screen.findByTestId("plugin-manager")).toBeInTheDocument();
expect(screen.getByTestId("plugin-slot")).toBeInTheDocument();
expect(screen.queryByTestId("pi-extensions-manager")).not.toBeInTheDocument(); expect(screen.queryByTestId("pi-extensions-manager")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("tab", { name: "Pi Extensions" })); await userEvent.click(screen.getByRole("tab", { name: "Pi Extensions" }));
@@ -1099,7 +1181,6 @@ describe("SettingsModal", () => {
expect(screen.getByTestId("pi-extensions-manager")).toBeInTheDocument(); expect(screen.getByTestId("pi-extensions-manager")).toBeInTheDocument();
}); });
expect(screen.queryByTestId("plugin-manager")).not.toBeInTheDocument(); expect(screen.queryByTestId("plugin-manager")).not.toBeInTheDocument();
expect(screen.queryByTestId("plugin-slot")).not.toBeInTheDocument();
expect(screen.getByRole("tabpanel", { name: "Pi Extensions" })).toBeVisible(); expect(screen.getByRole("tabpanel", { name: "Pi Extensions" })).toBeVisible();
expect(document.getElementById("plugins-panel-fusion-plugins")).toHaveAttribute("hidden"); expect(document.getElementById("plugins-panel-fusion-plugins")).toHaveAttribute("hidden");
}); });

View File

@@ -19,6 +19,15 @@ describe("droid runtime plugin index", () => {
])); ]));
}); });
it("locks droid settings slot registration shape", () => {
const settingsSlot = plugin.uiSlots?.find((slot) => slot.slotId === "settings-provider-card");
expect(settingsSlot).toMatchObject({
slotId: "settings-provider-card",
label: "Droid CLI Provider",
componentPath: "./components/settings-provider-card.js",
});
});
it("has a valid manifest", () => { it("has a valid manifest", () => {
expect(() => validatePluginManifest(plugin.manifest)).not.toThrow(); expect(() => validatePluginManifest(plugin.manifest)).not.toThrow();
}); });