mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
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:
parent
0a8c02c647
commit
1b7bd06685
21 changed files with 859 additions and 36 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -53,6 +53,11 @@ coverage/
|
|||
*.tsbuildinfo
|
||||
package-lock.json
|
||||
|
||||
# Playwright
|
||||
**/playwright-report/
|
||||
**/test-results/
|
||||
**/.playwright/
|
||||
|
||||
# Temporary files
|
||||
.tmp/
|
||||
tmp/
|
||||
|
|
|
|||
163
web/e2e/helpers/api-mocks.ts
Normal file
163
web/e2e/helpers/api-mocks.ts
Normal 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!())
|
||||
})
|
||||
}
|
||||
}
|
||||
119
web/e2e/network-error.spec.ts
Normal file
119
web/e2e/network-error.spec.ts
Normal 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
114
web/e2e/search-flow.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
187
web/e2e/share-button.spec.ts
Normal file
187
web/e2e/share-button.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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
27
web/playwright.config.ts
Normal 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
38
web/pnpm-lock.yaml
generated
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
55
web/src/features/skill/share-button.test.ts
Normal file
55
web/src/features/skill/share-button.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
57
web/src/features/skill/share-button.tsx
Normal file
57
web/src/features/skill/share-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -883,7 +883,12 @@
|
|||
"reportSuccessTitle": "举报已提交",
|
||||
"reportSuccessDescription": "管理员将尽快处理这条举报。",
|
||||
"downloadErrorTitle": "下载失败",
|
||||
"reportErrorTitle": "举报失败"
|
||||
"reportErrorTitle": "举报失败",
|
||||
"share": {
|
||||
"button": "分享",
|
||||
"copied": "已复制",
|
||||
"defaultDescription": "实用技能"
|
||||
}
|
||||
},
|
||||
"reports": {
|
||||
"title": "技能举报",
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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('网络连接失败,请检查网络')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ export default defineConfig({
|
|||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
exclude: ['**/node_modules/**', '**/e2e/**'],
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
watch: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue