diff --git a/.gitignore b/.gitignore index 5b0ecf53..bb2213d6 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,11 @@ coverage/ *.tsbuildinfo package-lock.json +# Playwright +**/playwright-report/ +**/test-results/ +**/.playwright/ + # Temporary files .tmp/ tmp/ diff --git a/web/e2e/helpers/api-mocks.ts b/web/e2e/helpers/api-mocks.ts new file mode 100644 index 00000000..2547d414 --- /dev/null +++ b/web/e2e/helpers/api-mocks.ts @@ -0,0 +1,163 @@ +import type { Page, Route } from '@playwright/test' + +type EnvelopeOptions = { + status?: number + msg?: string +} + +type SkillSummary = { + id: number + slug: string + displayName: string + summary?: string + downloadCount: number + starCount: number + ratingCount: number + namespace: string + updatedAt: string + canSubmitPromotion: boolean + headlineVersion?: { + id: number + version: string + status: string + } + publishedVersion?: { + id: number + version: string + status: string + } +} + +type SearchResponse = { + items: SkillSummary[] + total: number + page: number + size: number +} + +type SearchHandler = (url: URL) => SearchResponse +type SkillDetailHandler = () => SkillSummary + +const JSON_HEADERS = { + 'access-control-allow-origin': '*', + 'content-type': 'application/json', +} + +function envelope(data: T, options: EnvelopeOptions = {}) { + return JSON.stringify({ + code: options.status && options.status >= 400 ? options.status : 0, + msg: options.msg ?? 'ok', + data, + timestamp: '2026-03-27T00:00:00Z', + requestId: 'playwright-e2e', + }) +} + +async function fulfillJson(route: Route, data: T, options?: EnvelopeOptions) { + await route.fulfill({ + status: options?.status ?? 200, + headers: JSON_HEADERS, + body: envelope(data, options), + }) +} + +export function skill( + id: number, + displayName: string, + overrides: Partial = {}, +): SkillSummary { + return { + id, + slug: displayName.toLowerCase().replace(/\s+/g, '-'), + displayName, + summary: `${displayName} summary`, + downloadCount: 100 + id, + starCount: 10 + id, + ratingCount: 0, + namespace: 'global', + updatedAt: '2026-03-20T00:00:00Z', + canSubmitPromotion: false, + headlineVersion: { + id: id * 10, + version: '1.0.0', + status: 'PUBLISHED', + }, + publishedVersion: { + id: id * 10, + version: '1.0.0', + status: 'PUBLISHED', + }, + ...overrides, + } +} + +export async function setEnglishLocale(page: Page) { + await page.addInitScript(() => { + window.localStorage.setItem('i18nextLng', 'en') + }) +} + +export async function mockStaticApis( + page: Page, + options: { + authenticated?: boolean + }, +) { + const authenticated = options.authenticated ?? false + + await page.route('**/api/v1/auth/me', async (route) => { + if (!authenticated) { + await fulfillJson(route, null, { status: 401, msg: 'Unauthorized' }) + return + } + + await fulfillJson(route, { + userId: 'local-user', + displayName: 'Local User', + platformRoles: [], + }) + }) + + await page.route('**/api/v1/auth/methods**', async (route) => { + await fulfillJson(route, []) + }) + + await page.route('**/api/v1/auth/providers**', async (route) => { + await fulfillJson(route, []) + }) + + await page.route('**/api/web/labels', async (route) => { + await fulfillJson(route, [ + { slug: 'official', type: 'RECOMMENDED', displayName: 'Official' }, + { slug: 'featured', type: 'RECOMMENDED', displayName: 'Featured' }, + ]) + }) +} + +export async function mockCommonApis( + page: Page, + options: { + authenticated?: boolean + searchHandler?: SearchHandler + skillDetailHandler?: SkillDetailHandler + }, +) { + await mockStaticApis(page, options) + + if (options.searchHandler) { + await page.route('**/api/web/skills?**', async (route) => { + const url = new URL(route.request().url()) + await fulfillJson(route, options.searchHandler!(url)) + }) + } + + if (options.skillDetailHandler) { + await page.route('**/api/web/skills/**', async (route) => { + // Skip search endpoint + if (route.request().url().includes('?')) { + return route.continue() + } + await fulfillJson(route, options.skillDetailHandler!()) + }) + } +} diff --git a/web/e2e/network-error.spec.ts b/web/e2e/network-error.spec.ts new file mode 100644 index 00000000..348ab3ae --- /dev/null +++ b/web/e2e/network-error.spec.ts @@ -0,0 +1,119 @@ +import { expect, test } from '@playwright/test' +import { mockStaticApis, setEnglishLocale, skill } from './helpers/api-mocks' + +function buildSearchResponse(url: URL) { + const q = url.searchParams.get('q') ?? '' + const page = Number(url.searchParams.get('page') ?? '0') + const size = Number(url.searchParams.get('size') ?? '12') + + if (q === 'skill') { + return { + items: [skill(2, 'Recovered Skill Search')], + total: 1, + page, + size, + } + } + + return { + items: [skill(1, 'Initial Search Result')], + total: 1, + page, + size, + } +} + +test.describe('Network Error Handling', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + await mockStaticApis(page, { authenticated: false }) + }) + + test('shows an empty state when a search request fails', async ({ page }) => { + let failSearchRequests = false + + await page.route('**/api/web/skills?**', async (route) => { + if (failSearchRequests) { + await route.abort('internetdisconnected') + return + } + + const url = new URL(route.request().url()) + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + msg: 'ok', + data: buildSearchResponse(url), + timestamp: '2026-03-27T00:00:00Z', + requestId: 'playwright-e2e', + }), + }) + }) + + await page.goto('/search?q=&sort=relevance&page=0&starredOnly=false') + await expect(page.getByRole('heading', { name: /^Initial Search Result$/ })).toBeVisible() + + failSearchRequests = true + + const searchInput = page.getByRole('textbox') + await searchInput.fill('test query') + await searchInput.press('Enter') + + await expect(page.getByRole('heading', { name: 'No results found' })).toBeVisible() + await expect(searchInput).toHaveValue('test query') + }) + + test('renders the page shell even when the initial request fails', async ({ page }) => { + await page.route('**/api/web/skills?**', async (route) => { + await route.abort('internetdisconnected') + }) + + await page.goto('/search?q=&sort=relevance&page=0&starredOnly=false') + + await expect(page.getByRole('textbox')).toBeVisible() + await expect(page.getByRole('heading', { name: 'No results found' })).toBeVisible() + }) + + test('recovers when a later search request succeeds again', async ({ page }) => { + let failSearchRequests = false + + await page.route('**/api/web/skills?**', async (route) => { + if (failSearchRequests) { + await route.abort('internetdisconnected') + return + } + + const url = new URL(route.request().url()) + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + msg: 'ok', + data: buildSearchResponse(url), + timestamp: '2026-03-27T00:00:00Z', + requestId: 'playwright-e2e', + }), + }) + }) + + await page.goto('/search?q=&sort=relevance&page=0&starredOnly=false') + await expect(page.getByRole('heading', { name: /^Initial Search Result$/ })).toBeVisible() + + const searchInput = page.getByRole('textbox') + + failSearchRequests = true + await searchInput.fill('offline query') + await searchInput.press('Enter') + await expect(page.getByRole('heading', { name: 'No results found' })).toBeVisible() + + failSearchRequests = false + await searchInput.fill('skill') + await searchInput.press('Enter') + + await expect(page.getByRole('heading', { name: /^Recovered Skill Search$/ })).toBeVisible() + await expect(page.getByRole('heading', { name: 'No results found' })).not.toBeVisible() + }) +}) diff --git a/web/e2e/search-flow.spec.ts b/web/e2e/search-flow.spec.ts new file mode 100644 index 00000000..13e0f506 --- /dev/null +++ b/web/e2e/search-flow.spec.ts @@ -0,0 +1,114 @@ +import { expect, test } from '@playwright/test' +import { mockCommonApis, setEnglishLocale, skill } from './helpers/api-mocks' + +function buildSearchResponse(url: URL) { + const q = url.searchParams.get('q') ?? '' + const sort = url.searchParams.get('sort') ?? 'newest' + const label = url.searchParams.get('label') ?? '' + const page = Number(url.searchParams.get('page') ?? '0') + const size = Number(url.searchParams.get('size') ?? '12') + + if (q === 'agent' && sort === 'downloads' && label === 'official' && page === 1) { + return { + items: [skill(4, 'Official Agent Page Two')], + total: 24, + page, + size, + } + } + + if (q === 'agent' && sort === 'downloads' && label === 'official') { + return { + items: [skill(3, 'Official Agent')], + total: 24, + page, + size, + } + } + + if (q === 'agent' && sort === 'downloads') { + return { + items: [skill(2, 'Download Leader Agent')], + total: 1, + page, + size, + } + } + + if (q === 'agent') { + return { + items: [skill(1, 'Agent Builder')], + total: 1, + page, + size, + } + } + + return { + items: [skill(10, 'Alpha Search'), skill(11, 'Beta Search')], + total: 2, + page, + size, + } +} + +test.describe('Search Page Flows', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + await mockCommonApis(page, { + searchHandler: buildSearchResponse, + }) + }) + + test('updates URL state and results when searching, sorting, and filtering', async ({ page }) => { + await page.goto('/search?q=&sort=relevance&page=0&starredOnly=false') + + await expect(page.getByRole('heading', { name: /^Alpha Search$/ })).toBeVisible() + await expect(page.getByRole('heading', { name: /^Beta Search$/ })).toBeVisible() + + const searchInput = page.getByRole('textbox') + await searchInput.fill('agent') + await searchInput.press('Enter') + + await expect(page).toHaveURL(/\/search\?q=agent&sort=relevance&page=0&starredOnly=false$/) + await expect(page.getByRole('heading', { name: /^Agent Builder$/ })).toBeVisible() + await expect(page.getByRole('heading', { name: /^Alpha Search$/ })).not.toBeVisible() + + await page.getByRole('button', { name: 'Downloads' }).click() + + await expect(page).toHaveURL(/\/search\?q=agent&sort=downloads&page=0&starredOnly=false$/) + await expect(page.getByRole('heading', { name: /^Download Leader Agent$/ })).toBeVisible() + await expect(page.getByRole('heading', { name: /^Agent Builder$/ })).not.toBeVisible() + + await page.getByRole('button', { name: 'Official' }).click() + + await expect(page).toHaveURL(/\/search\?q=agent&label=official&sort=downloads&page=0&starredOnly=false$/) + await expect(page.getByRole('heading', { name: /^Official Agent$/ })).toBeVisible() + await expect(page.getByRole('heading', { name: /^Download Leader Agent$/ })).not.toBeVisible() + }) + + test('keeps the active label when paginating', async ({ page }) => { + await page.goto('/search?q=agent&label=official&sort=downloads&page=0&starredOnly=false') + + await expect(page.getByRole('heading', { name: /^Official Agent$/ })).toBeVisible() + + await page.getByRole('button', { name: 'Next' }).click() + + await expect(page).toHaveURL(/\/search\?q=agent&label=official&sort=downloads&page=1&starredOnly=false$/) + await expect(page.getByRole('heading', { name: /^Official Agent Page Two$/ })).toBeVisible() + await expect(page.getByRole('heading', { name: /^Official Agent$/ })).not.toBeVisible() + }) + + test('redirects unauthenticated users to login when enabling starred-only', async ({ page }) => { + await page.goto('/search?q=agent&sort=downloads&page=0&starredOnly=false') + + await page.getByRole('button', { name: 'Starred only' }).click() + + await expect(page).toHaveURL(/\/login\?returnTo=/) + + const currentUrl = new URL(page.url()) + expect(currentUrl.pathname).toBe('/login') + expect(currentUrl.searchParams.get('returnTo')).toBe('/search?q=agent&sort=downloads&page=0&starredOnly=false') + await expect(page.getByRole('heading', { name: 'Login to SkillHub' })).toBeVisible() + }) +}) diff --git a/web/e2e/share-button.spec.ts b/web/e2e/share-button.spec.ts new file mode 100644 index 00000000..5cbf364d --- /dev/null +++ b/web/e2e/share-button.spec.ts @@ -0,0 +1,187 @@ +import { expect, test } from '@playwright/test' +import { mockStaticApis, setEnglishLocale, skill } from './helpers/api-mocks' + +test.describe('Skill Share Button', () => { + test.beforeEach(async ({ page, context }) => { + await setEnglishLocale(page) + + // Grant clipboard permissions + await context.grantPermissions(['clipboard-read', 'clipboard-write']) + + await mockStaticApis(page, { authenticated: true }) + + // Mock skill sub-resource APIs (versions, files, etc.) to prevent server errors + await page.route('**/api/web/skills/*/versions', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, msg: 'ok', + data: { items: [{ id: 10, version: '1.0.0', status: 'PUBLISHED', createdAt: '2026-03-20T00:00:00Z' }], total: 1, page: 0, size: 20 }, + timestamp: '2026-03-28T00:00:00Z', requestId: 'playwright-e2e', + }), + }) + }) + + await page.route('**/api/web/skills/*/versions/*/files', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ code: 0, msg: 'ok', data: [], timestamp: '2026-03-28T00:00:00Z', requestId: 'playwright-e2e' }), + }) + }) + + await page.route('**/api/web/skills/*/versions/*', async (route) => { + // Let the versions list route handle its own path + if (route.request().url().endsWith('/versions')) return route.continue() + if (route.request().url().includes('/files')) return route.continue() + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, msg: 'ok', + data: { id: 10, version: '1.0.0', status: 'PUBLISHED', createdAt: '2026-03-20T00:00:00Z' }, + timestamp: '2026-03-28T00:00:00Z', requestId: 'playwright-e2e', + }), + }) + }) + }) + + test('copies share text to clipboard when share button is clicked', async ({ page }) => { + // Mock skill detail API + await page.route('**/api/web/skills/global/test-skill', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + msg: 'ok', + data: skill(1, 'Test Skill', { + summary: 'A useful test skill for sharing', + namespace: 'global', + slug: 'test-skill', + }), + timestamp: '2026-03-28T00:00:00Z', + requestId: 'playwright-e2e', + }), + }) + }) + + await page.goto('/space/global/test-skill') + + // Wait for skill detail page to load + await expect(page.getByRole('heading', { name: /^Test Skill$/ })).toBeVisible() + + // Find and click the share button + const shareButton = page.getByRole('button', { name: /Share/i }) + await expect(shareButton).toBeVisible() + await shareButton.click() + + // Verify button shows "Copied" state + await expect(page.getByRole('button', { name: /Copied/i })).toBeVisible() + + // Verify clipboard content + const clipboardText = await page.evaluate(() => navigator.clipboard.readText()) + expect(clipboardText).toContain('test-skill') + expect(clipboardText).toContain('http://localhost:3000/space/global/test-skill') + expect(clipboardText.split('\n')).toHaveLength(2) + }) + + test('share text includes skill description when available', async ({ page }) => { + await page.route('**/api/web/skills/global/test-skill', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + msg: 'ok', + data: skill(1, 'Test Skill', { + summary: 'A useful test skill for sharing', + namespace: 'global', + slug: 'test-skill', + }), + timestamp: '2026-03-28T00:00:00Z', + requestId: 'playwright-e2e', + }), + }) + }) + + await page.goto('/space/global/test-skill') + + await expect(page.getByRole('heading', { name: /^Test Skill$/ })).toBeVisible() + + const shareButton = page.getByRole('button', { name: /Share/i }) + await shareButton.click() + + const clipboardText = await page.evaluate(() => navigator.clipboard.readText()) + // Description is truncated to fit within 30 char limit (displayName + " - " + desc) + expect(clipboardText).toContain('A useful test sk') + }) + + test('share button resets to normal state after 2 seconds', async ({ page }) => { + await page.route('**/api/web/skills/global/test-skill', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + msg: 'ok', + data: skill(1, 'Test Skill', { + summary: 'A useful test skill', + namespace: 'global', + slug: 'test-skill', + }), + timestamp: '2026-03-28T00:00:00Z', + requestId: 'playwright-e2e', + }), + }) + }) + + await page.goto('/space/global/test-skill') + + await expect(page.getByRole('heading', { name: /^Test Skill$/ })).toBeVisible() + + const shareButton = page.getByRole('button', { name: /Share/i }) + await shareButton.click() + + // Should show "Copied" immediately + await expect(page.getByRole('button', { name: /Copied/i })).toBeVisible() + + // Should reset to "Share" after 2 seconds + await page.waitForTimeout(2100) + await expect(page.getByRole('button', { name: /^Share$/i })).toBeVisible() + }) + + test('formats namespaced skill correctly in share text', async ({ page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']) + + await page.route('**/api/web/skills/team-alpha/namespaced-skill', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + msg: 'ok', + data: skill(2, 'Namespaced Skill', { + summary: 'Team skill', + namespace: 'team-alpha', + slug: 'namespaced-skill', + }), + timestamp: '2026-03-28T00:00:00Z', + requestId: 'playwright-e2e', + }), + }) + }) + + await page.goto('/space/team-alpha/namespaced-skill') + + await expect(page.getByRole('heading', { name: /^Namespaced Skill$/ })).toBeVisible() + + const shareButton = page.getByRole('button', { name: /Share/i }) + await shareButton.click() + + const clipboardText = await page.evaluate(() => navigator.clipboard.readText()) + expect(clipboardText).toContain('team-alpha/namespaced-skill') + expect(clipboardText).toContain('http://localhost:3000/space/team-alpha/namespaced-skill') + }) +}) diff --git a/web/package.json b/web/package.json index 7e521efd..da847426 100644 --- a/web/package.json +++ b/web/package.json @@ -9,6 +9,8 @@ "build": "tsc -b && vite build", "preview": "vite preview", "test": "vitest run", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", "typecheck": "tsc --noEmit", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "generate-api": "openapi-typescript http://localhost:8080/v3/api-docs -o src/api/generated/schema.d.ts" @@ -40,6 +42,7 @@ "zustand": "^5.0.11" }, "devDependencies": { + "@playwright/test": "^1.58.2", "@types/mdast": "^4.0.4", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 00000000..f5203983 --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,27 @@ +import { defineConfig, devices } from '@playwright/test' + +export default defineConfig({ + testDir: './e2e', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: 'html', + use: { + baseURL: 'http://localhost:3000', + trace: 'on-first-retry', + screenshot: 'on', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'pnpm preview --port 3000', + url: 'http://localhost:3000', + reuseExistingServer: true, + timeout: 120000, + }, +}) diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index d82ceef9..a2c150da 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -81,6 +81,9 @@ importers: specifier: ^5.0.11 version: 5.0.11(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) devDependencies: + '@playwright/test': + specifier: ^1.58.2 + version: 1.58.2 '@types/mdast': specifier: ^4.0.4 version: 4.0.4 @@ -453,6 +456,11 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -1460,6 +1468,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1974,6 +1987,16 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -2827,6 +2850,10 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -3828,6 +3855,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -4549,6 +4579,14 @@ snapshots: pirates@4.0.7: {} + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + pluralize@8.0.0: {} postcss-import@15.1.0(postcss@8.5.8): diff --git a/web/src/features/skill/install-command.tsx b/web/src/features/skill/install-command.tsx index 47a09373..63aed99c 100644 --- a/web/src/features/skill/install-command.tsx +++ b/web/src/features/skill/install-command.tsx @@ -1,8 +1,8 @@ -import { useState, useMemo } from 'react' +import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { Check, Copy } from 'lucide-react' import { Button } from '@/shared/ui/button' -import { copyToClipboard } from '@/shared/lib/clipboard' +import { useCopyToClipboard } from '@/shared/lib/clipboard' interface InstallCommandProps { namespace: string @@ -32,7 +32,7 @@ export function buildInstallCommand(namespace: string, slug: string, baseUrl: st export function InstallCommand({ namespace, slug }: InstallCommandProps) { const { t } = useTranslation() - const [copied, setCopied] = useState(false) + const [copied, copy] = useCopyToClipboard() const baseUrl = useMemo(() => getBaseUrl(), []) @@ -40,9 +40,7 @@ export function InstallCommand({ namespace, slug }: InstallCommandProps) { const handleCopy = async () => { try { - await copyToClipboard(command) - setCopied(true) - window.setTimeout(() => setCopied(false), 2000) + await copy(command) } catch (err) { console.error('Failed to copy:', err) } diff --git a/web/src/features/skill/share-button.test.ts b/web/src/features/skill/share-button.test.ts new file mode 100644 index 00000000..dd87d1c2 --- /dev/null +++ b/web/src/features/skill/share-button.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { buildShareText } from './share-button' + +describe('buildShareText', () => { + const mockT = (key: string) => { + if (key === 'skillDetail.share.defaultDescription') { + return 'A useful skill' + } + return key + } + + it('builds share text for global namespace skill', () => { + const result = buildShareText('global', 'my-skill', 'Test description', 'https://skill.example.com', mockT) + const lines = result.split('\n') + + expect(lines).toHaveLength(3) + expect(lines[0]).toBe('my-skill') + expect(lines[1]).toBe('Test description') + expect(lines[2]).toBe('https://skill.example.com/space/global/my-skill') + }) + + it('builds share text for namespaced skill', () => { + const result = buildShareText('team-alpha', 'my-skill', 'Test description', 'https://skill.example.com', mockT) + const lines = result.split('\n') + + expect(lines).toHaveLength(3) + expect(lines[0]).toBe('team-alpha/my-skill') + expect(lines[1]).toBe('Test description') + expect(lines[2]).toBe('https://skill.example.com/space/team-alpha/my-skill') + }) + + it('includes full description without truncation', () => { + const longDesc = 'This is a very long description that exceeds the character limit' + const result = buildShareText('global', 'skill', longDesc, 'https://skill.example.com', mockT) + const lines = result.split('\n') + + expect(lines[1]).toBe(longDesc) + expect(lines[1]).not.toContain('…') + }) + + it('uses default description when description is undefined', () => { + const result = buildShareText('global', 'my-skill', undefined, 'https://skill.example.com', mockT) + const lines = result.split('\n') + + expect(lines[1]).toBe('A useful skill') + }) + + it('includes skill URL on third line', () => { + const result = buildShareText('global', 'my-skill', 'Test', 'https://skill.example.com', mockT) + const lines = result.split('\n') + + expect(lines).toHaveLength(3) + expect(lines[2]).toBe('https://skill.example.com/space/global/my-skill') + }) +}) diff --git a/web/src/features/skill/share-button.tsx b/web/src/features/skill/share-button.tsx new file mode 100644 index 00000000..89244551 --- /dev/null +++ b/web/src/features/skill/share-button.tsx @@ -0,0 +1,57 @@ +import { useTranslation } from 'react-i18next' +import { Share2, Check } from 'lucide-react' +import { useCopyToClipboard } from '@/shared/lib/clipboard' +import { getBaseUrl } from './install-command' + +interface ShareButtonProps { + namespace: string + slug: string + description?: string +} + +/** + * Build share text for a skill with full description + */ +export function buildShareText( + namespace: string, + slug: string, + description: string | undefined, + baseUrl: string, + t: (key: string) => string, +): string { + const skillUrl = `${baseUrl}/space/${namespace}/${slug}` + const displayName = namespace === 'global' ? slug : `${namespace}/${slug}` + const fullDesc = description || t('skillDetail.share.defaultDescription') + + return `${displayName}\n${fullDesc}\n${skillUrl}` +} + +export function ShareButton({ namespace, slug, description }: ShareButtonProps) { + const { t } = useTranslation() + const [copied, copy] = useCopyToClipboard() + + const handleShare = async () => { + try { + const baseUrl = getBaseUrl() + const shareText = buildShareText(namespace, slug, description, baseUrl, t) + await copy(shareText) + } catch (err) { + console.error('Failed to copy share text:', err) + } + } + + return ( + + ) +} diff --git a/web/src/features/token/create-token-dialog.tsx b/web/src/features/token/create-token-dialog.tsx index 6f0eb7cc..22496699 100644 --- a/web/src/features/token/create-token-dialog.tsx +++ b/web/src/features/token/create-token-dialog.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useMutation, useQueryClient } from '@tanstack/react-query' import { tokenApi } from '@/api/client' -import { copyToClipboard } from '@/shared/lib/clipboard' +import { useCopyToClipboard } from '@/shared/lib/clipboard' import { Dialog, DialogContent, @@ -47,6 +47,7 @@ export function CreateTokenDialog({ children, existingNames = [] }: CreateTokenD const [expirationMode, setExpirationMode] = useState('never') const [customExpiresAt, setCustomExpiresAt] = useState('') const [expiresAtError, setExpiresAtError] = useState(null) + const [, copy] = useCopyToClipboard() const queryClient = useQueryClient() const normalizedName = name.trim() @@ -107,7 +108,7 @@ export function CreateTokenDialog({ children, existingNames = [] }: CreateTokenD if (!createdToken) return try { - await copyToClipboard(createdToken.token) + await copy(createdToken.token) toast.success(t('createToken.copySuccess'), undefined, centeredToastOptions()) } catch (error) { console.error('Failed to copy token:', error) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index e0fc235b..d110b0d3 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -883,7 +883,12 @@ "reportSuccessTitle": "Report submitted", "reportSuccessDescription": "Administrators will review this report soon.", "downloadErrorTitle": "Download failed", - "reportErrorTitle": "Report failed" + "reportErrorTitle": "Report failed", + "share": { + "button": "Share", + "copied": "Copied", + "defaultDescription": "A useful skill" + } }, "reports": { "title": "Skill Reports", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index d0264f31..dbfbabad 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -883,7 +883,12 @@ "reportSuccessTitle": "举报已提交", "reportSuccessDescription": "管理员将尽快处理这条举报。", "downloadErrorTitle": "下载失败", - "reportErrorTitle": "举报失败" + "reportErrorTitle": "举报失败", + "share": { + "button": "分享", + "copied": "已复制", + "defaultDescription": "实用技能" + } }, "reports": { "title": "技能举报", diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index b37e7ed7..ed4f8252 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -8,6 +8,7 @@ import { FileTree } from '@/features/skill/file-tree' import { FilePreviewDialog } from '@/features/skill/file-preview-dialog' import type { FileTreeNode } from '@/features/skill/file-tree-builder' import { InstallCommand } from '@/features/skill/install-command' +import { ShareButton } from '@/features/skill/share-button' import { SkillLabelPanel } from '@/features/skill/skill-label-panel' import { getOverviewCollapseMaxHeight, @@ -1070,6 +1071,12 @@ export function SkillDetailPage() { {t('skillDetail.download')} + + {skill.canManageLifecycle && selectedVersionEntry && ( )} diff --git a/web/src/shared/components/copy-button.tsx b/web/src/shared/components/copy-button.tsx index 36674838..b27b57d1 100644 --- a/web/src/shared/components/copy-button.tsx +++ b/web/src/shared/components/copy-button.tsx @@ -1,7 +1,6 @@ -import { useState } from 'react' import { useTranslation } from 'react-i18next' import { Button } from '@/shared/ui/button' -import { copyToClipboard } from '@/shared/lib/clipboard' +import { useCopyToClipboard } from '@/shared/lib/clipboard' interface CopyButtonProps { text: string @@ -10,13 +9,11 @@ interface CopyButtonProps { export function CopyButton({ text, className }: CopyButtonProps) { const { t } = useTranslation() - const [copied, setCopied] = useState(false) + const [copied, copy] = useCopyToClipboard() const handleCopy = async () => { try { - await copyToClipboard(text) - setCopied(true) - setTimeout(() => setCopied(false), 2000) + await copy(text) } catch (err) { console.error('Failed to copy:', err) } diff --git a/web/src/shared/components/landing-quick-start.tsx b/web/src/shared/components/landing-quick-start.tsx index 0040b66c..f838d217 100644 --- a/web/src/shared/components/landing-quick-start.tsx +++ b/web/src/shared/components/landing-quick-start.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { Bot, Check, Copy, UserRound } from 'lucide-react' -import { copyToClipboard } from '@/shared/lib/clipboard' +import { useCopyToClipboard } from '@/shared/lib/clipboard' type LandingQuickStartTabId = 'agent' | 'human' @@ -14,13 +14,11 @@ interface LandingQuickStartTab { function CompactCopyButton({ text }: { text: string }) { const { t } = useTranslation() - const [copied, setCopied] = useState(false) + const [copied, copy] = useCopyToClipboard() const handleCopy = async () => { try { - await copyToClipboard(text) - setCopied(true) - window.setTimeout(() => setCopied(false), 2000) + await copy(text) } catch (err) { console.error('Failed to copy:', err) } diff --git a/web/src/shared/components/quick-start.tsx b/web/src/shared/components/quick-start.tsx index f6a85564..bc156c75 100644 --- a/web/src/shared/components/quick-start.tsx +++ b/web/src/shared/components/quick-start.tsx @@ -1,7 +1,7 @@ import { useTranslation } from 'react-i18next' import { Check, Copy, Settings, Download, Upload } from 'lucide-react' -import { useMemo, useState } from 'react' -import { copyToClipboard } from '@/shared/lib/clipboard' +import { useMemo } from 'react' +import { useCopyToClipboard } from '@/shared/lib/clipboard' function getAppBaseUrl(): string { if (typeof window === 'undefined') { @@ -16,13 +16,11 @@ function getAppBaseUrl(): string { function CopyButton({ text }: { text: string }) { const { t } = useTranslation() - const [copied, setCopied] = useState(false) + const [copied, copy] = useCopyToClipboard() const handleCopy = async () => { try { - await copyToClipboard(text) - setCopied(true) - window.setTimeout(() => setCopied(false), 2000) + await copy(text) } catch (err) { console.error('Failed to copy:', err) } diff --git a/web/src/shared/lib/api-error.test.ts b/web/src/shared/lib/api-error.test.ts index 51ad23a8..5144fdf6 100644 --- a/web/src/shared/lib/api-error.test.ts +++ b/web/src/shared/lib/api-error.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import i18n from '@/i18n/config' const errorSpy = vi.fn() @@ -9,8 +10,9 @@ vi.mock('./toast', () => ({ })) describe('ApiError', () => { - beforeEach(() => { + beforeEach(async () => { errorSpy.mockReset() + await i18n.changeLanguage('zh') }) it('keeps the provided server message key', async () => { @@ -23,8 +25,9 @@ describe('ApiError', () => { }) describe('handleApiError', () => { - beforeEach(() => { + beforeEach(async () => { errorSpy.mockReset() + await i18n.changeLanguage('zh') vi.stubGlobal('window', { location: { href: '' } }) }) @@ -54,13 +57,19 @@ describe('handleApiError', () => { expect(errorSpy).toHaveBeenLastCalledWith('Server said no') }) - it('shows network error message for status 0', async () => { + it('shows network error message when status is 0 (network disconnected)', async () => { const { ApiError, handleApiError } = await import('./api-error') - handleApiError(new ApiError('apiError.networkError', 0)) + handleApiError(new ApiError('Network error', 0)) - expect(errorSpy).toHaveBeenCalled() - const lastCall = errorSpy.mock.calls[errorSpy.mock.calls.length - 1][0] - expect(lastCall).toMatch(/network|网络/) + expect(errorSpy).toHaveBeenLastCalledWith('网络连接失败,请检查网络') + }) + + it('shows network error message when status is 0 with timeout', async () => { + const { ApiError, handleApiError } = await import('./api-error') + + handleApiError(new ApiError('error.request.timeout', 0)) + + expect(errorSpy).toHaveBeenLastCalledWith('网络连接失败,请检查网络') }) }) diff --git a/web/src/shared/lib/clipboard.ts b/web/src/shared/lib/clipboard.ts index f7ab6a05..8d6b4181 100644 --- a/web/src/shared/lib/clipboard.ts +++ b/web/src/shared/lib/clipboard.ts @@ -1,3 +1,5 @@ +import { useCallback, useRef, useState } from 'react' + /** * Copy text to clipboard with fallback for insecure contexts (HTTP, iframes). */ @@ -6,13 +8,45 @@ export async function copyToClipboard(text: string): Promise { await navigator.clipboard.writeText(text) return } - // Fallback for insecure contexts + // Fallback for insecure contexts (HTTP, iframes) const textarea = document.createElement('textarea') textarea.value = text textarea.style.position = 'fixed' textarea.style.opacity = '0' document.body.appendChild(textarea) textarea.select() - document.execCommand('copy') + const success = document.execCommand('copy') document.body.removeChild(textarea) + + if (!success) { + throw new Error('Failed to copy text to clipboard') + } +} + +/** + * React hook for clipboard copy with auto-reset "copied" state. + * + * @param timeout - ms before `copied` resets to false (default 2000) + * @returns `[copied, copy]` — boolean status and an async copy function + * + * @example + * const [copied, copy] = useCopyToClipboard() + * + */ +export function useCopyToClipboard(timeout = 2000) { + const [copied, setCopied] = useState(false) + const timerRef = useRef | undefined>(undefined) + + const copy = useCallback(async (text: string) => { + await copyToClipboard(text) + setCopied(true) + if (timerRef.current) { + clearTimeout(timerRef.current) + } + timerRef.current = setTimeout(() => setCopied(false), timeout) + }, [timeout]) + + return [copied, copy] as const } diff --git a/web/vite.config.ts b/web/vite.config.ts index 44db2323..aff7ad67 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -9,6 +9,9 @@ export default defineConfig({ '@': path.resolve(__dirname, './src'), }, }, + test: { + exclude: ['**/node_modules/**', '**/e2e/**'], + }, server: { port: 3000, watch: {