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