skillhub/web/e2e/search-card-interaction.spec.ts
2026-04-14 13:55:33 +08:00

461 lines
19 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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