Refactor Extensions Install Modal (#4328)
This commit is contained in:
+2
-21
@@ -2,8 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
import { IpcRendererEvent } from 'electron';
|
import { IpcRendererEvent } from 'electron';
|
||||||
import { HashRouter, Routes, Route, useNavigate, useLocation } from 'react-router-dom';
|
import { HashRouter, Routes, Route, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { ErrorUI } from './components/ErrorBoundary';
|
import { ErrorUI } from './components/ErrorBoundary';
|
||||||
import { ExtensionInstallModal } from './components/modals/ExtensionInstallModal';
|
import { ExtensionInstallModal } from './components/ExtensionInstallModal';
|
||||||
import { useExtensionInstallModal } from './hooks/useExtensionInstallModal';
|
|
||||||
import { ToastContainer } from 'react-toastify';
|
import { ToastContainer } from 'react-toastify';
|
||||||
import { GoosehintsModal } from './components/GoosehintsModal';
|
import { GoosehintsModal } from './components/GoosehintsModal';
|
||||||
import AnnouncementModal from './components/AnnouncementModal';
|
import AnnouncementModal from './components/AnnouncementModal';
|
||||||
@@ -366,8 +365,6 @@ export default function App() {
|
|||||||
|
|
||||||
const { getExtensions, addExtension, read } = useConfig();
|
const { getExtensions, addExtension, read } = useConfig();
|
||||||
const initAttemptedRef = useRef(false);
|
const initAttemptedRef = useRef(false);
|
||||||
const { modalState, modalConfig, dismissModal, confirmInstall } =
|
|
||||||
useExtensionInstallModal(addExtension);
|
|
||||||
|
|
||||||
// Create a setView function for useChat hook - we'll use window.history instead of navigate
|
// Create a setView function for useChat hook - we'll use window.history instead of navigate
|
||||||
const setView = (view: View, viewOptions: ViewOptions = {}) => {
|
const setView = (view: View, viewOptions: ViewOptions = {}) => {
|
||||||
@@ -664,15 +661,6 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleExtensionConfirm = async () => {
|
|
||||||
const result = await confirmInstall();
|
|
||||||
if (result.success) {
|
|
||||||
console.log('Extension installation completed successfully');
|
|
||||||
} else {
|
|
||||||
console.error('Extension installation failed:', result.error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (fatalError) {
|
if (fatalError) {
|
||||||
return <ErrorUI error={new Error(fatalError)} />;
|
return <ErrorUI error={new Error(fatalError)} />;
|
||||||
}
|
}
|
||||||
@@ -704,14 +692,7 @@ export default function App() {
|
|||||||
closeOnClick
|
closeOnClick
|
||||||
pauseOnHover
|
pauseOnHover
|
||||||
/>
|
/>
|
||||||
<ExtensionInstallModal
|
<ExtensionInstallModal addExtension={addExtension} />
|
||||||
isOpen={modalState.isOpen}
|
|
||||||
modalType={modalState.modalType}
|
|
||||||
config={modalConfig}
|
|
||||||
onConfirm={handleExtensionConfirm}
|
|
||||||
onCancel={dismissModal}
|
|
||||||
isSubmitting={modalState.isPending}
|
|
||||||
/>
|
|
||||||
<div className="relative w-screen h-screen overflow-hidden bg-background-muted flex flex-col">
|
<div className="relative w-screen h-screen overflow-hidden bg-background-muted flex flex-col">
|
||||||
<div className="titlebar-drag-region" />
|
<div className="titlebar-drag-region" />
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { render, screen, act } from '@testing-library/react';
|
||||||
|
import { ExtensionInstallModal } from './ExtensionInstallModal';
|
||||||
|
import { addExtensionFromDeepLink } from './settings/extensions/deeplink';
|
||||||
|
|
||||||
|
vi.mock('./settings/extensions/deeplink', () => ({
|
||||||
|
addExtensionFromDeepLink: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockElectron = {
|
||||||
|
getConfig: vi.fn(),
|
||||||
|
getAllowedExtensions: vi.fn(),
|
||||||
|
logInfo: vi.fn(),
|
||||||
|
on: vi.fn(),
|
||||||
|
off: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
(window as any).electron = mockElectron;
|
||||||
|
|
||||||
|
describe('ExtensionInstallModal', () => {
|
||||||
|
const mockAddExtension = vi.fn();
|
||||||
|
|
||||||
|
const getAddExtensionEventHandler = () => {
|
||||||
|
const addExtensionCall = mockElectron.on.mock.calls.find((call) => call[0] === 'add-extension');
|
||||||
|
expect(addExtensionCall).toBeDefined();
|
||||||
|
return addExtensionCall![1];
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockElectron.getConfig.mockReturnValue({
|
||||||
|
GOOSE_ALLOWLIST_WARNING: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Extension Request Handling', () => {
|
||||||
|
it('should handle trusted extension (default behaviour, no allowlist)', async () => {
|
||||||
|
mockElectron.getAllowedExtensions.mockResolvedValue([]);
|
||||||
|
|
||||||
|
render(<ExtensionInstallModal addExtension={mockAddExtension} />);
|
||||||
|
|
||||||
|
const eventHandler = getAddExtensionEventHandler();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await eventHandler({}, 'goose://extension?cmd=npx&arg=test-extension&name=TestExt');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Confirm Extension Installation')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/TestExt extension/)).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByRole('button')).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle trusted extension (from allowlist)', async () => {
|
||||||
|
mockElectron.getAllowedExtensions.mockResolvedValue(['npx test-extension']);
|
||||||
|
|
||||||
|
render(<ExtensionInstallModal addExtension={mockAddExtension} />);
|
||||||
|
|
||||||
|
const eventHandler = getAddExtensionEventHandler();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await eventHandler({}, 'goose://extension?cmd=npx&arg=test-extension&name=AllowedExt');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText('Confirm Extension Installation')).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByRole('button')).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle warning mode', async () => {
|
||||||
|
mockElectron.getConfig.mockReturnValue({
|
||||||
|
GOOSE_ALLOWLIST_WARNING: true,
|
||||||
|
});
|
||||||
|
mockElectron.getAllowedExtensions.mockResolvedValue(['uvx allowed-package']);
|
||||||
|
|
||||||
|
render(<ExtensionInstallModal addExtension={mockAddExtension} />);
|
||||||
|
|
||||||
|
const eventHandler = getAddExtensionEventHandler();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await eventHandler(
|
||||||
|
{},
|
||||||
|
'goose://extension?cmd=npx&arg=untrusted-extension&name=UntrustedExt'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText('Install Untrusted Extension?')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Install Anyway' })).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByRole('button')).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle blocked extension', async () => {
|
||||||
|
mockElectron.getAllowedExtensions.mockResolvedValue(['uvx allowed-package']);
|
||||||
|
|
||||||
|
render(<ExtensionInstallModal addExtension={mockAddExtension} />);
|
||||||
|
|
||||||
|
const eventHandler = getAddExtensionEventHandler();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await eventHandler({}, 'goose://extension?cmd=npx&arg=blocked-extension&name=BlockedExt');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText('Extension Installation Blocked')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'OK' })).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByRole('button')).toHaveLength(2);
|
||||||
|
expect(screen.getByText(/Contact your administrator/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Modal Actions', () => {
|
||||||
|
it('should dismiss modal correctly', async () => {
|
||||||
|
mockElectron.getAllowedExtensions.mockResolvedValue([]);
|
||||||
|
|
||||||
|
render(<ExtensionInstallModal addExtension={mockAddExtension} />);
|
||||||
|
|
||||||
|
const eventHandler = getAddExtensionEventHandler();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await eventHandler({}, 'goose://extension?cmd=npx&arg=test&name=Test');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
screen.getByRole('button', { name: 'No' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle successful extension installation', async () => {
|
||||||
|
vi.mocked(addExtensionFromDeepLink).mockResolvedValue(undefined);
|
||||||
|
mockElectron.getAllowedExtensions.mockResolvedValue([]);
|
||||||
|
|
||||||
|
render(<ExtensionInstallModal addExtension={mockAddExtension} />);
|
||||||
|
|
||||||
|
const eventHandler = getAddExtensionEventHandler();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await eventHandler({}, 'goose://extension?cmd=npx&arg=test&name=Test');
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
screen.getByRole('button', { name: 'Yes' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(addExtensionFromDeepLink).toHaveBeenCalledWith(
|
||||||
|
'goose://extension?cmd=npx&arg=test&name=Test',
|
||||||
|
mockAddExtension,
|
||||||
|
expect.any(Function)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+116
-34
@@ -1,15 +1,47 @@
|
|||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import { IpcRendererEvent } from 'electron';
|
import { IpcRendererEvent } from 'electron';
|
||||||
import { extractExtensionName } from '../components/settings/extensions/utils';
|
|
||||||
import { addExtensionFromDeepLink } from '../components/settings/extensions/deeplink';
|
|
||||||
import type { ExtensionConfig } from '../api/types.gen';
|
|
||||||
import {
|
import {
|
||||||
ExtensionModalState,
|
Dialog,
|
||||||
ExtensionInfo,
|
DialogContent,
|
||||||
ModalType,
|
DialogDescription,
|
||||||
ExtensionModalConfig,
|
DialogFooter,
|
||||||
ExtensionInstallResult,
|
DialogHeader,
|
||||||
} from '../types/extension';
|
DialogTitle,
|
||||||
|
} from './ui/dialog';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { extractExtensionName } from './settings/extensions/utils';
|
||||||
|
import { addExtensionFromDeepLink } from './settings/extensions/deeplink';
|
||||||
|
import type { ExtensionConfig } from '../api/types.gen';
|
||||||
|
|
||||||
|
type ModalType = 'blocked' | 'untrusted' | 'trusted';
|
||||||
|
|
||||||
|
interface ExtensionInfo {
|
||||||
|
name: string;
|
||||||
|
command?: string;
|
||||||
|
remoteUrl?: string;
|
||||||
|
link: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExtensionModalState {
|
||||||
|
isOpen: boolean;
|
||||||
|
modalType: ModalType;
|
||||||
|
extensionInfo: ExtensionInfo | null;
|
||||||
|
isPending: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExtensionModalConfig {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
cancelLabel: string;
|
||||||
|
showSingleButton: boolean;
|
||||||
|
isBlocked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExtensionInstallModalProps {
|
||||||
|
addExtension?: (name: string, config: ExtensionConfig, enabled: boolean) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
function extractCommand(link: string): string {
|
function extractCommand(link: string): string {
|
||||||
const url = new URL(link);
|
const url = new URL(link);
|
||||||
@@ -23,9 +55,7 @@ function extractRemoteUrl(link: string): string | null {
|
|||||||
return url.searchParams.get('url');
|
return url.searchParams.get('url');
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useExtensionInstallModal = (
|
export function ExtensionInstallModal({ addExtension }: ExtensionInstallModalProps) {
|
||||||
addExtension?: (name: string, config: ExtensionConfig, enabled: boolean) => Promise<void>
|
|
||||||
) => {
|
|
||||||
const [modalState, setModalState] = useState<ExtensionModalState>({
|
const [modalState, setModalState] = useState<ExtensionModalState>({
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
modalType: 'trusted',
|
modalType: 'trusted',
|
||||||
@@ -44,14 +74,12 @@ export const useExtensionInstallModal = (
|
|||||||
const config = window.electron.getConfig();
|
const config = window.electron.getConfig();
|
||||||
const ALLOWLIST_WARNING_MODE = config.GOOSE_ALLOWLIST_WARNING === true;
|
const ALLOWLIST_WARNING_MODE = config.GOOSE_ALLOWLIST_WARNING === true;
|
||||||
|
|
||||||
// If warning mode is enabled, always show warning but allow installation
|
|
||||||
if (ALLOWLIST_WARNING_MODE) {
|
if (ALLOWLIST_WARNING_MODE) {
|
||||||
return 'untrusted';
|
return 'untrusted';
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowedCommands = await window.electron.getAllowedExtensions();
|
const allowedCommands = await window.electron.getAllowedExtensions();
|
||||||
|
|
||||||
// If no allowlist configured
|
|
||||||
if (!allowedCommands || allowedCommands.length === 0) {
|
if (!allowedCommands || allowedCommands.length === 0) {
|
||||||
return 'trusted';
|
return 'trusted';
|
||||||
}
|
}
|
||||||
@@ -158,9 +186,9 @@ export const useExtensionInstallModal = (
|
|||||||
setPendingLink(null);
|
setPendingLink(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const confirmInstall = useCallback(async (): Promise<ExtensionInstallResult> => {
|
const confirmInstall = useCallback(async (): Promise<void> => {
|
||||||
if (!pendingLink) {
|
if (!pendingLink) {
|
||||||
return { success: false, error: 'No pending extension to install' };
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setModalState((prev) => ({ ...prev, isPending: true }));
|
setModalState((prev) => ({ ...prev, isPending: true }));
|
||||||
@@ -168,17 +196,16 @@ export const useExtensionInstallModal = (
|
|||||||
try {
|
try {
|
||||||
console.log(`Confirming installation of extension from: ${pendingLink}`);
|
console.log(`Confirming installation of extension from: ${pendingLink}`);
|
||||||
|
|
||||||
dismissModal();
|
|
||||||
|
|
||||||
if (addExtension) {
|
if (addExtension) {
|
||||||
await addExtensionFromDeepLink(pendingLink, addExtension, () => {
|
await addExtensionFromDeepLink(pendingLink, addExtension, () => {
|
||||||
console.log('Extension installation completed, navigating to extensions');
|
console.log('Extension installation completed, navigating to extensions');
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
throw new Error('addExtension function not provided to hook');
|
throw new Error('addExtension function not provided to component');
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
// Only dismiss modal after successful installation
|
||||||
|
dismissModal();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Installation failed';
|
const errorMessage = error instanceof Error ? error.message : 'Installation failed';
|
||||||
console.error('Extension installation failed:', error);
|
console.error('Extension installation failed:', error);
|
||||||
@@ -188,16 +215,9 @@ export const useExtensionInstallModal = (
|
|||||||
error: errorMessage,
|
error: errorMessage,
|
||||||
isPending: false,
|
isPending: false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return { success: false, error: errorMessage };
|
|
||||||
}
|
}
|
||||||
}, [pendingLink, dismissModal, addExtension]);
|
}, [pendingLink, dismissModal, addExtension]);
|
||||||
|
|
||||||
const getModalConfig = (): ExtensionModalConfig | null => {
|
|
||||||
if (!modalState.extensionInfo) return null;
|
|
||||||
return generateModalConfig(modalState.modalType, modalState.extensionInfo);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('Setting up extension install modal handler');
|
console.log('Setting up extension install modal handler');
|
||||||
|
|
||||||
@@ -213,11 +233,73 @@ export const useExtensionInstallModal = (
|
|||||||
};
|
};
|
||||||
}, [handleExtensionRequest]);
|
}, [handleExtensionRequest]);
|
||||||
|
|
||||||
return {
|
const getModalConfig = (): ExtensionModalConfig | null => {
|
||||||
modalState,
|
if (!modalState.extensionInfo) return null;
|
||||||
modalConfig: getModalConfig(),
|
return generateModalConfig(modalState.modalType, modalState.extensionInfo);
|
||||||
handleExtensionRequest,
|
|
||||||
dismissModal,
|
|
||||||
confirmInstall,
|
|
||||||
};
|
};
|
||||||
};
|
|
||||||
|
const config = getModalConfig();
|
||||||
|
if (!config) return null;
|
||||||
|
|
||||||
|
const getConfirmButtonVariant = () => {
|
||||||
|
switch (modalState.modalType) {
|
||||||
|
case 'blocked':
|
||||||
|
return 'outline';
|
||||||
|
case 'untrusted':
|
||||||
|
return 'destructive';
|
||||||
|
case 'trusted':
|
||||||
|
default:
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTitleClassName = () => {
|
||||||
|
switch (modalState.modalType) {
|
||||||
|
case 'blocked':
|
||||||
|
return 'text-red-600 dark:text-red-400';
|
||||||
|
case 'untrusted':
|
||||||
|
return 'text-yellow-600 dark:text-yellow-400';
|
||||||
|
case 'trusted':
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={modalState.isOpen} onOpenChange={(open) => !open && dismissModal()}>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className={getTitleClassName()}>{config.title}</DialogTitle>
|
||||||
|
<DialogDescription className="whitespace-pre-wrap text-left">
|
||||||
|
{config.message}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogFooter className="pt-4">
|
||||||
|
{config.showSingleButton ? (
|
||||||
|
<Button
|
||||||
|
onClick={dismissModal}
|
||||||
|
disabled={modalState.isPending}
|
||||||
|
variant={getConfirmButtonVariant()}
|
||||||
|
>
|
||||||
|
{config.confirmLabel}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={dismissModal} disabled={modalState.isPending}>
|
||||||
|
{config.cancelLabel}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={confirmInstall}
|
||||||
|
disabled={modalState.isPending}
|
||||||
|
variant={getConfirmButtonVariant()}
|
||||||
|
>
|
||||||
|
{modalState.isPending ? 'Installing...' : config.confirmLabel}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '../ui/dialog';
|
|
||||||
import { Button } from '../ui/button';
|
|
||||||
import { ModalType, ExtensionModalConfig } from '../../types/extension';
|
|
||||||
|
|
||||||
interface ExtensionInstallModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
modalType: ModalType;
|
|
||||||
config: ExtensionModalConfig | null;
|
|
||||||
onConfirm: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
isSubmitting?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ExtensionInstallModal({
|
|
||||||
isOpen,
|
|
||||||
modalType,
|
|
||||||
config,
|
|
||||||
onConfirm,
|
|
||||||
onCancel,
|
|
||||||
isSubmitting = false,
|
|
||||||
}: ExtensionInstallModalProps) {
|
|
||||||
if (!config) return null;
|
|
||||||
|
|
||||||
const getConfirmButtonVariant = () => {
|
|
||||||
switch (modalType) {
|
|
||||||
case 'blocked':
|
|
||||||
return 'outline';
|
|
||||||
case 'untrusted':
|
|
||||||
return 'destructive';
|
|
||||||
case 'trusted':
|
|
||||||
default:
|
|
||||||
return 'default';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTitleClassName = () => {
|
|
||||||
switch (modalType) {
|
|
||||||
case 'blocked':
|
|
||||||
return 'text-red-600 dark:text-red-400';
|
|
||||||
case 'untrusted':
|
|
||||||
return 'text-yellow-600 dark:text-yellow-400';
|
|
||||||
case 'trusted':
|
|
||||||
default:
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
|
|
||||||
<DialogContent className="sm:max-w-[500px]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className={getTitleClassName()}>{config.title}</DialogTitle>
|
|
||||||
<DialogDescription className="whitespace-pre-wrap text-left">
|
|
||||||
{config.message}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<DialogFooter className="pt-4">
|
|
||||||
{config.showSingleButton ? (
|
|
||||||
<Button onClick={onCancel} disabled={isSubmitting} variant={getConfirmButtonVariant()}>
|
|
||||||
{config.confirmLabel}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Button variant="outline" onClick={onCancel} disabled={isSubmitting}>
|
|
||||||
{config.cancelLabel}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={onConfirm}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
variant={getConfirmButtonVariant()}
|
|
||||||
>
|
|
||||||
{isSubmitting ? 'Installing...' : config.confirmLabel}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
||||||
import { renderHook, act } from '@testing-library/react';
|
|
||||||
import { useExtensionInstallModal } from './useExtensionInstallModal';
|
|
||||||
import { addExtensionFromDeepLink } from '../components/settings/extensions/deeplink';
|
|
||||||
|
|
||||||
const mockElectron = {
|
|
||||||
getConfig: vi.fn(),
|
|
||||||
getAllowedExtensions: vi.fn(),
|
|
||||||
logInfo: vi.fn(),
|
|
||||||
processExtensionLink: vi.fn(),
|
|
||||||
on: vi.fn(),
|
|
||||||
off: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mock('../components/settings/extensions/utils', () => ({
|
|
||||||
extractExtensionName: vi.fn((link: string) => {
|
|
||||||
const url = new URL(link);
|
|
||||||
return url.searchParams.get('name') || 'Unknown Extension';
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('../components/settings/extensions/deeplink', () => ({
|
|
||||||
addExtensionFromDeepLink: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
Object.defineProperty(globalThis, 'window', {
|
|
||||||
value: {
|
|
||||||
electron: mockElectron,
|
|
||||||
},
|
|
||||||
writable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
mockElectron.getConfig.mockReturnValue({
|
|
||||||
GOOSE_ALLOWLIST_WARNING: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('useExtensionInstallModal', () => {
|
|
||||||
const mockAddExtension = vi.fn();
|
|
||||||
|
|
||||||
describe('Initial State', () => {
|
|
||||||
it('should initialize with correct default state', () => {
|
|
||||||
const { result } = renderHook(() => useExtensionInstallModal(mockAddExtension));
|
|
||||||
|
|
||||||
expect(result.current.modalState).toEqual({
|
|
||||||
isOpen: false,
|
|
||||||
modalType: 'trusted',
|
|
||||||
extensionInfo: null,
|
|
||||||
isPending: false,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
expect(result.current.modalConfig).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Extension Request Handling', () => {
|
|
||||||
it('should handle trusted extension (default behaviour, no allowlist)', async () => {
|
|
||||||
mockElectron.getAllowedExtensions.mockResolvedValue([]);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useExtensionInstallModal(mockAddExtension));
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.handleExtensionRequest(
|
|
||||||
'goose://extension?cmd=npx&arg=test-extension&name=TestExt'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.modalState.isOpen).toBe(true);
|
|
||||||
expect(result.current.modalState.modalType).toBe('trusted');
|
|
||||||
expect(result.current.modalState.extensionInfo?.name).toBe('TestExt');
|
|
||||||
expect(result.current.modalConfig?.title).toBe('Confirm Extension Installation');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle trusted extension (from allowlist)', async () => {
|
|
||||||
mockElectron.getAllowedExtensions.mockResolvedValue(['npx test-extension']);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useExtensionInstallModal(mockAddExtension));
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.handleExtensionRequest(
|
|
||||||
'goose://extension?cmd=npx&arg=test-extension&name=AllowedExt'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.modalState.modalType).toBe('trusted');
|
|
||||||
expect(result.current.modalConfig?.title).toBe('Confirm Extension Installation');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle warning mode', async () => {
|
|
||||||
mockElectron.getConfig.mockReturnValue({
|
|
||||||
GOOSE_ALLOWLIST_WARNING: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
mockElectron.getAllowedExtensions.mockResolvedValue(['uvx allowed-package']);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useExtensionInstallModal(mockAddExtension));
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.handleExtensionRequest(
|
|
||||||
'goose://extension?cmd=npx&arg=untrusted-extension&name=UntrustedExt'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.modalState.modalType).toBe('untrusted');
|
|
||||||
expect(result.current.modalConfig?.title).toBe('Install Untrusted Extension?');
|
|
||||||
expect(result.current.modalConfig?.confirmLabel).toBe('Install Anyway');
|
|
||||||
expect(result.current.modalConfig?.showSingleButton).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle blocked extension', async () => {
|
|
||||||
mockElectron.getAllowedExtensions.mockResolvedValue(['uvx allowed-package']);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useExtensionInstallModal(mockAddExtension));
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.handleExtensionRequest(
|
|
||||||
'goose://extension?cmd=npx&arg=blocked-extension&name=BlockedExt'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.modalState.modalType).toBe('blocked');
|
|
||||||
expect(result.current.modalConfig?.title).toBe('Extension Installation Blocked');
|
|
||||||
expect(result.current.modalConfig?.confirmLabel).toBe('OK');
|
|
||||||
expect(result.current.modalConfig?.showSingleButton).toBe(true);
|
|
||||||
expect(result.current.modalConfig?.isBlocked).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Modal Actions', () => {
|
|
||||||
it('should dismiss modal correctly', async () => {
|
|
||||||
const { result } = renderHook(() => useExtensionInstallModal(mockAddExtension));
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.handleExtensionRequest('goose://extension?cmd=npx&arg=test&name=Test');
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.modalState.isOpen).toBe(true);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.dismissModal();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.modalState.isOpen).toBe(false);
|
|
||||||
expect(result.current.modalState.extensionInfo).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle successful extension installation', async () => {
|
|
||||||
vi.mocked(addExtensionFromDeepLink).mockResolvedValue(undefined);
|
|
||||||
mockElectron.getAllowedExtensions.mockResolvedValue([]);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useExtensionInstallModal(mockAddExtension));
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.handleExtensionRequest('goose://extension?cmd=npx&arg=test&name=Test');
|
|
||||||
});
|
|
||||||
|
|
||||||
let installResult;
|
|
||||||
await act(async () => {
|
|
||||||
installResult = await result.current.confirmInstall();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(installResult).toEqual({ success: true });
|
|
||||||
expect(addExtensionFromDeepLink).toHaveBeenCalledWith(
|
|
||||||
'goose://extension?cmd=npx&arg=test&name=Test',
|
|
||||||
mockAddExtension,
|
|
||||||
expect.any(Function)
|
|
||||||
);
|
|
||||||
expect(result.current.modalState.isOpen).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle failed extension installation', async () => {
|
|
||||||
const error = new Error('Installation failed');
|
|
||||||
vi.mocked(addExtensionFromDeepLink).mockRejectedValue(error);
|
|
||||||
mockElectron.getAllowedExtensions.mockResolvedValue([]);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useExtensionInstallModal(mockAddExtension));
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.handleExtensionRequest('goose://extension?cmd=npx&arg=test&name=Test');
|
|
||||||
});
|
|
||||||
|
|
||||||
let installResult;
|
|
||||||
await act(async () => {
|
|
||||||
installResult = await result.current.confirmInstall();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(installResult).toEqual({
|
|
||||||
success: false,
|
|
||||||
error: 'Installation failed',
|
|
||||||
});
|
|
||||||
expect(result.current.modalState.error).toBe('Installation failed');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not install blocked extensions', async () => {
|
|
||||||
mockElectron.getAllowedExtensions.mockResolvedValue(['uvx allowed-package']);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useExtensionInstallModal(mockAddExtension));
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.handleExtensionRequest(
|
|
||||||
'goose://extension?cmd=npx&arg=blocked&name=Blocked'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.modalState.modalType).toBe('blocked');
|
|
||||||
|
|
||||||
let installResult;
|
|
||||||
await act(async () => {
|
|
||||||
installResult = await result.current.confirmInstall();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(installResult).toEqual({
|
|
||||||
success: false,
|
|
||||||
error: 'No pending extension to install',
|
|
||||||
});
|
|
||||||
expect(addExtensionFromDeepLink).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
export type ModalType = 'blocked' | 'untrusted' | 'trusted';
|
|
||||||
|
|
||||||
export interface ExtensionInfo {
|
|
||||||
name: string;
|
|
||||||
command?: string;
|
|
||||||
remoteUrl?: string;
|
|
||||||
link: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExtensionModalState {
|
|
||||||
isOpen: boolean;
|
|
||||||
modalType: ModalType;
|
|
||||||
extensionInfo: ExtensionInfo | null;
|
|
||||||
isPending: boolean;
|
|
||||||
error: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExtensionModalConfig {
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
confirmLabel: string;
|
|
||||||
cancelLabel: string;
|
|
||||||
showSingleButton: boolean;
|
|
||||||
isBlocked: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExtensionInstallResult {
|
|
||||||
success: boolean;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user