Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
675 changes: 675 additions & 0 deletions app/src/components/skills/SkillsExplorerTab.tsx

Large diffs are not rendered by default.

267 changes: 267 additions & 0 deletions app/src/components/skills/__tests__/SkillsExplorerTab.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import type { CatalogEntry } from '../../../services/api/skillRegistryApi';
import type { SkillSummary } from '../../../services/api/skillsApi';
import SkillsExplorerTab from '../SkillsExplorerTab';

vi.mock('../../../services/api/skillsApi', () => ({
skillsApi: {
listSkills: vi.fn(),
installSkillFromUrl: vi.fn(),
uninstallSkill: vi.fn(),
},
}));

vi.mock('../../../services/api/skillRegistryApi', () => ({
skillRegistryApi: {
browse: vi.fn(),
search: vi.fn(),
sources: vi.fn(),
install: vi.fn(),
},
}));

const MOCK_SKILL: SkillSummary = {
id: 'test-skill',
name: 'Test Skill',
description: 'A test skill for unit testing',
version: '1.0.0',
author: 'Test Author',
tags: ['test', 'automation'],
platforms: [],
relatedSkills: [],
sourceFormat: 'hermes',
tools: [],
prompts: [],
location: '/Users/test/.openhuman/skills/test-skill/SKILL.md',
resources: [],
scope: 'user',
legacy: false,
warnings: [],
};

const MOCK_PROJECT_SKILL: SkillSummary = {
...MOCK_SKILL,
id: 'project-skill',
name: 'Project Skill',
sourceFormat: 'openhuman',
scope: 'project',
};

const MOCK_CATALOG_ENTRY: CatalogEntry = {
id: 'registry-skill-1',
name: 'Registry Skill',
description: 'A skill from the registry',
format: 'hermes',
author: 'Registry Author',
version: '2.0.0',
tags: ['registry', 'remote'],
download_url: 'https://example.com/SKILL.md',
source_id: 'openhuman-community',
stars: 42,
updated_at: '2026-01-01',
};

async function switchToInstalled() {
const installedTab = screen.getByText('Installed', { selector: 'button' });
await act(async () => {
fireEvent.click(installedTab);
});
}

