@nospt/plugin-dev-ai-hub 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/dev/index.tsx +17 -217
  2. package/dist/api/DevAiHubClient.esm.js +2 -0
  3. package/dist/api/DevAiHubClient.esm.js.map +1 -1
  4. package/dist/components/AssetCard/AssetCard.esm.js +60 -134
  5. package/dist/components/AssetCard/AssetCard.esm.js.map +1 -1
  6. package/dist/components/AssetCard/AssetCard.module.css.esm.js +8 -0
  7. package/dist/components/AssetCard/AssetCard.module.css.esm.js.map +1 -0
  8. package/dist/components/AssetDetailPanel/AssetDetailPanel.esm.js +175 -281
  9. package/dist/components/AssetDetailPanel/AssetDetailPanel.esm.js.map +1 -1
  10. package/dist/components/AssetDetailPanel/AssetDetailPanel.module.css.esm.js +8 -0
  11. package/dist/components/AssetDetailPanel/AssetDetailPanel.module.css.esm.js.map +1 -0
  12. package/dist/components/AssetFilters/AssetFilters.esm.js +94 -134
  13. package/dist/components/AssetFilters/AssetFilters.esm.js.map +1 -1
  14. package/dist/components/AssetFilters/AssetFilters.module.css.esm.js +8 -0
  15. package/dist/components/AssetFilters/AssetFilters.module.css.esm.js.map +1 -0
  16. package/dist/components/AssetInstallDialog/AssetInstallDialog.esm.js +70 -126
  17. package/dist/components/AssetInstallDialog/AssetInstallDialog.esm.js.map +1 -1
  18. package/dist/components/AssetInstallDialog/AssetInstallDialog.module.css.esm.js +8 -0
  19. package/dist/components/AssetInstallDialog/AssetInstallDialog.module.css.esm.js.map +1 -0
  20. package/dist/components/DevAiHubPage/DevAiHubPage.esm.js +64 -189
  21. package/dist/components/DevAiHubPage/DevAiHubPage.esm.js.map +1 -1
  22. package/dist/components/DevAiHubPage/DevAiHubPage.module.css.esm.js +8 -0
  23. package/dist/components/DevAiHubPage/DevAiHubPage.module.css.esm.js.map +1 -0
  24. package/dist/components/McpConfigPage/McpConfigPage.esm.js +200 -0
  25. package/dist/components/McpConfigPage/McpConfigPage.esm.js.map +1 -0
  26. package/dist/components/McpConfigPage/McpConfigPage.module.css.esm.js +8 -0
  27. package/dist/components/McpConfigPage/McpConfigPage.module.css.esm.js.map +1 -0
  28. package/dist/components/McpConfigPage/index.esm.js +6 -0
  29. package/dist/components/McpConfigPage/index.esm.js.map +1 -0
  30. package/dist/components/ToolIcon/ToolIcon.esm.js +25 -13
  31. package/dist/components/ToolIcon/ToolIcon.esm.js.map +1 -1
  32. package/dist/index.d.ts +10 -5
  33. package/dist/index.esm.js +1 -1
  34. package/dist/node_modules_dist/style-inject/dist/style-inject.es.esm.js +29 -0
  35. package/dist/node_modules_dist/style-inject/dist/style-inject.es.esm.js.map +1 -0
  36. package/dist/plugin.esm.js +24 -6
  37. package/dist/plugin.esm.js.map +1 -1
  38. package/dist/routes.esm.js +2 -1
  39. package/dist/routes.esm.js.map +1 -1
  40. package/package.json +24 -14
  41. package/package.json-prepack +24 -14
  42. package/src/components/AssetCard/AssetCard.module.css +153 -0
  43. package/src/components/AssetCard/AssetCard.tsx +79 -148
  44. package/src/components/AssetDetailPanel/AssetDetailPanel.module.css +207 -0
  45. package/src/components/AssetDetailPanel/AssetDetailPanel.tsx +228 -292
  46. package/src/components/AssetFilters/AssetFilters.module.css +67 -0
  47. package/src/components/AssetFilters/AssetFilters.tsx +136 -178
  48. package/src/components/AssetInstallDialog/AssetInstallDialog.module.css +72 -0
  49. package/src/components/AssetInstallDialog/AssetInstallDialog.tsx +95 -135
  50. package/src/components/DevAiHubPage/DevAiHubPage.module.css +105 -0
  51. package/src/components/DevAiHubPage/DevAiHubPage.tsx +73 -222
  52. package/src/components/McpConfigDialog/McpConfigDialog.module.css +111 -0
  53. package/src/components/McpConfigDialog/McpConfigDialog.tsx +178 -208
  54. package/src/components/McpConfigPage/McpConfigPage.module.css +121 -0
  55. package/src/components/McpConfigPage/McpConfigPage.tsx +242 -0
  56. package/src/components/McpConfigPage/index.ts +1 -0
  57. package/src/components/ToolIcon/ToolIcon.tsx +31 -15
  58. package/src/css-modules.d.ts +4 -0
  59. package/src/index.ts +1 -1
  60. package/src/plugin.tsx +23 -3
  61. package/src/routes.ts +1 -0
  62. package/tests/e2e/.auth.json +14 -0
  63. package/tests/e2e/asset-detail.spec.ts +90 -0
  64. package/tests/e2e/asset-filtering.spec.ts +145 -0
  65. package/tests/e2e/asset-installation.spec.ts +74 -0
  66. package/tests/e2e/dev-ai-hub-page.spec.ts +89 -0
  67. package/tests/e2e/fixtures/base.ts +61 -0
  68. package/tests/e2e/fixtures/mock-api.ts +221 -0
  69. package/tests/e2e/mcp-config.spec.ts +88 -0
  70. package/dist/components/McpConfigDialog/McpConfigDialog.esm.js +0 -274
  71. package/dist/components/McpConfigDialog/McpConfigDialog.esm.js.map +0 -1
