feat(web): add skill share button with e2e tests (#181)

* test(web): add Playwright e2e testing framework

Add Playwright for end-to-end testing with initial test suites for search flow and network error handling. Also enhance unit tests for network error scenarios.

- Add @playwright/test dependency and npm scripts
- Configure Playwright with chromium browser and HTML reporter
- Add e2e tests for search flow and network error handling
- Update .gitignore to exclude Playwright generated files
- Add network error test cases to api-error unit tests

* feat(web): add skill share button to detail page

Add share button to skill detail page that copies skill info to clipboard. Share text includes skill name, short description (max 30 chars), and detail page URL.

Closes #168

* test(web): add e2e tests for skill share button

Add Playwright e2e tests to verify share button functionality including clipboard copy, text formatting, and state transitions.

* fix(web): fix share button e2e tests and document Playwright workflow

Fix 3 issues in share-button e2e tests:
- Use authenticated mock (skill detail page requires login)
- Add publishedVersion to skill factory (ShareButton render condition)
- Mock versions/files sub-resource APIs to prevent server errors
- Adjust description assertion for 30-char truncation logic

Add Playwright E2E section to CLAUDE.md documenting commands,
screenshot behavior, and test-results directory conventions.

E2E test results: 10/10 passed (Chromium, Playwright 1.58.2)
- network-error.spec.ts:  3/3 passed
- search-flow.spec.ts:    3/3 passed
- share-button.spec.ts:   4/4 passed

* refactor(web): improve share button layout and text format

- Remove description truncation, display full text
- Change share text format from 2 lines to 3 lines (name, description, URL)
- Replace Button component with custom styled native button
- Move ShareButton from card to below download button
- Update tests to match new 3-line format

* fix(web): handle clipboard copy failure in fallback path

- Check document.execCommand('copy') return value
- Throw error when copy fails in fallback path
- Ensure error is properly caught and displayed to user

Fixes issue where "复制 Token 失败,请重试" was shown but the
underlying failure was not properly detected in the fallback code path.

* refactor(web): modernize clipboard with useCopyToClipboard hook

- Add useCopyToClipboard React hook for cleaner state management
- Migrate all copy buttons to use the new hook
- Remove repetitive useState + setTimeout patterns across 7 files
- Simplify clipboard.ts by removing excessive diagnostic logging
- Keep copyToClipboard utility for special cases (file-preview-dialog)

Benefits:
- More idiomatic React code with custom hook
- Consistent 2-second auto-reset behavior
- Reduced code duplication
- Better separation of concerns

* test(web): fix api-error tests and exclude e2e from vitest

- Set i18n language to 'zh' in api-error.test.ts beforeEach
- Add vitest config to exclude e2e directory from unit tests
- All 506 tests now pass

---------

Co-authored-by: xiose <huyanlin@nuaa.edu.cn>
This commit is contained in:
wowo 2026-03-30 14:04:52 +08:00 committed by GitHub
parent 0a8c02c647
commit 1b7bd06685
21 changed files with 859 additions and 36 deletions

5
.gitignore vendored
View file

@ -53,6 +53,11 @@ coverage/
*.tsbuildinfo
package-lock.json
# Playwright
**/playwright-report/
**/test-results/
**/.playwright/
# Temporary files
.tmp/
tmp/

View file

@ -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<T>(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<T>(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> = {},
): 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!())
})
}
}

View file

@ -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()
})
})

114
web/e2e/search-flow.spec.ts Normal file
View file

@ -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()
})
})

View file

@ -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')
})
})

View file

@ -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",

27
web/playwright.config.ts Normal file
View file

@ -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,
},
})

38
web/pnpm-lock.yaml generated
View file

@ -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):

View file

@ -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)
}

View file

@ -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')
})
})

View file

@ -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 (
<button
type="button"
onClick={handleShare}
className="relative w-full overflow-hidden rounded-xl border border-border/60 bg-muted/50 px-4 py-3 transition-colors hover:bg-muted/70 active:bg-muted/80"
>
<div className="flex items-center justify-center gap-2">
{copied ? <Check className="h-4 w-4" /> : <Share2 className="h-4 w-4" />}
<span className="text-[13px] leading-relaxed text-foreground sm:text-sm">
{copied ? t('skillDetail.share.copied') : t('skillDetail.share.button')}
</span>
</div>
</button>
)
}

View file

@ -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<TokenExpirationMode>('never')
const [customExpiresAt, setCustomExpiresAt] = useState('')
const [expiresAtError, setExpiresAtError] = useState<string | null>(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)

View file

@ -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",

View file

@ -883,7 +883,12 @@
"reportSuccessTitle": "举报已提交",
"reportSuccessDescription": "管理员将尽快处理这条举报。",
"downloadErrorTitle": "下载失败",
"reportErrorTitle": "举报失败"
"reportErrorTitle": "举报失败",
"share": {
"button": "分享",
"copied": "已复制",
"defaultDescription": "实用技能"
}
},
"reports": {
"title": "技能举报",

View file

@ -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')}
</Button>
<ShareButton
namespace={namespace}
slug={slug}
description={skill.summary}
/>
{skill.canManageLifecycle && selectedVersionEntry && (
<SecurityAuditSummary skillId={skill.id} versionId={selectedVersionEntry.id} versionStatus={selectedVersionEntry.status} />
)}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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('网络连接失败,请检查网络')
})
})

View file

@ -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<void> {
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()
* <button onClick={() => copy('hello')}>
* {copied ? 'Copied!' : 'Copy'}
* </button>
*/
export function useCopyToClipboard(timeout = 2000) {
const [copied, setCopied] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | 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
}

View file

@ -9,6 +9,9 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'),
},
},
test: {
exclude: ['**/node_modules/**', '**/e2e/**'],
},
server: {
port: 3000,
watch: {