Refactor Extensions Install Modal (#4328)

This commit is contained in:
Amed Rodriguez
2025-08-26 13:28:10 -07:00
committed by GitHub
parent 674acb4d02
commit a3097f5250
6 changed files with 279 additions and 397 deletions
+2 -21
View File
@@ -2,8 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { IpcRendererEvent } from 'electron';
import { HashRouter, Routes, Route, useNavigate, useLocation } from 'react-router-dom';
import { ErrorUI } from './components/ErrorBoundary';
import { ExtensionInstallModal } from './components/modals/ExtensionInstallModal';
import { useExtensionInstallModal } from './hooks/useExtensionInstallModal';
import { ExtensionInstallModal } from './components/ExtensionInstallModal';
import { ToastContainer } from 'react-toastify';
import { GoosehintsModal } from './components/GoosehintsModal';
import AnnouncementModal from './components/AnnouncementModal';
@@ -366,8 +365,6 @@ export default function App() {
const { getExtensions, addExtension, read } = useConfig();
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
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) {
return <ErrorUI error={new Error(fatalError)} />;
}
@@ -704,14 +692,7 @@ export default function App() {
closeOnClick
pauseOnHover
/>
<ExtensionInstallModal
isOpen={modalState.isOpen}
modalType={modalState.modalType}
config={modalConfig}
onConfirm={handleExtensionConfirm}
onCancel={dismissModal}
isSubmitting={modalState.isPending}
/>
<ExtensionInstallModal addExtension={addExtension} />
<div className="relative w-screen h-screen overflow-hidden bg-background-muted flex flex-col">
<div className="titlebar-drag-region" />
<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();
});
});
});
@@ -1,15 +1,47 @@
import { useState, useCallback, useEffect } from 'react';
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 {
ExtensionModalState,
ExtensionInfo,
ModalType,
ExtensionModalConfig,
ExtensionInstallResult,
} from '../types/extension';
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
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 {
const url = new URL(link);
@@ -23,9 +55,7 @@ function extractRemoteUrl(link: string): string | null {
return url.searchParams.get('url');
}
export const useExtensionInstallModal = (
addExtension?: (name: string, config: ExtensionConfig, enabled: boolean) => Promise<void>
) => {
export function ExtensionInstallModal({ addExtension }: ExtensionInstallModalProps) {
const [modalState, setModalState] = useState<ExtensionModalState>({
isOpen: false,
modalType: 'trusted',
@@ -44,14 +74,12 @@ export const useExtensionInstallModal = (
const config = window.electron.getConfig();
const ALLOWLIST_WARNING_MODE = config.GOOSE_ALLOWLIST_WARNING === true;
// If warning mode is enabled, always show warning but allow installation
if (ALLOWLIST_WARNING_MODE) {
return 'untrusted';
}
const allowedCommands = await window.electron.getAllowedExtensions();
// If no allowlist configured
if (!allowedCommands || allowedCommands.length === 0) {
return 'trusted';
}
@@ -158,9 +186,9 @@ export const useExtensionInstallModal = (
setPendingLink(null);
}, []);
const confirmInstall = useCallback(async (): Promise<ExtensionInstallResult> => {
const confirmInstall = useCallback(async (): Promise<void> => {
if (!pendingLink) {
return { success: false, error: 'No pending extension to install' };
return;
}
setModalState((prev) => ({ ...prev, isPending: true }));
@@ -168,17 +196,16 @@ export const useExtensionInstallModal = (
try {
console.log(`Confirming installation of extension from: ${pendingLink}`);
dismissModal();
if (addExtension) {
await addExtensionFromDeepLink(pendingLink, addExtension, () => {
console.log('Extension installation completed, navigating to extensions');
});
} 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) {
const errorMessage = error instanceof Error ? error.message : 'Installation failed';
console.error('Extension installation failed:', error);
@@ -188,16 +215,9 @@ export const useExtensionInstallModal = (
error: errorMessage,
isPending: false,
}));
return { success: false, error: errorMessage };
}
}, [pendingLink, dismissModal, addExtension]);
const getModalConfig = (): ExtensionModalConfig | null => {
if (!modalState.extensionInfo) return null;
return generateModalConfig(modalState.modalType, modalState.extensionInfo);
};
useEffect(() => {
console.log('Setting up extension install modal handler');
@@ -213,11 +233,73 @@ export const useExtensionInstallModal = (
};
}, [handleExtensionRequest]);
return {
modalState,
modalConfig: getModalConfig(),
handleExtensionRequest,
dismissModal,
confirmInstall,
const getModalConfig = (): ExtensionModalConfig | null => {
if (!modalState.extensionInfo) return null;
return generateModalConfig(modalState.modalType, modalState.extensionInfo);
};
};
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();
});
});
});
-30
View File
@@ -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;
}