describe('SkillsExplorerTab', () => {
beforeEach(async () => {
const { skillsApi } = await import('../../../services/api/skillsApi');
const { skillRegistryApi } = await import(
'../../../services/api/skillRegistryApi'
);
vi.mocked(skillsApi.listSkills).mockReset();
vi.mocked(skillsApi.uninstallSkill).mockReset();
vi.mocked(skillRegistryApi.browse).mockReset();
vi.mocked(skillRegistryApi.install).mockReset();
vi.mocked(skillRegistryApi.browse).mockResolvedValue([]);
});

it('defaults to registry view and shows catalog entries', async () => {
const { skillsApi } = await import('../../../services/api/skillsApi');
const { skillRegistryApi } = await import(
'../../../services/api/skillRegistryApi'
);
vi.mocked(skillsApi.listSkills).mockResolvedValue([]);
vi.mocked(skillRegistryApi.browse).mockResolvedValue([MOCK_CATALOG_ENTRY]);

render(<SkillsExplorerTab />);

await waitFor(() => {
expect(screen.getByText('Registry Skill')).toBeInTheDocument();
});
expect(screen.getByText('Hermes')).toBeInTheDocument();
expect(screen.getByText('openhuman-community')).toBeInTheDocument();
});

it('shows installed skills when switching to installed tab', async () => {
const { skillsApi } = await import('../../../services/api/skillsApi');
vi.mocked(skillsApi.listSkills).mockResolvedValue([MOCK_SKILL, MOCK_PROJECT_SKILL]);

render(<SkillsExplorerTab />);

await waitFor(() => {
expect(screen.getByText('Installed')).toBeInTheDocument();
});

await switchToInstalled();

await waitFor(() => {
expect(screen.getByText('Test Skill')).toBeInTheDocument();
});
expect(screen.getByText('Project Skill')).toBeInTheDocument();
});

it('shows empty state when no installed skills', async () => {
const { skillsApi } = await import('../../../services/api/skillsApi');
vi.mocked(skillsApi.listSkills).mockResolvedValue([]);

render(<SkillsExplorerTab />);
await switchToInstalled();

await waitFor(() => {
expect(screen.getByText('No skills found')).toBeInTheDocument();
});
});

it('shows error state on registry fetch failure', async () => {
const { skillRegistryApi } = await import(
'../../../services/api/skillRegistryApi'
);
const { skillsApi } = await import('../../../services/api/skillsApi');
vi.mocked(skillsApi.listSkills).mockResolvedValue([]);
vi.mocked(skillRegistryApi.browse).mockRejectedValue(new Error('Network error'));

render(<SkillsExplorerTab />);

await waitFor(() => {
expect(screen.getByText('Network error')).toBeInTheDocument();
});
expect(screen.getByRole('button', { name: /Try again/ })).toBeInTheDocument();
});

it('filters installed skills by search query', async () => {
const { skillsApi } = await import('../../../services/api/skillsApi');
vi.mocked(skillsApi.listSkills).mockResolvedValue([MOCK_SKILL, MOCK_PROJECT_SKILL]);

render(<SkillsExplorerTab />);
await switchToInstalled();

await waitFor(() => {
expect(screen.getByText('Test Skill')).toBeInTheDocument();
});

const searchInput = screen.getByPlaceholderText('Search skills...');
fireEvent.change(searchInput, { target: { value: 'project' } });

expect(screen.queryByText('Test Skill')).not.toBeInTheDocument();
expect(screen.getByText('Project Skill')).toBeInTheDocument();
});

it('shows install from URL button', async () => {
const { skillsApi } = await import('../../../services/api/skillsApi');
vi.mocked(skillsApi.listSkills).mockResolvedValue([]);

render(<SkillsExplorerTab />);

await waitFor(() => {
expect(screen.getByTestId('skill-install-from-url-btn')).toBeInTheDocument();
});
});

it('shows uninstall button only for user-scope skills', async () => {
const { skillsApi } = await import('../../../services/api/skillsApi');
vi.mocked(skillsApi.listSkills).mockResolvedValue([MOCK_SKILL, MOCK_PROJECT_SKILL]);

render(<SkillsExplorerTab />);
await switchToInstalled();

await waitFor(() => {
expect(screen.getByTestId('skill-explorer-tile-test-skill')).toBeInTheDocument();
});

expect(screen.getByTestId('skill-uninstall-test-skill')).toBeInTheDocument();
expect(screen.queryByTestId('skill-uninstall-project-skill')).not.toBeInTheDocument();
});

it('displays version and tags in installed view', async () => {
const { skillsApi } = await import('../../../services/api/skillsApi');
vi.mocked(skillsApi.listSkills).mockResolvedValue([MOCK_SKILL]);

render(<SkillsExplorerTab />);
await switchToInstalled();

await waitFor(() => {
expect(screen.getByText('v1.0.0')).toBeInTheDocument();
});
expect(screen.getByText('test')).toBeInTheDocument();
expect(screen.getByText('automation')).toBeInTheDocument();
});

it('displays scope badges', async () => {
const { skillsApi } = await import('../../../services/api/skillsApi');
vi.mocked(skillsApi.listSkills).mockResolvedValue([MOCK_SKILL, MOCK_PROJECT_SKILL]);

render(<SkillsExplorerTab />);
await switchToInstalled();

await waitFor(() => {
expect(screen.getByText('Test Skill')).toBeInTheDocument();
});
expect(screen.getAllByText('User').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Project').length).toBeGreaterThanOrEqual(1);
});

it('shows skill warnings when present', async () => {
const { skillsApi } = await import('../../../services/api/skillsApi');
const skillWithWarning = {
...MOCK_SKILL,
warnings: ['Missing required field: author'],
};
vi.mocked(skillsApi.listSkills).mockResolvedValue([skillWithWarning]);

render(<SkillsExplorerTab />);
await switchToInstalled();

await waitFor(() => {
expect(screen.getByText('Missing required field: author')).toBeInTheDocument();
});
});

it('shows "Installed" badge for already-installed catalog entries', async () => {
const { skillsApi } = await import('../../../services/api/skillsApi');
const { skillRegistryApi } = await import(
'../../../services/api/skillRegistryApi'
);
const installedSkill = { ...MOCK_SKILL, id: 'registry-skill-1' };
vi.mocked(skillsApi.listSkills).mockResolvedValue([installedSkill]);
vi.mocked(skillRegistryApi.browse).mockResolvedValue([MOCK_CATALOG_ENTRY]);

render(<SkillsExplorerTab />);

await waitFor(() => {
expect(screen.getByText('Registry Skill')).toBeInTheDocument();
});
expect(screen.getByTestId('registry-tile-registry-skill-1')).toBeInTheDocument();
});

it('has an install from URL button', async () => {
const { skillsApi } = await import('../../../services/api/skillsApi');
vi.mocked(skillsApi.listSkills).mockResolvedValue([]);

render(<SkillsExplorerTab />);

await waitFor(() => {
expect(screen.getByTestId('skill-install-from-url-btn')).toBeInTheDocument();
});
expect(screen.getByTestId('skill-install-from-url-btn')).toHaveTextContent(
'Install from URL'
);
});
});
16 changes: 16 additions & 0 deletions app/src/lib/i18n/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,22 @@ const messages: TranslationMap = {
'skills.explorer.subtitle':
'تصفح حزم SKILL.md، بما في ذلك markdown والسكربتات والمراجع والقوالب والأمثلة والمطالبات بنمط Hermes.',
'skills.explorer.title': 'مستكشف المهارات',
'skills.explorer.searchPlaceholder': 'Search skills...',
'skills.explorer.scopeUser': 'User',
'skills.explorer.scopeProject': 'Project',
'skills.explorer.scopeLegacy': 'Legacy',
'skills.explorer.noDescription': 'No description provided.',
'skills.explorer.uninstallSuccess': 'Skill uninstalled successfully.',
'skills.explorer.registryTab': 'السجل',
'skills.explorer.installedTab': 'المثبتة',
'skills.explorer.allFormats': 'جميع الأنماط',
'skills.explorer.refreshRegistry': 'تحديث السجل',
'skills.explorer.registryEmptyTitle': 'لا توجد إدخالات في السجل',
'skills.explorer.registryEmptyDescription':
'تعذر تحميل سجل المهارات. تحقق من اتصالك وحاول التحديث.',
'skills.explorer.installed': 'مثبت',
'skills.explorer.install': 'تثبيت',
'skills.explorer.installing': 'جارٍ التثبيت…',
'skills.integrations': 'التكاملات',
'skills.integrationsSubtitle':
'اتصالات OAuth السحابية — سجّل الدخول بحسابك ويتولى Composio إدارة الرموز حتى يتمكن الوكلاء من القراءة والتصرف نيابةً عنك. لا حاجة لإدارة مفاتيح API.',
Expand Down
16 changes: 16 additions & 0 deletions app/src/lib/i18n/bn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,22 @@ const messages: TranslationMap = {
'skills.explorer.subtitle':
'Hermes-ধাঁচের markdown, scripts, references, templates, examples এবং prompts সহ SKILL.md প্যাকেজ ব্রাউজ করুন।',
'skills.explorer.title': 'স্কিল এক্সপ্লোরার',
'skills.explorer.searchPlaceholder': 'Search skills...',
'skills.explorer.scopeUser': 'User',
'skills.explorer.scopeProject': 'Project',
'skills.explorer.scopeLegacy': 'Legacy',
'skills.explorer.noDescription': 'No description provided.',
'skills.explorer.uninstallSuccess': 'Skill uninstalled successfully.',
'skills.explorer.registryTab': 'রেজিস্ট্রি',
'skills.explorer.installedTab': 'ইনস্টল করা',
'skills.explorer.allFormats': 'সব ফরম্যাট',
'skills.explorer.refreshRegistry': 'রেজিস্ট্রি রিফ্রেশ করুন',
'skills.explorer.registryEmptyTitle': 'কোনো রেজিস্ট্রি এন্ট্রি নেই',
'skills.explorer.registryEmptyDescription':
'দক্ষতা রেজিস্ট্রি লোড করা যায়নি। আপনার সংযোগ পরীক্ষা করে রিফ্রেশ করার চেষ্টা করুন।',
'skills.explorer.installed': 'ইনস্টল করা হয়েছে',
'skills.explorer.install': 'ইনস্টল করুন',
'skills.explorer.installing': 'ইনস্টল হচ্ছে…',
'skills.integrations': 'ইন্টিগ্রেশন',
'skills.integrationsSubtitle':
'ক্লাউড-ভিত্তিক OAuth সংযোগ — আপনার অ্যাকাউন্ট দিয়ে সাইন ইন করুন এবং Composio টোকেন পরিচালনা করে যাতে এজেন্টরা আপনার পক্ষে পড়তে এবং কাজ করতে পারে। কোনো API কী পরিচালনা করতে হবে না।',
Expand Down
16 changes: 16 additions & 0 deletions app/src/lib/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,22 @@ const messages: TranslationMap = {
'skills.explorer.subtitle':
'Durchsuche SKILL.md-Pakete, einschließlich Hermes-artigem Markdown, Skripten, Referenzen, Vorlagen, Beispielen und Prompts.',
'skills.explorer.title': 'Skill-Explorer',
'skills.explorer.searchPlaceholder': 'Search skills...',
'skills.explorer.scopeUser': 'User',
'skills.explorer.scopeProject': 'Project',
'skills.explorer.scopeLegacy': 'Legacy',
'skills.explorer.noDescription': 'No description provided.',
'skills.explorer.uninstallSuccess': 'Skill uninstalled successfully.',
'skills.explorer.registryTab': 'Verzeichnis',
'skills.explorer.installedTab': 'Installiert',
'skills.explorer.allFormats': 'Alle Formate',
'skills.explorer.refreshRegistry': 'Verzeichnis aktualisieren',
'skills.explorer.registryEmptyTitle': 'Keine Verzeichniseinträge',
'skills.explorer.registryEmptyDescription':
'Das Skill-Verzeichnis konnte nicht geladen werden. Prüfen Sie Ihre Verbindung und versuchen Sie es erneut.',
'skills.explorer.installed': 'Installiert',
'skills.explorer.install': 'Installieren',
'skills.explorer.installing': 'Wird installiert…',
'skills.integrations': 'Integrationen',
'skills.integrationsSubtitle':
'Cloud-basierte OAuth-Verbindungen – mit deinem Konto anmelden und Composio verwaltet die Tokens, damit Agenten in deinem Namen lesen und handeln können. Keine API-Schlüssel notwendig.',
Expand Down
16 changes: 16 additions & 0 deletions app/src/lib/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,22 @@ const en: TranslationMap = {
'skills.explorer.subtitle':
'Browse SKILL.md packages, including Hermes-style markdown, scripts, references, templates, examples, and prompts.',
'skills.explorer.title': 'Skills explorer',
'skills.explorer.searchPlaceholder': 'Search skills...',
'skills.explorer.scopeUser': 'User',
'skills.explorer.scopeProject': 'Project',
'skills.explorer.scopeLegacy': 'Legacy',
'skills.explorer.noDescription': 'No description provided.',
'skills.explorer.uninstallSuccess': 'Skill uninstalled successfully.',
'skills.explorer.registryTab': 'Registry',
'skills.explorer.installedTab': 'Installed',
'skills.explorer.allFormats': 'All formats',
'skills.explorer.refreshRegistry': 'Refresh registry',
'skills.explorer.registryEmptyTitle': 'No registry entries',
'skills.explorer.registryEmptyDescription':
'Could not load the skill registry. Check your connection and try refreshing.',
'skills.explorer.installed': 'Installed',
'skills.explorer.install': 'Install',
'skills.explorer.installing': 'Installing…',
'skills.integrations': 'Composio Integrations',
'skills.integrationsSubtitle':
'Cloud-based OAuth connections — sign in with your account and Composio brokers the tokens so agents can read and act on your behalf. No API keys to manage.',
Expand Down
16 changes: 16 additions & 0 deletions app/src/lib/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,22 @@ const messages: TranslationMap = {
'skills.explorer.subtitle':
'Explora paquetes SKILL.md, incluidos markdown, scripts, referencias, plantillas, ejemplos y prompts estilo Hermes.',
'skills.explorer.title': 'Explorador de skills',
'skills.explorer.searchPlaceholder': 'Search skills...',
'skills.explorer.scopeUser': 'User',
'skills.explorer.scopeProject': 'Project',
'skills.explorer.scopeLegacy': 'Legacy',
'skills.explorer.noDescription': 'No description provided.',
'skills.explorer.uninstallSuccess': 'Skill uninstalled successfully.',
'skills.explorer.registryTab': 'Registro',
'skills.explorer.installedTab': 'Instaladas',
'skills.explorer.allFormats': 'Todos los formatos',
'skills.explorer.refreshRegistry': 'Actualizar registro',
'skills.explorer.registryEmptyTitle': 'Sin entradas en el registro',
'skills.explorer.registryEmptyDescription':
'No se pudo cargar el registro de habilidades. Verifique su conexión e intente actualizar.',
'skills.explorer.installed': 'Instalada',
'skills.explorer.install': 'Instalar',
'skills.explorer.installing': 'Instalando…',
'skills.integrations': 'Integraciones',
'skills.integrationsSubtitle':
'Conexiones OAuth en la nube: inicia sesión con tu cuenta y Composio gestiona los tokens para que los agentes puedan leer y actuar en tu nombre. Sin claves de API que administrar.',
Expand Down
Loading
Loading