@@ -0,0 +1,242 @@
1
+ import { useEffect, useState } from 'react';
2
+ import {
3
+ Box, Flex, Text, ButtonIcon, ToggleButton, Switch,
4
+ Tabs, TabList, Tab, TabPanel,
5
+ Tooltip, TooltipTrigger,
6
+ } from '@backstage/ui';
7
+ import { RiFileCopyLine, RiCheckLine, RiDatabase2Line } from '@remixicon/react';
8
+ import { useApi, discoveryApiRef } from '@backstage/core-plugin-api';
9
+ import { ToolIcon } from '../ToolIcon';
10
+ import { useCopyToClipboard, useProviders } from '../../hooks';
11
+ import type { AiTool } from '@nospt/plugin-dev-ai-hub-common';
12
+ import styles from './McpConfigPage.module.css';
13
+
14
+ interface ToolConfig {
15
+ tool: AiTool;
16
+ label: string;
17
+ file: string;
18
+ description: string;
19
+ buildConfig: (mcpUrl: string) => string;
20
+ }
21
+
22
+ const TOOL_CONFIGS: ToolConfig[] = [
23
+ {
24
+ tool: 'claude-code',
25
+ label: 'Claude Code',
26
+ file: '.mcp.json',
27
+ description: 'Add to .mcp.json in your project root (or run via claude mcp add):',
28
+ buildConfig: url => JSON.stringify({
29
+ mcpServers: { 'dev-ai-hub': { type: 'http', url } },
30
+ }, null, 2),
31
+ },
32
+ {
33
+ tool: 'github-copilot',
34
+ label: 'GitHub Copilot',
35
+ file: '.vscode/settings.json',
36
+ description: 'Add to your VS Code settings (.vscode/settings.json or user settings):',
37
+ buildConfig: url => JSON.stringify({
38
+ 'github.copilot.chat.mcp.servers': { 'dev-ai-hub': { type: 'http', url } },
39
+ }, null, 2),
40
+ },
41
+ {
42
+ tool: 'google-gemini',
43
+ label: 'Google Gemini',
44
+ file: 'gemini-config.json',
45
+ description: 'Add to your Gemini CLI configuration:',
46
+ buildConfig: url => JSON.stringify({
47
+ mcpServers: { 'dev-ai-hub': { url } },
48
+ }, null, 2),
49
+ },
50
+ ];
51
+
52
+ function providerLabel(target: string): string {
53
+ return target.split('/').pop()?.replace(/\.git$/, '') ?? target;
54
+ }
55
+
56
+ export function McpConfigPage() {
57
+ const discoveryApi = useApi(discoveryApiRef);
58
+ const { copy: copyUrl, copied: copiedUrl } = useCopyToClipboard();
59
+ const { copy: copySnippet, copied: copiedSnippet } = useCopyToClipboard();
60
+ const [selectedToolKey, setSelectedToolKey] = useState<string>('claude-code');
61
+ const [baseUrl, setBaseUrl] = useState('');
62
+ const [selectedProvider, setSelectedProvider] = useState<string>('');
63
+ const [proactiveEnabled, setProactiveEnabled] = useState(false);
64
+
65
+ const { providers } = useProviders();
66
+ const showProviderFilter = providers.length > 1;
67
+
68
+ useEffect(() => {
69
+ discoveryApi.getBaseUrl('dev-ai-hub').then(url => setBaseUrl(url));
70
+ }, [discoveryApi]);
71
+
72
+ const cfg = TOOL_CONFIGS.find(t => t.tool === selectedToolKey) ?? TOOL_CONFIGS[0];
73
+
74
+ const buildMcpUrl = () => {
75
+ if (!baseUrl) return 'loading...';
76
+ const params = new URLSearchParams();
77
+ params.set('tool', cfg.tool);
78
+ if (selectedProvider) params.set('provider', selectedProvider);
79
+ if (proactiveEnabled) params.set('proactive', 'true');
80
+ return `${baseUrl}/mcp?${params.toString()}`;
81
+ };
82
+
83
+ const mcpUrl = buildMcpUrl();
84
+ const configSnippet = baseUrl ? cfg.buildConfig(mcpUrl) : '';
85
+
86
+ const [isDark, setIsDark] = useState(() => {
87
+ if (typeof document === 'undefined') return false;
88
+ return document.documentElement.getAttribute('data-theme-mode') === 'dark' ||
89
+ document.body.getAttribute('data-theme-mode') === 'dark';
90
+ });
91
+
92
+ useEffect(() => {
93
+ const getIsDark = () =>
94
+ document.documentElement.getAttribute('data-theme-mode') === 'dark' ||
95
+ document.body.getAttribute('data-theme-mode') === 'dark';
96
+
97
+ const observer = new MutationObserver(() => setIsDark(getIsDark()));
98
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme-mode'] });
99
+ observer.observe(document.body, { attributes: true, attributeFilter: ['data-theme-mode'] });
100
+ return () => observer.disconnect();
101
+ }, []);
102
+
103
+ return (
104
+ <div className={styles.pageRoot}>
105
+ <Text variant="title-small" weight="bold">Configure MCP Server</Text>
106
+ <Text variant="body-small" color="secondary" className={styles.subtitle}>
107
+ Connect your AI tool to the Dev AI Hub via Model Context Protocol.
108
+ </Text>
109
+
110
+ <div className={styles.configContent}>
111
+ <Tabs selectedKey={selectedToolKey} onSelectionChange={key => setSelectedToolKey(key as string)}>
112
+ <TabList className={styles.tabBar}>
113
+ {TOOL_CONFIGS.map(t => (
114
+ <Tab key={t.tool} id={t.tool}>
115
+ <span className={styles.tabContent}>
116
+ <ToolIcon tool={t.tool} size={16} />
117
+ <span>{t.label}</span>
118
+ </span>
119
+ </Tab>
120
+ ))}
121
+ </TabList>
122
+
123
+ {TOOL_CONFIGS.map(t => (
124
+ <TabPanel key={t.tool} id={t.tool}>
125
+ {showProviderFilter && (
126
+ <Box className={styles.providerSection}>
127
+ <Text variant="body-x-small" color="secondary" className={styles.sectionLabel}>
128
+ Scope to Provider
129
+ </Text>
130
+ <Flex className={styles.chipsRow}>
131
+ <ToggleButton
132
+ id="all-providers"
133
+ size="small"
134
+ className={styles.filterChip}
135
+ isSelected={!selectedProvider}
136
+ onChange={() => setSelectedProvider('')}
137
+ style={{
138
+ borderColor: !selectedProvider ? 'var(--bui-fg-primary)' : 'var(--bui-border-1)',
139
+ backgroundColor: !selectedProvider ? 'var(--bui-fg-primary)' : 'transparent',
140
+ color: !selectedProvider ? 'var(--bui-bg-neutral-1)' : 'var(--bui-fg-secondary)',
141
+ }}
142
+ >
143
+ All providers
144
+ </ToggleButton>
145
+ {providers.map(p => {
146
+ const isSelected = selectedProvider === p.id;
147
+ const label = providerLabel(p.target);
148
+ return (
149
+ <ToggleButton
150
+ key={p.id}
151
+ id={p.id}
152
+ size="small"
153
+ className={styles.filterChip}
154
+ iconStart={<RiDatabase2Line size={12} style={{ color: isSelected ? 'var(--bui-bg-neutral-1)' : 'inherit' }} />}
155
+ isSelected={isSelected}
156
+ onChange={() => setSelectedProvider(isSelected ? '' : p.id)}
157
+ style={{
158
+ borderColor: isSelected ? 'var(--bui-fg-primary)' : 'var(--bui-border-1)',
159
+ backgroundColor: isSelected ? 'var(--bui-fg-primary)' : 'transparent',
160
+ color: isSelected ? 'var(--bui-bg-neutral-1)' : 'var(--bui-fg-secondary)',
161
+ }}
162
+ >
163
+ {label}
164
+ </ToggleButton>
165
+ );
166
+ })}
167
+ </Flex>
168
+ </Box>
169
+ )}
170
+
171
+ <Flex className={styles.proactiveSection}>
172
+ <Flex className={styles.proactiveRow}>
173
+ <Switch
174
+ isSelected={proactiveEnabled}
175
+ onChange={setProactiveEnabled}
176
+ aria-label="Proactive suggestions"
177
+ />
178
+ <Box>
179
+ <Text variant="body-small" weight="bold">Proactive suggestions</Text>
180
+ <Text variant="body-x-small" color="secondary">
181
+ The AI will automatically suggest relevant assets based on your project context.
182
+ Disable if you prefer to search manually.
183
+ </Text>
184
+ </Box>
185
+ </Flex>
186
+ </Flex>
187
+
188
+ <Box className={styles.urlSection}>
189
+ <Text variant="body-x-small" color="secondary" className={styles.sectionLabel}>
190
+ MCP Endpoint
191
+ </Text>
192
+ <Flex className={styles.urlBox}>
193
+ <Text variant="body-small" className={styles.urlText}>
194
+ {mcpUrl}
195
+ </Text>
196
+ <TooltipTrigger>
197
+ <ButtonIcon
198
+ aria-label={copiedUrl ? 'Copied!' : 'Copy URL'}
199
+ icon={copiedUrl ? <RiCheckLine size={14} style={{ color: 'var(--bui-fg-success)' }} /> : <RiFileCopyLine size={14} />}
200
+ variant="tertiary"
201
+ onPress={() => copyUrl(mcpUrl)}
202
+ />
203
+ <Tooltip>{copiedUrl ? 'Copied!' : 'Copy URL'}</Tooltip>
204
+ </TooltipTrigger>
205
+ </Flex>
206
+ </Box>
207
+
208
+ <Text variant="body-x-small" color="secondary" className={styles.sectionLabel}>
209
+ {t.file}
210
+ </Text>
211
+ <Text variant="body-x-small" color="secondary" style={{ display: 'block', marginBottom: 'var(--bui-space-2)', marginTop: 'var(--bui-space-1)' }}>
212
+ {t.description}
213
+ </Text>
214
+ <div className={styles.snippetContainer}>
215
+ <pre className={`${styles.snippetPre} ${isDark ? styles.snippetPreDark : styles.snippetPreLight}`}>
216
+ {configSnippet}
217
+ </pre>
218
+ <TooltipTrigger>
219
+ <ButtonIcon
220
+ aria-label="Copy config"
221
+ className={styles.copySnippetButton}
222
+ icon={copiedSnippet ? <RiCheckLine size={14} style={{ color: 'var(--bui-fg-success)' }} /> : <RiFileCopyLine size={14} />}
223
+ variant="tertiary"
224
+ onPress={() => copySnippet(configSnippet)}
225
+ />
226
+ <Tooltip>{copiedSnippet ? 'Copied!' : 'Copy config'}</Tooltip>
227
+ </TooltipTrigger>
228
+ </div>
229
+
230
+ <Text variant="body-x-small" color="secondary" className={styles.hint}>
231
+ 💡 Omit <code>?tool=</code> from the URL to receive assets for all AI tools.
232
+ {showProviderFilter && ' Omit ?provider= to receive assets from all repositories.'}
233
+ {' '}Proactive suggestions add <code>?proactive=true</code> and register the{' '}
234
+ <code>suggest_assets</code> tool and <code>check_for_assets</code> prompt.
235
+ </Text>
236
+ </TabPanel>
237
+ ))}
238
+ </Tabs>
239
+ </div>
240
+ </div>
241
+ );
242
+ }
@@ -0,0 +1 @@
1
+ export { McpConfigPage } from './McpConfigPage';
@@ -1,6 +1,5 @@
1
- import SvgIcon, { SvgIconProps } from '@mui/material/SvgIcon';
2
- import AllInclusiveIcon from '@mui/icons-material/AllInclusive';
3
- import NearMeIcon from '@mui/icons-material/NearMe';
1
+ import type { CSSProperties } from 'react';
2
+ import { RiInfinityLine, RiNavigationLine } from '@remixicon/react';
4
3
  import { siAnthropic, siGithub, siGooglegemini } from 'simple-icons';
