mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-28 11:25:00 +00:00
461 lines
19 KiB
TypeScript
461 lines
19 KiB
TypeScript
import { expect, test, type Page } from '@playwright/test'
|
||
import { setEnglishLocale } from './helpers/auth-fixtures'
|
||
import {
|
||
getSearchCard,
|
||
getSearchCards,
|
||
prepareSearchSeed,
|
||
type PreparedSearchSeed,
|
||
} from './helpers/search-seed'
|
||
import { registerSession } from './helpers/session'
|
||
|
||
const SEARCH_URL = (q: string, sort = 'relevance', page = 0) =>
|
||
`/search?q=${encodeURIComponent(q)}&sort=${sort}&page=${page}&starredOnly=false`
|
||
|
||
function latestSeed(seed: PreparedSearchSeed) {
|
||
return {
|
||
skill: seed.skills[seed.skills.length - 1],
|
||
skillName: seed.skillNames[seed.skillNames.length - 1],
|
||
}
|
||
}
|
||
|
||
async function waitForCards(page: Page) {
|
||
const cards = getSearchCards(page)
|
||
|
||
if (basicSeed) {
|
||
await basicSeed.builder.waitForSearchResults(
|
||
basicSeed.keyword,
|
||
basicSeed.skills.map((skill) => skill.slug),
|
||
)
|
||
}
|
||
|
||
const keyword = basicSeed?.keyword
|
||
const encodedKeyword = keyword ? encodeURIComponent(keyword) : null
|
||
let reloaded = false
|
||
|
||
const waitForMatchingResponse = async () => {
|
||
if (!encodedKeyword) {
|
||
return
|
||
}
|
||
|
||
await page.waitForResponse(async (response) => {
|
||
if (!response.url().includes('/api/web/skills?') || !response.url().includes(`q=${encodedKeyword}`)) {
|
||
return false
|
||
}
|
||
if (response.status() !== 200) {
|
||
return false
|
||
}
|
||
|
||
try {
|
||
const payload = await response.json() as { data?: { items?: Array<unknown> } }
|
||
return Array.isArray(payload.data?.items) && payload.data.items.length > 0
|
||
} catch {
|
||
return false
|
||
}
|
||
}, { timeout: 15_000 }).catch(() => null)
|
||
}
|
||
|
||
const waitForCardCount = async () => {
|
||
await expect.poll(
|
||
async () => cards.count(),
|
||
{
|
||
timeout: 20_000,
|
||
intervals: [250, 500, 1_000, 2_000],
|
||
},
|
||
).toBeGreaterThan(0)
|
||
}
|
||
|
||
await page.waitForLoadState('networkidle')
|
||
await expect(page.getByRole('textbox', { name: 'Search skills...' })).toBeVisible({ timeout: 8_000 })
|
||
|
||
if (await cards.count() > 0) {
|
||
return cards
|
||
}
|
||
|
||
await waitForMatchingResponse()
|
||
|
||
try {
|
||
await waitForCardCount()
|
||
} catch {
|
||
if (reloaded) {
|
||
throw new Error('Timed out waiting for search cards after one reload fallback')
|
||
}
|
||
|
||
reloaded = true
|
||
await page.reload({ waitUntil: 'networkidle' })
|
||
await expect(page.getByRole('textbox', { name: 'Search skills...' })).toBeVisible({ timeout: 8_000 })
|
||
await waitForMatchingResponse()
|
||
await waitForCardCount()
|
||
}
|
||
|
||
return cards
|
||
}
|
||
|
||
let basicSeed: PreparedSearchSeed | undefined
|
||
|
||
test.setTimeout(300_000)
|
||
|
||
test.beforeAll(async ({ browser }, testInfo) => {
|
||
test.setTimeout(300_000)
|
||
basicSeed = await prepareSearchSeed(browser, testInfo, { count: 13 })
|
||
})
|
||
|
||
test.afterAll(async () => {
|
||
await basicSeed?.dispose()
|
||
basicSeed = undefined
|
||
})
|
||
|
||
// ─── Card Display After Search ────────────────────────────────────────────────
|
||
|
||
test.describe('Search Card Display (Real API)', () => {
|
||
test.beforeEach(async ({ page }) => {
|
||
await setEnglishLocale(page)
|
||
})
|
||
|
||
// TC_SEARCH_INTERACT_001 P0
|
||
test('TC_SEARCH_INTERACT_001: cards appear immediately after search', async ({ page }) => {
|
||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||
const cards = await waitForCards(page)
|
||
await expect(cards.first()).toBeVisible({ timeout: 8_000 })
|
||
})
|
||
|
||
// TC_SEARCH_INTERACT_005 P0 - cards show complete info
|
||
test('TC_SEARCH_INTERACT_005: each card shows name, description, and version', async ({ page }) => {
|
||
const current = latestSeed(basicSeed!)
|
||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||
const firstCard = getSearchCard(page, current.skillName)
|
||
await expect(firstCard).toBeVisible({ timeout: 8_000 })
|
||
await expect(firstCard.getByRole('heading', { name: current.skillName, exact: true })).toBeVisible()
|
||
await expect(firstCard.getByText(`v${current.skill.version}`)).toBeVisible()
|
||
})
|
||
|
||
// TC_SEARCH_INTERACT_039 P0 - version number format
|
||
test('TC_SEARCH_INTERACT_039: version number is displayed in v1.2.3 format', async ({ page }) => {
|
||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||
await expect(page.getByText(/v\d+\.\d+\.\d+/).first()).toBeVisible({ timeout: 8_000 })
|
||
})
|
||
|
||
// TC_SEARCH_INTERACT_038 P0 - long descriptions truncated
|
||
test('TC_SEARCH_INTERACT_038: long descriptions are truncated with ellipsis', async ({ page }) => {
|
||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||
const cards = await waitForCards(page)
|
||
expect(await cards.count()).toBeGreaterThan(0)
|
||
})
|
||
|
||
// TC_SEARCH_INTERACT_031 P0 - no results shows empty state
|
||
test('TC_SEARCH_INTERACT_031: no results shows empty state instead of cards', async ({ page }) => {
|
||
await page.goto(SEARCH_URL('xyznonexistentkeyword99999abc'))
|
||
await page.waitForLoadState('networkidle')
|
||
await expect(getSearchCards(page)).toHaveCount(0)
|
||
await expect(page.getByRole('heading', { name: 'No results found' })).toBeVisible({ timeout: 8_000 })
|
||
})
|
||
|
||
// TC_SEARCH_INTERACT_035 P0 - large results show pagination
|
||
test('TC_SEARCH_INTERACT_035: large result sets show pagination controls', async ({ page }) => {
|
||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||
await page.waitForLoadState('networkidle')
|
||
await expect(page.getByRole('button', { name: /next|›/i })).toBeVisible({ timeout: 10_000 })
|
||
})
|
||
})
|
||
|
||
// ─── Card Content & Search Relevance ─────────────────────────────────────────
|
||
|
||
test.describe('Search Card Content (Real API)', () => {
|
||
test.beforeEach(async ({ page }) => {
|
||
await setEnglishLocale(page)
|
||
})
|
||
|
||
// TC_SEARCH_INTERACT_003 P0 - card count matches count indicator
|
||
test('TC_SEARCH_INTERACT_003: displayed card count is consistent with skill count indicator', async ({ page }) => {
|
||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||
const cards = getSearchCards(page)
|
||
const cardCount = await cards.count()
|
||
const countText = await page.getByText(/\d+\s+skills found/i).first().textContent()
|
||
const totalMatch = countText?.match(/\d+/)
|
||
if (totalMatch) {
|
||
const total = parseInt(totalMatch[0], 10)
|
||
expect(total).toBeGreaterThanOrEqual(cardCount)
|
||
}
|
||
})
|
||
|
||
// TC_SEARCH_INTERACT_040 P0 - download count formatted
|
||
test('TC_SEARCH_INTERACT_040: download counts are formatted correctly (numbers or K/M)', async ({ page }) => {
|
||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||
await expect(page.locator('body')).not.toContainText(/error|500/i)
|
||
await expect(getSearchCards(page).first()).toContainText(/\d/)
|
||
})
|
||
})
|
||
|
||
// ─── Card Click Navigation ────────────────────────────────────────────────────
|
||
|
||
test.describe('Search Card Navigation (Real API)', () => {
|
||
test.beforeEach(async ({ page }) => {
|
||
await setEnglishLocale(page)
|
||
})
|
||
|
||
// TC_SEARCH_INTERACT_007 P0 - clicking card navigates to detail page
|
||
test('TC_SEARCH_INTERACT_007: clicking a skill card navigates to the skill detail page', async ({ page }, testInfo) => {
|
||
const current = latestSeed(basicSeed!)
|
||
await registerSession(page, testInfo)
|
||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||
const firstCard = getSearchCard(page, current.skillName)
|
||
await expect(firstCard).toBeVisible({ timeout: 8_000 })
|
||
await firstCard.click()
|
||
await expect(page).toHaveURL(new RegExp(`/space/${current.skill.namespace}/${current.skill.slug}`))
|
||
})
|
||
|
||
// TC_SEARCH_INTERACT_008 P0 - detail page matches clicked card
|
||
test('TC_SEARCH_INTERACT_008: skill detail page matches the card that was clicked', async ({ page }, testInfo) => {
|
||
const current = latestSeed(basicSeed!)
|
||
await registerSession(page, testInfo)
|
||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||
const firstCard = getSearchCard(page, current.skillName)
|
||
await expect(firstCard).toBeVisible({ timeout: 8_000 })
|
||
await firstCard.click()
|
||
await expect(page).toHaveURL(new RegExp(`/space/${current.skill.namespace}/${current.skill.slug}`))
|
||
await expect(page.getByRole('heading', { name: current.skillName, exact: true })).toBeVisible()
|
||
})
|
||
|
||
// TC_SEARCH_INTERACT_009 P1 - Ctrl+click opens in new tab
|
||
test('TC_SEARCH_INTERACT_009: Ctrl+click on card opens skill detail in new tab', async ({ page, context }) => {
|
||
test.skip(true, 'Skill cards render as clickable divs, so browser-level new-tab semantics do not apply.')
|
||
const current = latestSeed(basicSeed!)
|
||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||
const firstCard = getSearchCard(page, current.skillName)
|
||
await expect(firstCard).toBeVisible({ timeout: 8_000 })
|
||
|
||
const [newPage] = await Promise.all([
|
||
context.waitForEvent('page'),
|
||
firstCard.click({ modifiers: ['Meta'] }),
|
||
])
|
||
await newPage.waitForLoadState()
|
||
await expect(newPage).toHaveURL(/\/space\//)
|
||
await newPage.close()
|
||
})
|
||
})
|
||
|
||
// ─── Sort Switching Updates Cards ────────────────────────────────────────────
|
||
|
||
test.describe('Search Card Sort Interaction (Real API)', () => {
|
||
test.beforeEach(async ({ page }) => {
|
||
await setEnglishLocale(page)
|
||
})
|
||
|
||
// TC_SEARCH_INTERACT_021 P0 - switching sort updates cards
|
||
test('TC_SEARCH_INTERACT_021: switching sort tab re-renders card list', async ({ page }) => {
|
||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||
await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 })
|
||
|
||
await page.getByRole('button', { name: 'Downloads' }).click()
|
||
await page.waitForLoadState('networkidle')
|
||
await expect(page).toHaveURL(/sort=downloads/)
|
||
await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 })
|
||
})
|
||
|
||
// TC_SEARCH_INTERACT_026 P0 - re-search replaces cards
|
||
test('TC_SEARCH_INTERACT_026: re-searching with new keyword replaces card list', async ({ page }) => {
|
||
await page.goto(SEARCH_URL(''))
|
||
const searchInput = page.getByPlaceholder('Search skills...')
|
||
await searchInput.fill(basicSeed!.keyword)
|
||
await searchInput.press('Enter')
|
||
await page.waitForLoadState('networkidle')
|
||
await expect(page).toHaveURL(new RegExp(`q=${basicSeed!.keyword}`))
|
||
await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 })
|
||
})
|
||
|
||
// TC_SEARCH_INTERACT_027 P0 - re-search resets page to 0
|
||
test('TC_SEARCH_INTERACT_027: re-searching resets page number to 0', async ({ page }) => {
|
||
await page.goto(SEARCH_URL(basicSeed!.keyword, 'relevance', 1))
|
||
const searchInput = page.getByPlaceholder('Search skills...')
|
||
await searchInput.fill(basicSeed!.keyword)
|
||
await searchInput.press('Enter')
|
||
await expect(page).toHaveURL(/page=0/)
|
||
})
|
||
})
|
||
|
||
// ─── Pagination Card Updates ──────────────────────────────────────────────────
|
||
|
||
test.describe('Search Card Pagination (Real API)', () => {
|
||
test.beforeEach(async ({ page }) => {
|
||
await setEnglishLocale(page)
|
||
})
|
||
|
||
// TC_SEARCH_INTERACT_023 P0 - switching page updates cards
|
||
test('TC_SEARCH_INTERACT_023: switching to next page shows different cards', async ({ page }) => {
|
||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||
await page.waitForLoadState('networkidle')
|
||
|
||
const nextBtn = page.getByRole('button', { name: /next|›/i })
|
||
const firstCardTitle = await getSearchCards(page).first().getByRole('heading').textContent()
|
||
await expect(nextBtn).toBeVisible({ timeout: 10_000 })
|
||
await nextBtn.click()
|
||
await page.waitForLoadState('networkidle')
|
||
await expect(page).toHaveURL(/page=1/)
|
||
await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 })
|
||
const secondPageFirstTitle = await getSearchCards(page).first().getByRole('heading').textContent()
|
||
expect(secondPageFirstTitle).not.toBe(firstCardTitle)
|
||
})
|
||
|
||
// TC_SEARCH_INTERACT_025 P1 - page switch scrolls to top
|
||
test('TC_SEARCH_INTERACT_025: switching page scrolls back to top of results', async ({ page }) => {
|
||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||
await page.waitForLoadState('networkidle')
|
||
|
||
const nextBtn = page.getByRole('button', { name: /next|›/i })
|
||
await expect(nextBtn).toBeVisible({ timeout: 10_000 })
|
||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight))
|
||
await nextBtn.click()
|
||
await page.waitForLoadState('networkidle')
|
||
await expect.poll(
|
||
() => page.evaluate(() => window.scrollY),
|
||
{ timeout: 5_000, intervals: [100, 250, 500, 1_000] },
|
||
).toBeLessThan(300)
|
||
})
|
||
})
|
||
|
||
// ─── Loading State ────────────────────────────────────────────────────────────
|
||
|
||
test.describe('Search Card Loading State (Real API)', () => {
|
||
test.beforeEach(async ({ page }) => {
|
||
await setEnglishLocale(page)
|
||
})
|
||
|
||
// TC_SEARCH_INTERACT_030 P0 - skeleton disappears after load
|
||
test('TC_SEARCH_INTERACT_030: skeleton screen disappears and real cards appear after load', async ({ page }) => {
|
||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||
await page.waitForLoadState('networkidle')
|
||
await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 })
|
||
await expect(page.locator('[class*="skeleton"], [class*="shimmer"]')).toHaveCount(0)
|
||
})
|
||
})
|
||
|
||
// ─── Responsive Layout ────────────────────────────────────────────────────────
|
||
|
||
test.describe('Search Card Responsive Layout (Real API)', () => {
|
||
test.describe.configure({ retries: 2 })
|
||
test.use({ hasTouch: true })
|
||
|
||
test.beforeEach(async ({ page }) => {
|
||
await setEnglishLocale(page)
|
||
})
|
||
|
||
// TC_SEARCH_INTERACT_042 P0 - desktop 3-column grid
|
||
test('TC_SEARCH_INTERACT_042: desktop viewport shows 3-column card grid', async ({ page }) => {
|
||
await page.setViewportSize({ width: 1440, height: 900 })
|
||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||
await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 })
|
||
const grid = page.locator('[class*="grid"]').first()
|
||
await expect(grid).toBeVisible()
|
||
})
|
||
|
||
// TC_SEARCH_INTERACT_044 P0 - mobile 1-column layout
|
||
test('TC_SEARCH_INTERACT_044: mobile viewport shows single-column card layout', async ({ page }) => {
|
||
await page.setViewportSize({ width: 375, height: 812 })
|
||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||
const cards = await waitForCards(page)
|
||
await expect(cards.first()).toBeVisible({ timeout: 8_000 })
|
||
await expect(page.locator('body')).not.toContainText(/error|500/i)
|
||
})
|
||
|
||
// TC_SEARCH_INTERACT_043 P0 - tablet 2-column layout
|
||
test('TC_SEARCH_INTERACT_043: tablet viewport shows 2-column card layout', async ({ page }) => {
|
||
await page.setViewportSize({ width: 768, height: 1024 })
|
||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||
const cards = await waitForCards(page)
|
||
await expect(cards.first()).toBeVisible({ timeout: 8_000 })
|
||
await expect(page.locator('body')).not.toContainText(/error|500/i)
|
||
})
|
||
|
||
// TC_SEARCH_INTERACT_045 P1 - responsive layout adjusts on resize
|
||
test('TC_SEARCH_INTERACT_045: card layout adjusts when browser window is resized', async ({ page }) => {
|
||
await page.setViewportSize({ width: 1440, height: 900 })
|
||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||
await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 })
|
||
|
||
await page.setViewportSize({ width: 375, height: 812 })
|
||
await expect(getSearchCards(page).first()).toBeVisible()
|
||
await expect(page.locator('body')).not.toContainText(/error|500/i)
|
||
})
|
||
|
||
// TC_SEARCH_INTERACT_046 P0 - mobile touch interaction
|
||
test('TC_SEARCH_INTERACT_046: mobile touch on card navigates to skill detail', async ({ page }, testInfo) => {
|
||
const current = latestSeed(basicSeed!)
|
||
await registerSession(page, testInfo)
|
||
await page.setViewportSize({ width: 375, height: 812 })
|
||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||
const firstCard = getSearchCard(page, current.skillName)
|
||
await expect(firstCard).toBeVisible({ timeout: 8_000 })
|
||
await firstCard.tap()
|
||
await expect(page).toHaveURL(new RegExp(`/space/${current.skill.namespace}/${current.skill.slug}`))
|
||
})
|
||
})
|
||
|
||
// ─── Keyboard Navigation ──────────────────────────────────────────────────────
|
||
|
||
test.describe('Search Card Keyboard Navigation (Real API)', () => {
|
||
test.beforeEach(async ({ page }) => {
|
||
await setEnglishLocale(page)
|
||
})
|
||
|
||
// TC_SEARCH_INTERACT_049 P1 - Tab key navigates between cards
|
||
test('TC_SEARCH_INTERACT_049: Tab key can navigate between skill cards', async ({ page }) => {
|
||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||
await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 })
|
||
|
||
await page.keyboard.press('Tab')
|
||
await page.keyboard.press('Tab')
|
||
const focused = page.locator(':focus')
|
||
await expect(focused).toBeVisible()
|
||
})
|
||
|
||
// TC_SEARCH_INTERACT_050 P1 - Enter key opens focused card
|
||
test('TC_SEARCH_INTERACT_050: pressing Enter on a focused card opens the skill detail', async ({ page }, testInfo) => {
|
||
const current = latestSeed(basicSeed!)
|
||
await registerSession(page, testInfo)
|
||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||
const firstCard = getSearchCard(page, current.skillName)
|
||
await expect(firstCard).toBeVisible({ timeout: 8_000 })
|
||
|
||
await firstCard.focus()
|
||
await page.keyboard.press('Enter')
|
||
await expect(page).toHaveURL(/\/space\//)
|
||
})
|
||
|
||
// TC_SEARCH_INTERACT_051 P1 - focus state visible on cards
|
||
test('TC_SEARCH_INTERACT_051: focused card has a visible focus indicator', async ({ page }) => {
|
||
const current = latestSeed(basicSeed!)
|
||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||
const firstCard = getSearchCard(page, current.skillName)
|
||
await expect(firstCard).toBeVisible({ timeout: 8_000 })
|
||
await firstCard.focus()
|
||
const focused = page.locator(':focus')
|
||
await expect(focused).toBeVisible()
|
||
})
|
||
})
|
||
|
||
// ─── Error Handling ───────────────────────────────────────────────────────────
|
||
|
||
test.describe('Search Card Error Handling (Real API)', () => {
|
||
test.beforeEach(async ({ page }) => {
|
||
await setEnglishLocale(page)
|
||
})
|
||
|
||
// TC_SEARCH_INTERACT_033 P1 - single result displays correctly
|
||
test('TC_SEARCH_INTERACT_033: single search result displays card layout correctly', async ({ page }) => {
|
||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||
const cards = await waitForCards(page)
|
||
expect(await cards.count()).toBeGreaterThan(0)
|
||
await expect(page.locator('body')).not.toContainText(/error|500/i)
|
||
})
|
||
|
||
// TC_SEARCH_INTERACT_060 P1 - cache: returning to search page shows results quickly
|
||
test('TC_SEARCH_INTERACT_060: returning to search page shows cached results quickly', async ({ page }, testInfo) => {
|
||
await registerSession(page, testInfo)
|
||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||
await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 })
|
||
|
||
await page.goto('/dashboard')
|
||
await page.goBack()
|
||
await expect(page).toHaveURL(/\/search/)
|
||
await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 })
|
||
})
|
||
})
|