skillhub/web/e2e/search-page-full.spec.ts

336 lines
15 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 } from '@playwright/test'
import { setEnglishLocale } from './helpers/auth-fixtures'
import {
DEFAULT_SEARCH_KEYWORD,
getSearchCards,
prepareSearchSeed,
type PreparedSearchSeed,
} from './helpers/search-seed'
import { registerSession } from './helpers/session'
function searchUrl(query: string, sort = 'relevance', page = 0, starredOnly = false) {
return `/search?q=${encodeURIComponent(query)}&sort=${sort}&page=${page}&starredOnly=${starredOnly}`
}
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
})
// ─── Search Input ────────────────────────────────────────────────────────────
test.describe('Search Input (Real API)', () => {
test.beforeEach(async ({ page }) => {
await setEnglishLocale(page)
})
// TC_SEARCH_INPUT_001 P0
test('TC_SEARCH_INPUT_001: searches with a single keyword and shows results', async ({ page }) => {
await page.goto(searchUrl(basicSeed!.keyword))
await expect(getSearchCards(page).first()).toBeVisible({ timeout: 10_000 })
})
// TC_SEARCH_INPUT_003 P0 - empty search shows the default discovery list
test('TC_SEARCH_INPUT_003: empty search shows the default discovery list', async ({ page }) => {
const emptyQueryResponse = page.waitForResponse((response) => {
if (!response.url().includes('/api/web/skills?')) {
return false
}
const url = new URL(response.url())
return response.status() === 200
&& url.searchParams.has('q')
&& url.searchParams.get('q') === ''
})
await page.goto(searchUrl(''))
await emptyQueryResponse
await expect(page).toHaveURL(/\/search/)
await expect(getSearchCards(page).first()).toBeVisible({ timeout: 10_000 })
})
// TC_SEARCH_INPUT_004 P0 - Enter key triggers search
test('TC_SEARCH_INPUT_004: pressing Enter in search box triggers search', async ({ page }) => {
await page.goto(searchUrl(''))
const searchInput = page.getByPlaceholder('Search skills...')
await searchInput.fill(basicSeed!.keyword)
await searchInput.press('Enter')
await expect(page).toHaveURL(new RegExp(`q=${basicSeed!.keyword}`))
await expect(getSearchCards(page).first()).toBeVisible()
})
// TC_SEARCH_INPUT_009 P0 - Chinese keyword search
test('TC_SEARCH_INPUT_009: supports Chinese keyword search without error', async ({ page }) => {
await page.goto(searchUrl('测试技能'))
await expect(page).toHaveURL(/\/search/)
await expect(page.locator('body')).not.toContainText(/error|500|crash/i)
})
// TC_SEARCH_INPUT_010 P0 - English keyword search
test('TC_SEARCH_INPUT_010: supports English keyword search', async ({ page }) => {
await page.goto(searchUrl('skill'))
await expect(page).toHaveURL(/q=skill/)
await expect(page.locator('body')).not.toContainText(/error|500|crash/i)
})
// TC_SEARCH_INPUT_007 P1 - special characters handled gracefully
test('TC_SEARCH_INPUT_007: handles special characters in search without crashing', async ({ page }) => {
await page.goto(searchUrl('@#$%'))
await expect(page).toHaveURL(/\/search/)
await expect(page.locator('body')).not.toContainText(/error|500|crash/i)
})
// TC_SEARCH_INPUT_011 P1 - leading/trailing spaces trimmed
test('TC_SEARCH_INPUT_011: trims leading and trailing spaces from search query', async ({ page }) => {
await page.goto(searchUrl(''))
const searchInput = page.getByPlaceholder('Search skills...')
await searchInput.fill(` ${basicSeed!.keyword} `)
await searchInput.press('Enter')
await expect(page).toHaveURL(new RegExp(`q=${basicSeed!.keyword}`))
await expect(getSearchCards(page).first()).toBeVisible()
})
})
// ─── Sort / Filter ────────────────────────────────────────────────────────────
test.describe('Search Sort and Filter (Authenticated Real API)', () => {
test.beforeEach(async ({ page }, testInfo) => {
await setEnglishLocale(page)
await registerSession(page, testInfo)
})
// TC_SEARCH_SORT_001 P0 - default relevance tab selected
test('TC_SEARCH_SORT_001: relevance sort tab is selected by default', async ({ page }) => {
await page.goto(searchUrl(basicSeed!.keyword, 'relevance'))
await expect(page.getByRole('button', { name: 'Relevance' })).toBeVisible()
})
// TC_SEARCH_SORT_004 P0 - downloads sort
test('TC_SEARCH_SORT_004: clicking Downloads tab updates sort in URL', async ({ page }) => {
await page.goto(searchUrl(basicSeed!.keyword, 'relevance'))
await page.getByRole('button', { name: 'Downloads' }).click()
await expect(page).toHaveURL(/sort=downloads/)
})
// TC_SEARCH_SORT_005 P0 - newest sort
test('TC_SEARCH_SORT_005: clicking Newest tab updates sort in URL', async ({ page }) => {
await page.goto(searchUrl(basicSeed!.keyword, 'relevance'))
await page.getByRole('button', { name: 'Newest' }).click()
await expect(page).toHaveURL(/sort=newest|sort=created/)
})
// TC_SEARCH_SORT_006 P0 - switching sort preserves search keyword
test('TC_SEARCH_SORT_006: switching sort tab preserves the search keyword', async ({ page }) => {
await page.goto(searchUrl(basicSeed!.keyword, 'relevance'))
await page.getByRole('button', { name: 'Downloads' }).click()
await expect(page).toHaveURL(new RegExp(`q=${basicSeed!.keyword}`))
await expect(page).toHaveURL(/sort=downloads/)
})
// TC_SEARCH_SORT_007 P0 - switching sort resets page to 0
test('TC_SEARCH_SORT_007: switching sort tab resets page to 0', async ({ page }) => {
await page.goto(searchUrl(basicSeed!.keyword, 'relevance', 1))
await page.getByRole('button', { name: 'Downloads' }).click()
await expect(page).toHaveURL(/page=0/)
})
// TC_SEARCH_SORT_012 P1 - URL contains sort param
test('TC_SEARCH_SORT_012: URL contains sort parameter after switching tabs', async ({ page }) => {
await page.goto(searchUrl(basicSeed!.keyword, 'relevance'))
await page.getByRole('button', { name: 'Downloads' }).click()
await expect(page).toHaveURL(/sort=/)
})
test('starred only filter stays on search page for authenticated user', async ({ page }) => {
await page.goto(searchUrl(basicSeed!.keyword, 'relevance'))
await page.getByRole('button', { name: 'Starred only' }).click()
await expect(page).toHaveURL(/starredOnly=true/)
await expect(page).not.toHaveURL(/\/login/)
})
})
test.describe('Search Sort and Filter (Anonymous Real API)', () => {
test.beforeEach(async ({ page }) => {
await setEnglishLocale(page)
})
test('starred only filter redirects anonymous user to login', async ({ page }) => {
await page.goto(searchUrl(DEFAULT_SEARCH_KEYWORD, 'relevance'))
await page.getByRole('button', { name: 'Starred only' }).click()
await expect(page).toHaveURL(/\/login\?returnTo=/)
})
})
// ─── Skill Count ──────────────────────────────────────────────────────────────
test.describe('Search Skill Count Display (Real API)', () => {
test.beforeEach(async ({ page }) => {
await setEnglishLocale(page)
})
// TC_SEARCH_COUNT_001 P0 - count visible
test('TC_SEARCH_COUNT_001: skill count indicator is visible on search page with results', async ({ page }) => {
await page.goto(searchUrl(basicSeed!.keyword))
await expect(page.getByText(/\d+\s+skills found/i)).toBeVisible({ timeout: 10_000 })
})
// TC_SEARCH_COUNT_007 P0 - count updates after search
test('TC_SEARCH_COUNT_007: skill count updates after performing a search', async ({ page }) => {
await page.goto(searchUrl(''))
const searchInput = page.getByPlaceholder('Search skills...')
await searchInput.fill(basicSeed!.keyword)
await searchInput.press('Enter')
await expect(page.getByText(/\d+\s+skills found/i)).toBeVisible({ timeout: 10_000 })
})
// TC_SEARCH_COUNT_009 P0 - zero results shows 0
test('TC_SEARCH_COUNT_009: shows empty-state copy when search returns no results', async ({ page }) => {
await page.goto(searchUrl('xyznonexistentkeyword99999'))
await expect(page.getByRole('heading', { name: 'No results found' })).toBeVisible({ timeout: 8_000 })
})
// TC_SEARCH_COUNT_008 P0 - count stays same when switching sort
test('TC_SEARCH_COUNT_008: skill count remains the same after switching sort tab', async ({ page }) => {
await page.goto(searchUrl(basicSeed!.keyword))
const countText = await page.getByText(/\d+\s+skills found/i).textContent()
const initialCount = Number(countText?.match(/\d+/)?.[0] ?? '0')
await page.getByRole('button', { name: 'Downloads' }).click()
const updatedCountText = await page.getByText(/\d+\s+skills found/i).textContent()
const updatedCount = Number(updatedCountText?.match(/\d+/)?.[0] ?? '0')
expect(updatedCount).toBe(initialCount)
})
})
// ─── Search Results ───────────────────────────────────────────────────────────
test.describe('Search Results (Real API)', () => {
test.beforeEach(async ({ page }) => {
await setEnglishLocale(page)
})
// TC_SEARCH_RESULT_001 P0 - results shown
test('TC_SEARCH_RESULT_001: shows skill cards when search returns results', async ({ page }) => {
await page.goto(searchUrl(basicSeed!.keyword))
await expect(getSearchCards(page).first()).toBeVisible({ timeout: 10_000 })
})
// TC_SEARCH_RESULT_002 P0 - no results message
test('TC_SEARCH_RESULT_002: shows empty state message when no results found', async ({ page }) => {
await page.goto(searchUrl('xyznonexistentkeyword99999'))
await expect(page.getByRole('heading', { name: 'No results found' })).toBeVisible({ timeout: 8_000 })
})
// TC_SEARCH_RESULT_006 P0 - loading state
test('TC_SEARCH_RESULT_006: page renders without error during and after search', async ({ page }) => {
await page.goto(searchUrl(basicSeed!.keyword))
await expect(page.locator('body')).not.toContainText(/error|500|crash/i)
})
// TC_SEARCH_RESULT_008 P0 - result count matches cards
test('TC_SEARCH_RESULT_008: number of displayed cards matches the count indicator', async ({ page }) => {
await page.goto(searchUrl(basicSeed!.keyword))
await page.waitForLoadState('networkidle')
const cards = getSearchCards(page)
await expect(cards.first()).toBeVisible({ timeout: 10_000 })
const visibleCount = await cards.count()
const countText = await page.getByText(/\d+\s+skills found/i).textContent()
const totalMatch = countText?.match(/\d+/)
expect(totalMatch).toBeTruthy()
expect(visibleCount).toBeGreaterThan(0)
expect(Number(totalMatch?.[0])).toBeGreaterThanOrEqual(visibleCount)
})
// TC_SEARCH_RESULT_009 P0 - downloads sort order
test('TC_SEARCH_RESULT_009: results are sorted by downloads when Downloads tab is selected', async ({ page }) => {
await page.goto(searchUrl(basicSeed!.keyword, 'downloads'))
await expect(page).toHaveURL(/sort=downloads/)
await expect(page.locator('body')).not.toContainText(/error|500/i)
})
// TC_SEARCH_RESULT_010 P0 - newest sort order
test('TC_SEARCH_RESULT_010: results are sorted by newest when Newest tab is selected', async ({ page }) => {
await page.goto(searchUrl(basicSeed!.keyword, 'newest'))
await expect(page.locator('body')).not.toContainText(/error|500/i)
})
})
// ─── Pagination ───────────────────────────────────────────────────────────────
test.describe('Search Pagination (Real API)', () => {
test.beforeEach(async ({ page }) => {
await setEnglishLocale(page)
})
// TC_SEARCH_PAGE_011 P1 - URL contains page param
test('TC_SEARCH_PAGE_011: URL contains page parameter', async ({ page }) => {
await page.goto(searchUrl(basicSeed!.keyword, 'relevance', 0))
await expect(page).toHaveURL(/page=/)
})
// TC_SEARCH_PAGE_012 P0 - switching page preserves search and sort
test('TC_SEARCH_PAGE_012: switching page preserves search keyword and sort', async ({ page }) => {
await page.goto(searchUrl(basicSeed!.keyword, 'downloads', 0))
const nextBtn = page.getByRole('button', { name: /next||»/i })
await expect(nextBtn).toBeVisible({ timeout: 10_000 })
await nextBtn.click()
await expect(page).toHaveURL(new RegExp(`q=${basicSeed!.keyword}`))
await expect(page).toHaveURL(/sort=downloads/)
await expect(page).toHaveURL(/page=1/)
})
// TC_SEARCH_PAGE_007 P0 - first page disables previous button
test('TC_SEARCH_PAGE_007: previous page button is disabled on first page', async ({ page }) => {
await page.goto(searchUrl(basicSeed!.keyword, 'relevance', 0))
const prevBtn = page.getByRole('button', { name: /prev||«/i })
if (await prevBtn.isVisible()) {
await expect(prevBtn).toBeDisabled()
}
})
})
// ─── Security ─────────────────────────────────────────────────────────────────
test.describe('Search Security (Real API)', () => {
test.beforeEach(async ({ page }) => {
await setEnglishLocale(page)
})
// TC_SEARCH_SEC_001 P0 - XSS in search box
test('TC_SEARCH_SEC_001: XSS payload in search box is not executed', async ({ page }) => {
let alerted = false
page.on('dialog', () => { alerted = true })
await page.goto(searchUrl(''))
const searchInput = page.getByPlaceholder('Search skills...')
await searchInput.fill("<script>alert('xss')</script>")
await searchInput.press('Enter')
await page.waitForTimeout(1_000)
expect(alerted).toBe(false)
await expect(page.locator('body')).not.toContainText(/error|500/i)
})
// TC_SEARCH_SEC_002 P0 - SQL injection in search box
test('TC_SEARCH_SEC_002: SQL injection payload in search box is handled safely', async ({ page }) => {
await page.goto(searchUrl("' OR '1'='1"))
await expect(page.locator('body')).not.toContainText(/sql|syntax error|database/i)
await expect(page).toHaveURL(/\/search/)
})
// TC_SEARCH_SEC_003 P1 - URL param tampering
test('TC_SEARCH_SEC_003: tampered URL parameters are handled gracefully', async ({ page }, testInfo) => {
await registerSession(page, testInfo)
await page.goto('/search?q=agent&sort=INVALID_SORT&page=-1&starredOnly=invalid')
await expect(page).toHaveURL(/\/search/)
await expect(page.locator('body')).not.toContainText(/error|500|crash/i)
})
})