5
4
  import type { AiTool } from '@nospt/plugin-dev-ai-hub-common';
6
5
 
@@ -12,23 +11,35 @@ const TOOL_ICON: Record<SvgTool, { path: string; hex: string; label: string }> =
12
11
  'google-gemini': { ...siGooglegemini, label: 'Google Gemini' },
13
12
  };
14
13
 
15
- interface ToolIconProps extends Omit<SvgIconProps, 'color'> {
14
+ interface ToolIconProps {
16
15
  tool: AiTool;
17
16
  /** Use the brand's official color. Defaults to true. */
18
17
  branded?: boolean;
18
+ className?: string;
19
+ style?: CSSProperties;
20
+ /** Icon size in pixels. Defaults to 20. */
21
+ size?: number;
19
22
  }
20
23
 
21
- export function ToolIcon({ tool, branded = true, sx, ...props }: ToolIconProps) {
24
+ export function ToolIcon({ tool, branded = true, className, style, size = 20 }: ToolIconProps) {
22
25
  if (tool === 'all') {
23
- return <AllInclusiveIcon {...props} sx={{ color: 'text.secondary', ...sx }} titleAccess="Universal" />;
26
+ return (
27
+ <RiInfinityLine
28
+ size={size}
29
+ className={className}
30
+ style={{ color: 'var(--bui-fg-secondary)', ...style }}
31
+ aria-label="Universal"
32
+ />
33
+ );
24
34
  }
25
35
 
26
36
  if (tool === 'cursor') {
27
37
  return (
28
- <NearMeIcon
29
- {...props}
30
- sx={{ color: branded ? 'text.primary' : 'inherit', ...sx }}
31
- titleAccess="Cursor"
38
+ <RiNavigationLine
39
+ size={size}
40
+ className={className}
41
+ style={{ color: branded ? 'var(--bui-fg-primary)' : 'inherit', ...style }}
42
+ aria-label="Cursor"
32
43
  />
33
44
  );
34
45
  }
@@ -37,13 +48,18 @@ export function ToolIcon({ tool, branded = true, sx, ...props }: ToolIconProps)
37
48
  if (!cfg) return null;
38
49
 
39
50
  return (
40
- <SvgIcon
41
- {...props}
42
- sx={{ color: branded ? `#${cfg.hex}` : 'inherit', ...sx }}
43
- titleAccess={cfg.label}
51
+ <svg
52
+ xmlns="http://www.w3.org/2000/svg"
44
53
  viewBox="0 0 24 24"
54
+ width={size}
55
+ height={size}
56
+ fill="currentColor"
57
+ className={className}
58
+ style={{ color: branded ? `#${cfg.hex}` : 'inherit', ...style }}
59
+ aria-label={cfg.label}
60
+ role="img"
45
61
  >
46
62
  <path d={cfg.path} />
47
- </SvgIcon>
63
+ </svg>
48
64
  );
49
65
  }
@@ -0,0 +1,4 @@
1
+ declare module '*.module.css' {
2
+ const classes: { readonly [key: string]: string };
3
+ export default classes;
4
+ }
package/src/index.ts CHANGED
@@ -7,4 +7,4 @@ export { DevAiHubPage } from './pluginLegacy';
7
7
  // Shared
8
8
  export { devAiHubApiRef } from './api/DevAiHubClient';
9
9
  export type { DevAiHubApi } from './api/DevAiHubClient';
10
- export { rootRouteRef } from './routes';
10
+ export { rootRouteRef, mcpConfigRouteRef } from './routes';
package/src/plugin.tsx CHANGED
@@ -1,20 +1,22 @@
1
1
  import {
2
2
  createFrontendPlugin,
3
3
  PageBlueprint,
4
+ SubPageBlueprint,
4
5
  ApiBlueprint,
5
6
  NavItemBlueprint,
6
7
  createApiFactory,
7
8
  discoveryApiRef,
8
9
  fetchApiRef,
9
10
  } from '@backstage/frontend-plugin-api';
10
- import HubIcon from '@mui/icons-material/Hub';
11
+ import SmartToyIcon from '@mui/icons-material/SmartToy';
11
12
  import { devAiHubApiRef, DevAiHubClient } from './api/DevAiHubClient';
12
- import { rootRouteRef } from './routes';
13
+ import { rootRouteRef, mcpConfigRouteRef } from './routes';
13
14
 
14
15
  export const devAiHubPlugin = createFrontendPlugin({
15
16
  pluginId: 'dev-ai-hub',
16
17
  routes: {
17
18
  root: rootRouteRef,
19
+ mcpConfig: mcpConfigRouteRef,
18
20
  },
19
21
  extensions: [
20
22
  ApiBlueprint.make({
@@ -31,16 +33,34 @@ export const devAiHubPlugin = createFrontendPlugin({
31
33
  PageBlueprint.make({
32
34
  params: {
33
35
  path: '/dev-ai-hub',
36
+ title: 'Dev AI Hub',
34
37
  routeRef: rootRouteRef,
38
+ },
39
+ }),
40
+ SubPageBlueprint.make({
41
+ name: 'browse',
42
+ params: {
43
+ path: 'browse',
44
+ title: 'Browse',
35
45
  loader: () =>
36
46
  import('./components/DevAiHubPage').then(m => <m.DevAiHubPage />),
37
47
  },
38
48
  }),
49
+ SubPageBlueprint.make({
50
+ name: 'mcp-config',
51
+ params: {
52
+ path: 'mcp-config',
53
+ title: 'Configure MCP',
54
+ routeRef: mcpConfigRouteRef,
55
+ loader: () =>
56
+ import('./components/McpConfigPage').then(m => <m.McpConfigPage />),
57
+ },
58
+ }),
39
59
  NavItemBlueprint.make({
40
60
  params: {
41
61
  title: 'AI Hub',
42
62
  routeRef: rootRouteRef,
43
- icon: HubIcon,
63
+ icon: SmartToyIcon,
44
64
  },
45
65
  }),
46
66
  ],
package/src/routes.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  import { createRouteRef } from '@backstage/frontend-plugin-api';
2
2
 
3
3
  export const rootRouteRef = createRouteRef();
4
+ export const mcpConfigRouteRef = createRouteRef();
@@ -0,0 +1,14 @@
1
+ {
2
+ "cookies": [],
3
+ "origins": [
4
+ {
5
+ "origin": "http://localhost:3000",
6
+ "localStorage": [
7
+ {
8
+ "name": "language",
9
+ "value": "en"
10
+ }
11
+ ]
12
+ }
13
+ ]
14
+ }
@@ -0,0 +1,90 @@
1
+ import { test, expect } from './fixtures/base';
2
+ import { DEV_ASSETS } from './fixtures/mock-api';
3
+
4
+ const PAGE_URL = '/dev-ai-hub';
5
+
6
+ // First mock asset: TypeScript Best Practices (instruction, claude-code + github-copilot)
7
+ const ASSET = DEV_ASSETS[0];
8
+
9
+ test.describe('Asset Detail Panel', () => {
10
+ test.beforeEach(async ({ page }) => {
11
+ await page.goto(PAGE_URL);
12
+ await expect(page.getByText(ASSET.name)).toBeVisible();
13
+ await page.getByRole('button', { name: 'View details' }).first().click();
14
+ });
15
+
16
+ test('sets assetId query param in the URL', async ({ page }) => {
17
+ await expect(page).toHaveURL(/assetId=/);
18
+ });
19
+
20
+ test('panel header shows the asset name', async ({ page }) => {
21
+ // Scope to the drawer; use first() because the name also appears in the Preview tab <h1>
22
+ const panel = page.locator('[role="presentation"] + div');
23
+ await expect(panel.getByText(ASSET.name).first()).toBeVisible();
24
+ });
25
+
26
+ test('panel header shows the asset type tag', async ({ page }) => {
27
+ const panel = page.locator('[role="presentation"] + div');
28
+ await expect(panel.getByText(ASSET.type)).toBeVisible();
29
+ });
30
+
31
+ test('panel shows description', async ({ page }) => {
32
+ const panel = page.locator('[role="presentation"] + div');
33
+ await expect(
34
+ panel.getByText('Coding standards and best practices for TypeScript projects.'),
35
+ ).toBeVisible();
36
+ });
37
+
38
+ test('renders Preview, Metadata, and Raw YAML tabs', async ({ page }) => {
39
+ for (const name of ['Preview', 'Metadata', 'Raw YAML']) {
40
+ await expect(page.getByRole('tab', { name })).toBeVisible();
41
+ }
42
+ });
43
+
44
+ test('Preview tab renders the markdown as HTML', async ({ page }) => {
45
+ // dev mock content starts with "# TypeScript Best Practices"
46
+ await expect(page.getByRole('heading', { name: 'TypeScript Best Practices' })).toBeVisible();
47
+ });
48
+
49
+ test('Metadata tab shows author, version, branch', async ({ page }) => {
50
+ const panel = page.locator('[role="presentation"] + div');
51
+ await page.getByRole('tab', { name: 'Metadata' }).click();
52
+ await expect(panel.getByText('Author:')).toBeVisible();
53
+ await expect(panel.getByText('Platform Team')).toBeVisible();
54
+ await expect(panel.getByText('Version:')).toBeVisible();
55
+ await expect(panel.getByText('1.0.0')).toBeVisible();
56
+ await expect(panel.getByText('Branch:')).toBeVisible();
57
+ await expect(panel.getByText('main')).toBeVisible();
58
+ });
59
+
60
+ test('Metadata tab shows compatible tools as tags', async ({ page }) => {
61
+ await page.getByRole('tab', { name: 'Metadata' }).click();
62
+ await expect(page.getByText('Compatible tools')).toBeVisible();
63
+ await expect(page.getByText('claude-code')).toBeVisible();
64
+ await expect(page.getByText('github-copilot')).toBeVisible();
65
+ });
66
+
67
+ test('Raw YAML tab shows YAML source', async ({ page }) => {
68
+ await page.getByRole('tab', { name: 'Raw YAML' }).click();
69
+ await expect(page.getByText(/name: TypeScript Best Practices/)).toBeVisible();
70
+ });
71
+
72
+ test('"Copy Markdown" button is visible in the actions footer', async ({ page }) => {
73
+ await expect(page.getByRole('button', { name: 'Copy Markdown' })).toBeVisible();
74
+ });
75
+
76
+ test('"Open in Repo" button is visible in the actions footer', async ({ page }) => {
77
+ await expect(page.getByRole('button', { name: 'Open in Repo' })).toBeVisible();
78
+ });
79
+
80
+ test('Close button (×) removes assetId from the URL', async ({ page }) => {
81
+ await page.getByRole('button', { name: 'Close' }).click();
82
+ await expect(page).not.toHaveURL(/assetId=/);
83
+ });
84
+
85
+ test('clicking the overlay removes assetId from the URL', async ({ page }) => {
86
+ // Overlay has role="presentation"; click dispatches onClose
87
+ await page.locator('[role="presentation"]').click({ position: { x: 5, y: 5 } });
88
+ await expect(page).not.toHaveURL(/assetId=/);
89
+ });
90
+ });
@@ -0,0 +1,145 @@
1
+ import { test, expect } from './fixtures/base';
2
+ import { DEV_ASSETS } from './fixtures/mock-api';
3
+
4
+ const PAGE_URL = '/dev-ai-hub';
5
+
6
+ test.describe('Asset Filters', () => {
7
+ test.beforeEach(async ({ page }) => {
8
+ await page.goto(PAGE_URL);
9
+ await expect(page.getByText(DEV_ASSETS[0].name)).toBeVisible();
10
+ });
11
+
12
+ // ── Search ────────────────────────────────────────────────────────────────
13
+
14
+ test('SearchField has the correct placeholder text', async ({ page }) => {
15
+ await expect(
16
+ page.getByRole('searchbox', { name: 'Search assets' }),
17
+ ).toHaveAttribute('placeholder', 'Search assets by name, description or content…');
18
+ });
19
+
20
+ test('searching by partial name filters the grid', async ({ page }) => {
21
+ await page.getByRole('searchbox', { name: 'Search assets' }).fill('typescript');
22
+ await expect(page.getByText('TypeScript Best Practices')).toBeVisible();
23
+ await expect(page.getByText('Code Review Agent')).not.toBeVisible();
24
+ });
25
+
26
+ test('search is case-insensitive', async ({ page }) => {
27
+ await page.getByRole('searchbox', { name: 'Search assets' }).fill('GIT COMMIT');
28
+ await expect(page.getByText('Git Commit', { exact: true })).toBeVisible();
29
+ });
30
+
31
+ test('search by description matches partial words', async ({ page }) => {
32
+ await page.getByRole('searchbox', { name: 'Search assets' }).fill('conventional git commit');
33
+ await expect(page.getByText('Git Commit', { exact: true })).toBeVisible();
34
+ });
35
+
36
+ test('empty search restores all results', async ({ page }) => {
37
+ const search = page.getByRole('searchbox', { name: 'Search assets' });
38
+ await search.fill('typescript');
39
+ await expect(page.getByText('TypeScript Best Practices')).toBeVisible();
40
+
41
+ await search.clear();
42
+ for (const asset of DEV_ASSETS) {
43
+ await expect(page.getByText(asset.name, { exact: true })).toBeVisible();
44
+ }
45
+ });
46
+
47
+ test('no-match search shows empty state', async ({ page }) => {
48
+ await page.getByRole('searchbox', { name: 'Search assets' }).fill('xyznotfound');
49
+ await expect(page.getByText('No assets found')).toBeVisible();
50
+ });
51
+
52
+ // ── Type filter ───────────────────────────────────────────────────────────
53
+
54
+ test('all type-filter buttons are rendered', async ({ page }) => {
55
+ for (const label of ['All', 'Instructions', 'Agents', 'Skills', 'Workflows']) {
56
+ await expect(page.getByRole('button', { name: label }).first()).toBeVisible();
57
+ }
58
+ });
59
+
60
+ test('"Instructions" button filters to instruction assets only', async ({ page }) => {
61
+ await page.getByRole('button', { name: 'Instructions', exact: true }).click();
62
+ // 2 instruction assets in the mock
63
+ await expect(page.getByText('2 assets found')).toBeVisible();
64
+ await expect(page.getByText('TypeScript Best Practices')).toBeVisible();
65
+ await expect(page.getByText('Security Guidelines')).toBeVisible();
66
+ await expect(page.getByText('Code Review Agent')).not.toBeVisible();
67
+ });
68
+
69
+ test('"Skills" button filters to skill assets only', async ({ page }) => {
70
+ await page.getByRole('button', { name: 'Skills', exact: true }).click();
71
+ await expect(page.getByText('1 asset found')).toBeVisible();
72
+ await expect(page.getByText('Git Commit', { exact: true })).toBeVisible();
73
+ });
74
+
75
+ test('"Workflows" button filters to workflow assets only', async ({ page }) => {
76
+ await page.getByRole('button', { name: 'Workflows', exact: true }).click();
77
+ await expect(page.getByText('1 asset found')).toBeVisible();
78
+ await expect(page.getByText('Feature Development Workflow')).toBeVisible();
79
+ });
80
+
81
+ test('"All" button clears the type filter', async ({ page }) => {
82
+ await page.getByRole('button', { name: 'Skills', exact: true }).click();
83
+ await expect(page.getByText('1 asset found')).toBeVisible();
84
+
85
+ await page.getByRole('button', { name: 'All', exact: true }).click();
86
+ await expect(page.getByText(`${DEV_ASSETS.length} assets found`)).toBeVisible();
87
+ });
88
+
89
+ // ── AI Tool filter ────────────────────────────────────────────────────────
90
+
91
+ test('all AI-tool filter buttons are rendered', async ({ page }) => {
92
+ for (const label of ['All Tools', 'Claude Code', 'GitHub Copilot', 'Google Gemini', 'Cursor']) {
93
+ await expect(page.getByRole('button', { name: label }).first()).toBeVisible();
94
+ }
95
+ });
96
+
97
+ test('"Claude Code" tool filter shows only claude-code-compatible assets', async ({ page }) => {
98
+ await page.getByRole('button', { name: 'Claude Code' }).first().click();
99
+ // claude-code: mock-1, mock-2, mock-3 (all), mock-5
100
+ await expect(page.getByText('TypeScript Best Practices')).toBeVisible();
101
+ await expect(page.getByText('Code Review Agent')).toBeVisible();
102
+ await expect(page.getByText('Git Commit', { exact: true })).toBeVisible(); // tools: ['all']
103
+ await expect(page.getByText('Security Guidelines')).toBeVisible();
104
+ await expect(page.getByText('Product Manager')).not.toBeVisible(); // github-copilot only
105
+ });
106
+
107
+ test('"Cursor" tool filter shows only cursor-compatible assets', async ({ page }) => {
108
+ await page.getByRole('button', { name: 'Cursor' }).first().click();
109
+ // cursor: mock-4 (github-copilot, cursor), mock-3 (all)
110
+ await expect(page.getByText('Feature Development Workflow')).toBeVisible();
111
+ await expect(page.getByText('Git Commit', { exact: true })).toBeVisible(); // tools: ['all']
112
+ });
113
+
114
+ // ── Tags ──────────────────────────────────────────────────────────────────
115
+
116
+ test('tag chips are rendered for the current result set', async ({ page }) => {
117
+ // 'typescript' tag is present in mock-1
118
+ await expect(page.getByRole('button', { name: '#typescript' }).first()).toBeVisible();
119
+ });
120
+
121
+ test('clicking a tag chip filters by that tag', async ({ page }) => {
122
+ await page.getByRole('button', { name: '#git' }).first().click();
123
+ // mock-3 has ['git', 'commits', 'conventional-commits']
124
+ await expect(page.getByText('Git Commit', { exact: true })).toBeVisible();
125
+ await expect(page.getByText('TypeScript Best Practices')).not.toBeVisible();
126
+ });
127
+
128
+ test('clicking a second tag chip adds an AND condition', async ({ page }) => {
129
+ await page.getByRole('button', { name: '#typescript' }).first().click();
130
+ await page.getByRole('button', { name: '#best-practices' }).first().click();
131
+ // Only mock-1 has both 'typescript' AND 'best-practices'
132
+ await expect(page.getByText('TypeScript Best Practices')).toBeVisible();
133
+ await expect(page.getByText('Security Guidelines')).not.toBeVisible();
134
+ });
135
+
136
+ test('clicking an active tag chip deselects it', async ({ page }) => {
137
+ const tagBtn = page.getByRole('button', { name: '#typescript' }).first();
138
+ await tagBtn.click();
139
+ await expect(page.getByText('TypeScript Best Practices')).toBeVisible();
140
+ await expect(page.getByText('Code Review Agent')).not.toBeVisible();
141
+
142
+ await tagBtn.click(); // deselect
143
+ await expect(page.getByText('Code Review Agent')).toBeVisible();
144
+ });
145
+ });