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:
@@ -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 |
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user