From 69a299e40f03bb889e0e6046eb086cee18852cc9 Mon Sep 17 00:00:00 2001 From: huishi3 Date: Thu, 9 Apr 2026 09:24:51 +0530 Subject: [PATCH] test(e2e): add auth validation and search coverage --- web/e2e/helpers/search-seed.ts | 265 +++++++++++++ web/e2e/helpers/session.ts | 245 +++++++++++- web/e2e/helpers/test-data-builder.ts | 198 +++++++--- web/e2e/register-login-validation.spec.ts | 308 +++++++++++++++ web/e2e/search-card-interaction.spec.ts | 438 ++++++++++++++++++++++ web/e2e/search-page-full.spec.ts | 324 ++++++++++++++++ 6 files changed, 1722 insertions(+), 56 deletions(-) create mode 100644 web/e2e/helpers/search-seed.ts create mode 100644 web/e2e/register-login-validation.spec.ts create mode 100644 web/e2e/search-card-interaction.spec.ts create mode 100644 web/e2e/search-page-full.spec.ts diff --git a/web/e2e/helpers/search-seed.ts b/web/e2e/helpers/search-seed.ts new file mode 100644 index 00000000..c7112b32 --- /dev/null +++ b/web/e2e/helpers/search-seed.ts @@ -0,0 +1,265 @@ +import type { Browser, Locator, Page, TestInfo } from '@playwright/test' +import { createFreshSession, loginWithCredentials, registerSession } from './session' +import { E2eTestDataBuilder, type SeededNamespace, type SeededSkill } from './test-data-builder' + +export const DEFAULT_SEARCH_KEYWORD = 'agent' + +export interface SearchSeedContext { + builder: E2eTestDataBuilder + keyword: string + namespace: SeededNamespace + skills: SeededSkill[] + skillNames: string[] +} + +export interface PreparedSearchSeed extends SearchSeedContext { + dispose: () => Promise +} + +interface PublisherSession { + builder: E2eTestDataBuilder + context: Awaited> + namespace: SeededNamespace + page: Page +} + +function requireEnv(name: string): string { + const value = process.env[name] + if (!value) { + throw new Error(`Missing required E2E env: ${name}`) + } + return value +} + +function getOptionalEnv(name: string): string | undefined { + const value = process.env[name]?.trim() + return value ? value : undefined +} + +function publisherCredentials() { + return { + username: requireEnv('E2E_PUBLISH_USERNAME'), + password: requireEnv('E2E_PUBLISH_PASSWORD'), + } +} + +function adminCredentials() { + return { + username: getOptionalEnv('E2E_ADMIN_USERNAME') ?? getOptionalEnv('BOOTSTRAP_ADMIN_USERNAME') ?? 'admin', + password: getOptionalEnv('E2E_ADMIN_PASSWORD') ?? getOptionalEnv('BOOTSTRAP_ADMIN_PASSWORD') ?? 'ChangeMe!2026', + } +} + +function hasPublisherCredentials() { + return Boolean(getOptionalEnv('E2E_PUBLISH_USERNAME') && getOptionalEnv('E2E_PUBLISH_PASSWORD')) +} + +async function openProvidedPublisherSession(browser: Browser, testInfo: TestInfo): Promise { + const context = await browser.newContext() + const page = await context.newPage() + const builder = new E2eTestDataBuilder(page, testInfo) + + await loginWithCredentials(page, publisherCredentials(), testInfo) + await builder.init() + + return { + builder, + context, + namespace: await builder.ensureWritableNamespace(), + page, + } +} + +async function openAdhocPublisherSession(browser: Browser, testInfo: TestInfo): Promise { + const context = await browser.newContext() + const page = await context.newPage() + const builder = new E2eTestDataBuilder(page, testInfo) + + try { + await createFreshSession(page, testInfo) + } catch { + // Fall back to a regular worker session when transient registration issues happen + // after Playwright restarts the worker following an earlier test failure. + await registerSession(page, testInfo) + } + await builder.init() + + return { + builder, + context, + namespace: await builder.ensureWritableNamespace(), + page, + } +} + +async function publishSearchSkillsChunk( + session: PublisherSession, + keyword: string, + description: string, + seedSuffix: string, + startIndex: number, + count: number, +) { + const skills: SeededSkill[] = [] + const skillNames: string[] = [] + + for (let offset = 0; offset < count; offset += 1) { + const skillIndex = startIndex + offset + 1 + const skillName = `${keyword}-search-${skillIndex}-${seedSuffix}`.slice(0, 48) + const skill = await session.builder.publishSkill(session.namespace.slug, { + name: skillName, + description, + }) + skills.push(skill) + skillNames.push(skillName) + } + + return { skillNames, skills } +} + +export async function seedPublicSearchSkills( + page: Page, + testInfo: TestInfo, + options?: { + awaitSearchIndexed?: boolean + count?: number + keyword?: string + description?: string + }, +): Promise { + const count = options?.count ?? 1 + const builder = new E2eTestDataBuilder(page, testInfo) + const seedSuffix = `${testInfo.parallelIndex ?? 0}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}` + const keyword = options?.keyword || `agent-${seedSuffix}`.slice(0, 32) + + await loginWithCredentials(page, publisherCredentials(), testInfo) + await builder.init() + + const namespace = await builder.ensureWritableNamespace() + const skills: SeededSkill[] = [] + const skillNames: string[] = [] + + for (let index = 0; index < count; index += 1) { + const skillName = `${keyword}-search-${index + 1}-${seedSuffix}`.slice(0, 48) + const skill = await builder.publishSkill(namespace.slug, { + name: skillName, + description: options?.description || `Searchable ${keyword} skill ${index + 1} for Playwright E2E coverage.`, + }) + skills.push(skill) + skillNames.push(skillName) + } + + if (options?.awaitSearchIndexed ?? true) { + await builder.waitForSearchResults(keyword, skills.map((skill) => skill.slug)) + } + + return { + builder, + keyword, + namespace, + skills, + skillNames, + } +} + +export async function cleanupSearchSeed(seed?: SearchSeedContext) { + if (seed) { + await seed.builder.cleanup() + } +} + +export async function prepareSearchSeed( + browser: Browser, + testInfo: TestInfo, + options?: { + awaitSearchIndexed?: boolean + count?: number + keyword?: string + description?: string + }, +): Promise { + const count = options?.count ?? 1 + const seedSuffix = `${testInfo.parallelIndex ?? 0}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}` + const keyword = options?.keyword || `agent-${seedSuffix}`.slice(0, 32) + const description = options?.description || `Searchable ${keyword} skill for Playwright E2E coverage.` + const useProvidedPublisher = count <= 3 && hasPublisherCredentials() + const publisherSessions: PublisherSession[] = [ + useProvidedPublisher + ? await openProvidedPublisherSession(browser, testInfo) + : await openAdhocPublisherSession(browser, testInfo), + ] + const skills: SeededSkill[] = [] + const skillNames: string[] = [] + let publishedCount = 0 + + while (publishedCount < count) { + if (publishedCount >= 10 && publisherSessions.length === 1) { + publisherSessions.push(await openAdhocPublisherSession(browser, testInfo)) + } + + const activeSession = publishedCount < 10 ? publisherSessions[0] : publisherSessions[publisherSessions.length - 1] + const chunkSize = publishedCount < 10 ? Math.min(10 - publishedCount, count - publishedCount) : count - publishedCount + const chunk = await publishSearchSkillsChunk( + activeSession, + keyword, + description, + seedSuffix, + publishedCount, + chunkSize, + ) + skills.push(...chunk.skills) + skillNames.push(...chunk.skillNames) + publishedCount += chunkSize + } + + const seed: SearchSeedContext = { + builder: publisherSessions[0].builder, + keyword, + namespace: publisherSessions[0].namespace, + skills, + skillNames, + } + + const adminContext = await browser.newContext() + const adminPage = await adminContext.newPage() + const adminBuilder = new E2eTestDataBuilder(adminPage, testInfo) + + await loginWithCredentials(adminPage, adminCredentials(), testInfo) + await adminBuilder.init() + + for (const skill of seed.skills) { + const reviewTaskId = await adminBuilder.waitForPendingReview(skill.namespace, skill.slug, skill.version) + await adminBuilder.approveReview(reviewTaskId) + } + + await seed.builder.waitForSearchResults(seed.keyword, seed.skills.map((skill) => skill.slug)) + + return { + ...seed, + dispose: async () => { + await adminContext.close() + for (let index = publisherSessions.length - 1; index >= 0; index -= 1) { + await cleanupSearchSeed({ + builder: publisherSessions[index].builder, + keyword: seed.keyword, + namespace: publisherSessions[index].namespace, + skills: [], + skillNames: [], + }) + await publisherSessions[index].context.close() + } + }, + } +} + +export function getSearchCard(page: Page, skillName: string): Locator { + return page.locator('.cursor-pointer.group').filter({ + has: page.getByRole('heading', { name: skillName, exact: true }), + }).first() +} + +export function getSearchCards(page: Page): Locator { + return page.locator('.cursor-pointer.group').filter({ + has: page.locator('h3'), + }) +} diff --git a/web/e2e/helpers/session.ts b/web/e2e/helpers/session.ts index da6cca00..6b6ee60f 100644 --- a/web/e2e/helpers/session.ts +++ b/web/e2e/helpers/session.ts @@ -2,6 +2,29 @@ import { expect, type Page, type TestInfo } from '@playwright/test' const password = 'Passw0rd!123' const cachedUserByWorker = new Map() +const cachedSessionByAccount = new Map() +const requestTimeoutMs = process.env.CI ? 12_000 : 8_000 + +export interface TestCredentials { + password: string + username: string +} + +interface SessionSnapshot { + username: string + cookies: Array<{ + name: string + value: string + domain: string + path: string + expires: number + httpOnly: boolean + secure: boolean + sameSite: 'Strict' | 'Lax' | 'None' + }> +} + +const cachedSessionByWorker = new Map() function usernameForWorker(testInfo?: TestInfo): string { const worker = testInfo?.parallelIndex ?? 0 @@ -25,12 +48,14 @@ function isRetryableStatus(status: number): boolean { async function loginWithRetry( request: Page['request'], username: string, + currentPassword = password, retries = process.env.CI ? 10 : 6, ): Promise { for (let i = 0; i < retries; i += 1) { try { const login = await request.post('/api/v1/auth/local/login', { - data: { username, password }, + data: { username, password: currentPassword }, + timeout: requestTimeoutMs, }) if (login.ok()) { @@ -51,30 +76,148 @@ async function loginWithRetry( return false } +async function hasActiveSession(page: Page): Promise { + try { + const response = await page.context().request.get('/api/v1/auth/me', { + timeout: requestTimeoutMs, + }) + return response.ok() + } catch { + return false + } +} + +async function cacheSession(page: Page, worker: number, username: string) { + const snapshot = { + username, + cookies: await page.context().cookies(), + } + cachedSessionByWorker.set(worker, snapshot) + cachedSessionByAccount.set(username, snapshot) +} + +async function cacheAccountSession(page: Page, username: string) { + cachedSessionByAccount.set(username, { + username, + cookies: await page.context().cookies(), + }) +} + +async function restoreCachedSession(page: Page, worker: number): Promise { + const snapshot = cachedSessionByWorker.get(worker) + if (!snapshot) { + return null + } + + await page.context().addCookies(snapshot.cookies) + if (await hasActiveSession(page)) { + return snapshot + } + + cachedSessionByWorker.delete(worker) + return null +} + +async function restoreCachedSessionForAccount(page: Page, username: string): Promise { + const snapshot = cachedSessionByAccount.get(username) + if (!snapshot) { + return null + } + + await page.context().addCookies(snapshot.cookies) + if (await hasActiveSession(page)) { + return snapshot + } + + cachedSessionByAccount.delete(username) + return null +} + +async function primeAuthProviders(page: Page) { + try { + await page.context().request.get('/api/v1/auth/providers', { timeout: requestTimeoutMs }) + } catch { + // Best effort warm-up. + } +} + +async function tryBootstrapMockSession(page: Page, worker: number): Promise<{ username: string, password: string } | null> { + try { + await page.context().request.get('/api/v1/auth/providers', { + headers: { 'X-Mock-User-Id': 'local-user' }, + timeout: requestTimeoutMs, + }) + } catch { + return null + } + + if (!(await hasActiveSession(page))) { + return null + } + + await cacheSession(page, worker, 'local-user') + cachedUserByWorker.set(worker, 'local-user') + return { username: 'local-user', password } +} + async function registerSessionOnce(page: Page, testInfo?: TestInfo) { const worker = testInfo?.parallelIndex ?? 0 const cached = cachedUserByWorker.get(worker) const username = usernameForWorker(testInfo) const request = page.context().request - // Prime auth provider endpoint to stabilize cookie/bootstrap behavior. - try { - await request.get('/api/v1/auth/providers') - } catch { - // Best effort warm-up. + await primeAuthProviders(page) + + // Avoid hammering auth endpoints on every test run for the same worker. + const restored = await restoreCachedSession(page, worker) + if (restored) { + cachedUserByWorker.set(worker, restored.username) + return { username: restored.username, password } + } + + const mockSession = await tryBootstrapMockSession(page, worker) + if (mockSession) { + return mockSession } // Prefer the known-good cached account to avoid repeated failed-logins on a fixed username. if (cached && await loginWithRetry(request, cached)) { + await cacheSession(page, worker, cached) return { username: cached, password } } // Support environments where a deterministic worker account already exists. - if (!cached && await loginWithRetry(request, username, process.env.CI ? 4 : 3)) { + if (!cached && await loginWithRetry(request, username, password, process.env.CI ? 4 : 3)) { cachedUserByWorker.set(worker, username) + await cacheSession(page, worker, username) return { username, password } } + try { + const register = await request.post('/api/v1/auth/local/register', { + data: { + username, + password, + email: `${username}@example.test`, + }, + timeout: requestTimeoutMs, + }) + + if (register.ok()) { + cachedUserByWorker.set(worker, username) + await cacheSession(page, worker, username) + return { username, password } + } + + if (register.status() === 409 && await loginWithRetry(request, username, password, process.env.CI ? 8 : 6)) { + cachedUserByWorker.set(worker, username) + await cacheSession(page, worker, username) + return { username, password } + } + } catch { + // Fall through to the unique-account fallback below. + } + // Registering creates session cookies for the current request context. // Prefer creating a new unique account to avoid password drift and login throttling. for (let i = 0; i < 12; i += 1) { @@ -87,10 +230,12 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo) { password, email: `${uniqueUsername}@example.test`, }, + timeout: requestTimeoutMs, }) if (register.ok()) { cachedUserByWorker.set(worker, uniqueUsername) + await cacheSession(page, worker, uniqueUsername) return { username: uniqueUsername, password } } @@ -118,8 +263,9 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo) { // Final fallback for environments where registration is temporarily unavailable. const fallbackCandidates = [cached, username].filter((candidate): candidate is string => Boolean(candidate)) for (const candidate of fallbackCandidates) { - if (await loginWithRetry(request, candidate, process.env.CI ? 12 : 8)) { + if (await loginWithRetry(request, candidate, password, process.env.CI ? 12 : 8)) { cachedUserByWorker.set(worker, candidate) + await cacheSession(page, worker, candidate) return { username: candidate, password } } } @@ -127,6 +273,50 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo) { throw new Error(`Failed to establish e2e session for worker ${worker}`) } +async function createFreshSessionOnce(page: Page, testInfo?: TestInfo) { + const worker = testInfo?.parallelIndex ?? 0 + const request = page.context().request + + await primeAuthProviders(page) + + for (let i = 0; i < 12; i += 1) { + const uniqueUsername = `${uniqueUsernameForWorker(testInfo)}_${i}` + + try { + const register = await request.post('/api/v1/auth/local/register', { + data: { + username: uniqueUsername, + password, + email: `${uniqueUsername}@example.test`, + }, + timeout: requestTimeoutMs, + }) + + if (register.ok()) { + cachedUserByWorker.set(worker, uniqueUsername) + await cacheSession(page, worker, uniqueUsername) + return { username: uniqueUsername, password } + } + + const status = register.status() + if (status === 409 || status === 400) { + continue + } + + if (isRetryableStatus(status)) { + await sleep(300 * (i + 1)) + continue + } + + expect(register.ok()).toBeTruthy() + } catch { + await sleep(300 * (i + 1)) + } + } + + throw new Error(`Failed to create fresh e2e session for worker ${worker}`) +} + export async function registerSession(page: Page, testInfo?: TestInfo) { let lastError: unknown @@ -143,3 +333,42 @@ export async function registerSession(page: Page, testInfo?: TestInfo) { throw lastError } + +export async function createFreshSession(page: Page, testInfo?: TestInfo) { + let lastError: unknown + + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + return await createFreshSessionOnce(page, testInfo) + } catch (error) { + lastError = error + if (attempt < 2) { + await sleep(500 * (attempt + 1)) + } + } + } + + throw lastError +} + +export async function loginWithCredentials(page: Page, credentials: TestCredentials, _testInfo?: TestInfo) { + const request = page.context().request + + await primeAuthProviders(page) + + const restored = await restoreCachedSessionForAccount(page, credentials.username) + if (restored) { + return credentials + } + + const loggedIn = await loginWithRetry( + request, + credentials.username, + credentials.password, + process.env.CI ? 12 : 8, + ) + expect(loggedIn).toBeTruthy() + + await cacheAccountSession(page, credentials.username) + return credentials +} diff --git a/web/e2e/helpers/test-data-builder.ts b/web/e2e/helpers/test-data-builder.ts index 1a18e5e7..24fa8487 100644 --- a/web/e2e/helpers/test-data-builder.ts +++ b/web/e2e/helpers/test-data-builder.ts @@ -25,6 +25,15 @@ export interface SeededReviewData { skill: SeededSkill } +interface ReviewTaskSummary { + id: number + namespace: string + skillSlug: string + status: string + submittedBy: string + version: string +} + interface ApiEnvelope { code: number msg: string @@ -36,6 +45,13 @@ interface ApiFailure extends Error { code?: number } +export interface SeedSkillOptions { + name?: string + description?: string + version?: string + readmeHeading?: string +} + function asApiErrorBody(value: unknown): string { if (!value || typeof value !== 'object') { return '' @@ -49,26 +65,39 @@ function uniqueSuffix(testInfo?: TestInfo): string { return `${worker}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` } -function buildSkillPackageZipBuffer(suffix: string): Buffer { - const tempRoot = mkdtempSync(path.join(tmpdir(), 'skillhub-e2e-')) - try { - const packageDir = path.join(tempRoot, `pkg-${suffix}`) - const zipPath = path.join(tempRoot, `pkg-${suffix}.zip`) - const skillName = `e2e-skill-${suffix}`.slice(0, 48) - const skillMd = `--- +function buildSkillPackageContent(suffix: string, options?: SeedSkillOptions) { + const skillName = (options?.name || `e2e-skill-${suffix}`).slice(0, 48) + const description = options?.description || 'E2E generated skill for real-request tests' + const version = options?.version || '1.0.0' + const readmeHeading = options?.readmeHeading || skillName + const skillMd = `--- name: ${skillName} -description: E2E generated skill for real-request tests -version: 1.0.0 +description: ${description} +version: ${version} --- -# ${skillName} +# ${readmeHeading} Generated by Playwright E2E. ` + return { + readmeHeading, + skillMd, + skillName, + } +} + +function buildSkillPackageZipBuffer(suffix: string, options?: SeedSkillOptions): Buffer { + const tempRoot = mkdtempSync(path.join(tmpdir(), 'skillhub-e2e-')) + try { + const packageDir = path.join(tempRoot, `pkg-${suffix}`) + const zipPath = path.join(tempRoot, `pkg-${suffix}.zip`) + const { readmeHeading, skillMd } = buildSkillPackageContent(suffix, options) + execFileSync('mkdir', ['-p', packageDir]) writeFileSync(path.join(packageDir, 'SKILL.md'), skillMd, 'utf8') - writeFileSync(path.join(packageDir, 'README.md'), `# ${skillName}\n`, 'utf8') + writeFileSync(path.join(packageDir, 'README.md'), `# ${readmeHeading}\n`, 'utf8') execFileSync('zip', ['-q', '-r', zipPath, 'SKILL.md', 'README.md'], { cwd: packageDir }) return readFileSync(zipPath) } finally { @@ -76,25 +105,15 @@ Generated by Playwright E2E. } } -function createSkillPackageZipFile(suffix: string): { filePath: string; cleanup: () => void } { +function createSkillPackageZipFile(suffix: string, options?: SeedSkillOptions): { filePath: string; cleanup: () => void } { const tempRoot = mkdtempSync(path.join(tmpdir(), 'skillhub-e2e-file-')) const packageDir = path.join(tempRoot, `pkg-${suffix}`) const zipPath = path.join(tempRoot, `pkg-${suffix}.zip`) - const skillName = `e2e-skill-${suffix}`.slice(0, 48) - const skillMd = `--- -name: ${skillName} -description: E2E generated skill for real-request tests -version: 1.0.0 ---- - -# ${skillName} - -Generated by Playwright E2E. -` + const { readmeHeading, skillMd } = buildSkillPackageContent(suffix, options) execFileSync('mkdir', ['-p', packageDir]) writeFileSync(path.join(packageDir, 'SKILL.md'), skillMd, 'utf8') - writeFileSync(path.join(packageDir, 'README.md'), `# ${skillName}\n`, 'utf8') + writeFileSync(path.join(packageDir, 'README.md'), `# ${readmeHeading}\n`, 'utf8') execFileSync('zip', ['-q', '-r', zipPath, 'SKILL.md', 'README.md'], { cwd: packageDir }) return { @@ -234,32 +253,115 @@ export class E2eTestDataBuilder { } } - async publishSkill(namespaceSlug: string): Promise { - const unique = `${this.suffix}_${Math.random().toString(36).slice(2, 6)}` - const zipBuffer = buildSkillPackageZipBuffer(unique) + async waitForSearchResult(query: string, expectedSlug?: string): Promise { + const encodedQuery = encodeURIComponent(query) - let result: SeededSkill - try { - result = await parseEnvelope( - await this.request.post(`/api/web/skills/${encodeURIComponent(namespaceSlug)}/publish`, { - multipart: { - file: { - name: 'sample-skill.zip', - mimeType: 'application/zip', - buffer: zipBuffer, - }, - visibility: 'PUBLIC', - }, - }), - ) - } catch (error) { - const fallback = await this.getMySkillInNamespace(namespaceSlug) - if (!fallback) { - throw error + for (let attempt = 0; attempt < 20; attempt += 1) { + try { + const page = await parseEnvelope<{ + items: Array<{ slug: string }> + }>( + await this.request.get(`/api/web/skills?q=${encodedQuery}&sort=relevance&page=0&size=50`), + ) + if (!expectedSlug || page.items.some((item) => item.slug === expectedSlug)) { + return + } + } catch { + // Search indexing can lag briefly behind publish in local environments. } - return fallback + + await new Promise((resolve) => setTimeout(resolve, 300 * (attempt + 1))) } + throw new Error(`Timed out waiting for search result "${query}"${expectedSlug ? ` (${expectedSlug})` : ''}`) + } + + async waitForSearchResults(query: string, expectedSlugs: string[]): Promise { + const pending = new Set(expectedSlugs) + if (pending.size === 0) { + return + } + + const encodedQuery = encodeURIComponent(query) + + for (let attempt = 0; attempt < 20; attempt += 1) { + try { + const page = await parseEnvelope<{ + items: Array<{ slug: string }> + }>( + await this.request.get(`/api/web/skills?q=${encodedQuery}&sort=relevance&page=0&size=50`), + ) + + for (const item of page.items) { + pending.delete(item.slug) + } + + if (pending.size === 0) { + return + } + } catch { + // Search indexing can lag briefly behind publish in local environments. + } + + await new Promise((resolve) => setTimeout(resolve, 300 * (attempt + 1))) + } + + throw new Error(`Timed out waiting for search results "${query}" (${Array.from(pending).join(', ')})`) + } + + async waitForPendingReview(namespaceSlug: string, skillSlug: string, version: string): Promise { + for (let attempt = 0; attempt < 20; attempt += 1) { + try { + const page = await parseEnvelope<{ + items: ReviewTaskSummary[] + }>( + await this.request.get('/api/web/reviews?status=PENDING&page=0&size=100&sortDirection=DESC'), + ) + + const matched = page.items.find((item) => + item.namespace === namespaceSlug && + item.skillSlug === skillSlug && + item.version === version && + item.status === 'PENDING', + ) + if (matched) { + return matched.id + } + } catch { + // Review list can lag behind publish very briefly. + } + + await new Promise((resolve) => setTimeout(resolve, 300 * (attempt + 1))) + } + + throw new Error(`Timed out waiting for pending review ${namespaceSlug}/${skillSlug}@${version}`) + } + + async approveReview(reviewTaskId: number, comment = 'Approved by Playwright E2E'): Promise { + await parseEnvelope( + await this.request.post(`/api/web/reviews/${reviewTaskId}/approve`, { + data: { comment }, + }), + ) + } + + async publishSkill(namespaceSlug: string, options?: SeedSkillOptions): Promise { + const unique = `${this.suffix}_${Math.random().toString(36).slice(2, 6)}` + const zipBuffer = buildSkillPackageZipBuffer(unique, options) + + const result = await parseEnvelope( + await this.request.post(`/api/web/skills/${encodeURIComponent(namespaceSlug)}/publish`, { + multipart: { + file: { + name: 'sample-skill.zip', + mimeType: 'application/zip', + buffer: zipBuffer, + }, + visibility: 'PUBLIC', + }, + }), + ) + this.cleanupTasks.push(async () => { await this.request.delete(`/api/web/skills/${encodeURIComponent(result.namespace)}/${encodeURIComponent(result.slug)}`) }) @@ -267,9 +369,9 @@ export class E2eTestDataBuilder { return result } - createSkillPackageFile(): string { + createSkillPackageFile(options?: SeedSkillOptions): string { const unique = `${this.suffix}_${Math.random().toString(36).slice(2, 6)}` - const { filePath, cleanup } = createSkillPackageZipFile(unique) + const { filePath, cleanup } = createSkillPackageZipFile(unique, options) this.cleanupTasks.push(async () => { cleanup() }) diff --git a/web/e2e/register-login-validation.spec.ts b/web/e2e/register-login-validation.spec.ts new file mode 100644 index 00000000..25e12f77 --- /dev/null +++ b/web/e2e/register-login-validation.spec.ts @@ -0,0 +1,308 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' + +// TC_UN_* 用户名输入框 / TC_EM_* 邮箱输入框 / TC_PW_* 密码输入框 +// TC_REG_* 注册/登录流程 / TC_UI_* UI/UX + +let existingRegisteredUsername: string | null = null +const DUPLICATE_USERNAME_ERROR = /already.*exist|taken|username.*used/i +const REGISTER_RATE_LIMIT_ERROR = /too many|too frequent|rate limit|请求过于频繁/ + +test.describe('Register - Username Validation (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + await page.goto('/register') + }) + + // TC_UN_008 P0 + test('TC_UN_008: shows required error when username is empty', async ({ page }) => { + await page.getByRole('button', { name: 'Register' }).click() + await expect(page.getByText(/username.*required|required.*username/i)).toBeVisible() + }) + + // TC_UN_001 P0 - valid minimum length + test('TC_UN_001: accepts valid username with minimum 3 characters', async ({ page }) => { + await page.getByLabel(/username/i).fill('abc') + await page.getByLabel(/username/i).blur() + await expect(page.getByText(/仅支持|only.*letter|username.*required/i)).not.toBeVisible() + }) + + // TC_UN_006 P1 - 2 chars below minimum + test('TC_UN_006: shows length error for 2-character username', async ({ page }) => { + await page.getByLabel(/username/i).fill('ab') + await page.getByLabel(/username/i).blur() + await expect(page.getByText(/3.{0,10}64|length|at least/i)).toBeVisible() + }) + + // TC_UN_009 P1 - special chars + test('TC_UN_009: shows error for username with special characters like @', async ({ page }) => { + await page.getByLabel(/username/i).fill('user@123') + await page.getByLabel(/username/i).blur() + await expect(page.getByText(/letter|number|underscore|alphanumeric/i)).toBeVisible() + }) + + // TC_UN_010 P1 - Chinese chars + test('TC_UN_010: shows error for username containing Chinese characters', async ({ page }) => { + await page.getByLabel(/username/i).fill('用户123') + await page.getByLabel(/username/i).blur() + await expect(page.getByText(/letter|number|underscore|alphanumeric/i)).toBeVisible() + }) +}) + +test.describe('Register - Email Validation (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + await page.goto('/register') + }) + + // TC_EM_007 P0 - email is optional + test('TC_EM_007: allows empty email (email is optional)', async ({ page }) => { + const emailField = page.getByLabel(/email/i) + if (await emailField.isVisible()) { + await emailField.clear() + await emailField.blur() + await expect(page.getByText(/email.*required/i)).not.toBeVisible() + } + }) + + // TC_EM_008 P1 - missing @ + test('TC_EM_008: shows error for email missing @ symbol', async ({ page }) => { + const emailField = page.getByLabel(/email/i) + if (await emailField.isVisible()) { + await emailField.fill('userexample.com') + await emailField.blur() + await expect(page.getByText(/email.*invalid|invalid.*email|format/i)).toBeVisible() + } + }) + + // TC_EM_009 P1 - missing domain + test('TC_EM_009: shows error for email missing domain after @', async ({ page }) => { + const emailField = page.getByLabel(/email/i) + if (await emailField.isVisible()) { + await emailField.fill('user@') + await emailField.blur() + await expect(page.getByText(/email.*invalid|invalid.*email|format/i)).toBeVisible() + } + }) +}) + +test.describe('Register - Password Validation (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + await page.goto('/register') + }) + + // TC_PW_013 P0 - empty password + test('TC_PW_013: shows required error when password is empty', async ({ page }) => { + await page.getByRole('button', { name: 'Register' }).click() + await expect(page.getByText(/password.*required|required.*password/i)).toBeVisible() + }) + + // TC_PW_007 P1 - 7 chars (below minimum 8) + test('TC_PW_007: shows length error for 7-character password', async ({ page }) => { + await page.getByLabel(/^password/i).fill('Abc123!') + await page.getByLabel(/^password/i).blur() + await expect(page.getByText(/8|at least|minimum/i)).toBeVisible() + }) + + // TC_PW_008 P1 - only 2 types (uppercase + lowercase) + test('TC_PW_008: shows complexity error for password with only 2 character types', async ({ page }) => { + await page.getByLabel(/^password/i).fill('Abcdefgh') + await page.getByLabel(/^password/i).blur() + await expect(page.getByText(/three|3.*type|character type|complexity/i)).toBeVisible() + }) + + // TC_PW_001 P0 - valid password with 3+ types + test('TC_PW_001: accepts valid password with 3 character types and minimum length', async ({ page }) => { + await page.getByLabel(/^password/i).fill('Abc123!@') + await page.getByLabel(/^password/i).blur() + await expect(page.getByText(/three|3.*type|character type|complexity/i)).not.toBeVisible() + await expect(page.getByText(/8|at least|minimum/i)).not.toBeVisible() + }) +}) + +test.describe('Register Flow (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + // TC_REG_001 P0 - successful registration with all fields + test('TC_REG_001: registers successfully with valid username, email and password', async ({ page }) => { + await page.goto('/register') + const suffix = Date.now().toString(36) + const username = `testuser_${suffix}` + await page.getByLabel(/username/i).fill(username) + const emailField = page.getByLabel(/email/i) + if (await emailField.isVisible()) { + await emailField.fill(`test_${suffix}@example.test`) + } + await page.getByLabel(/^password/i).fill('Test123!@') + await page.getByRole('button', { name: 'Register' }).click() + // Should redirect away from /register on success + await expect(page).not.toHaveURL('/register') + existingRegisteredUsername = username + }) + + // TC_REG_003 P0 - duplicate username + test('TC_REG_003: shows error when registering with existing username', async ({ page }) => { + let username = existingRegisteredUsername + if (!username) { + const suffix = Date.now().toString(36) + username = `dupuser_${suffix}` + + await page.goto('/register') + await page.getByLabel(/username/i).fill(username) + await page.getByLabel(/^password/i).fill('Test123!@') + await page.getByRole('button', { name: 'Register' }).click() + await expect(page).not.toHaveURL('/register') + existingRegisteredUsername = username + } + + // Now try to register with the same username again + await page.goto('/register') + await setEnglishLocale(page) + await page.getByLabel(/username/i).fill(username) + await page.getByLabel(/^password/i).fill('Test123!@') + + for (let attempt = 0; attempt < 3; attempt += 1) { + await page.getByRole('button', { name: 'Register' }).click() + + if (await page.getByText(DUPLICATE_USERNAME_ERROR).isVisible().catch(() => false)) { + return + } + + if (attempt < 2 && await page.getByText(REGISTER_RATE_LIMIT_ERROR).isVisible().catch(() => false)) { + await page.waitForTimeout(1_500 * (attempt + 1)) + continue + } + + break + } + + await expect(page.getByText(DUPLICATE_USERNAME_ERROR)).toBeVisible() + }) + + // TC_REG_002 P0 - registration without email + test('TC_REG_002: registers successfully without email (email is optional)', async ({ page }) => { + await page.goto('/register') + const suffix = Date.now().toString(36) + Math.random().toString(36).slice(2, 5) + await page.getByLabel(/username/i).fill(`noemail_${suffix}`) + await page.getByLabel(/^password/i).fill('Test123!@') + await page.getByRole('button', { name: 'Register' }).click() + await expect(page).not.toHaveURL('/register') + }) + + // TC_REG_005 P0 - required fields empty on submit + test('TC_REG_005: shows validation errors when submitting empty required fields', async ({ page }) => { + await page.goto('/register') + await page.getByRole('button', { name: 'Register' }).click() + await expect(page.getByText(/username.*required|required.*username/i)).toBeVisible() + await expect(page.getByText(/password.*required|required.*password/i)).toBeVisible() + }) +}) + +test.describe('Login Flow (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + // TC_REG_006 P0 - successful login (already tested in auth-entry.spec.ts partially; extend here) + test('TC_REG_006: shows required field errors when submitting empty login form', async ({ page }) => { + await page.goto('/login') + await page.getByRole('button', { name: 'Login' }).click() + await expect(page.getByText('Username is required')).toBeVisible() + await expect(page.getByText('Password is required')).toBeVisible() + }) + + // TC_REG_007 P0 - wrong password + test('TC_REG_007: shows error for wrong password on existing account', async ({ page }) => { + // First register a user, then attempt login with wrong password + const suffix = Date.now().toString(36) + const username = `logintest_${suffix}` + + await page.goto('/register') + await page.getByLabel(/username/i).fill(username) + await page.getByLabel(/^password/i).fill('Test123!@') + await page.getByRole('button', { name: 'Register' }).click() + await expect(page).not.toHaveURL('/register') + + await page.goto('/login') + await setEnglishLocale(page) + await page.getByLabel(/username/i).fill(username) + await page.getByLabel(/^password/i).fill('WrongPassword999!') + await page.getByRole('button', { name: 'Login' }).click() + await expect(page.getByText(/invalid|incorrect|wrong|username.*password/i)).toBeVisible() + }) + + // TC_REG_008 P0 - non-existent username + test('TC_REG_008: shows error for non-existent username login attempt', async ({ page }) => { + await page.goto('/login') + await page.getByLabel(/username/i).fill('nonexistent_user_xyz99999') + await page.getByLabel(/^password/i).fill('Test123!@') + await page.getByRole('button', { name: 'Login' }).click() + await expect(page.getByText(/invalid|incorrect|wrong|username.*password|not found/i)).toBeVisible() + }) + + // TC_REG_010 P1 - SQL injection safety + test('TC_REG_010: safely handles SQL injection input in username field', async ({ page }) => { + await page.goto('/login') + await page.getByLabel(/username/i).fill("admin' OR '1'='1") + await page.getByLabel(/^password/i).fill('anything') + await page.getByRole('button', { name: 'Login' }).click() + // Should not log in; should show error or validation message, NOT redirect to dashboard + await expect(page).not.toHaveURL('/dashboard') + }) + + // TC_REG_011 P1 - XSS in input + test('TC_REG_011: safely handles XSS payload in username field without executing script', async ({ page }) => { + let alerted = false + page.on('dialog', () => { alerted = true }) + + await page.goto('/login') + await page.getByLabel(/username/i).fill("") + await page.getByLabel(/^password/i).fill('anything') + await page.getByRole('button', { name: 'Login' }).click() + await expect(page).not.toHaveURL('/dashboard') + expect(alerted).toBe(false) + }) +}) + +test.describe('Register/Login UI (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + // TC_UI_003 P2 - password visibility toggle + test('TC_UI_003: password visibility toggle switches between masked and plain text', async ({ page }) => { + await page.goto('/register') + const passwordInput = page.getByLabel(/^password/i) + await expect(passwordInput).toHaveAttribute('type', 'password') + + const toggleBtn = page.getByRole('button', { name: /show|hide|toggle/i }) + .or(page.locator('[data-testid*="password-toggle"], [aria-label*="password"]')) + if (await toggleBtn.isVisible()) { + await toggleBtn.click() + await expect(passwordInput).toHaveAttribute('type', 'text') + } + }) + + // TC_UI_005 P2 - Enter key submits form + test('TC_UI_005: pressing Enter in the last input field submits the login form', async ({ page }) => { + await page.goto('/login') + await page.getByLabel(/username/i).fill('someuser') + await page.getByLabel(/^password/i).fill('SomePass123!') + await page.getByLabel(/^password/i).press('Enter') + // Form should attempt submission (either error msg or redirect) + await expect( + page.getByText(/invalid|incorrect|dashboard/i) + .or(page.locator('[role="alert"]')) + ).toBeVisible({ timeout: 5000 }) + }) + + // returnTo param preservation (from auth-entry.spec.ts - extended) + test('preserves returnTo param when navigating from register link on login page', async ({ page }) => { + await page.goto('/login?returnTo=%2Fdashboard%2Ftokens') + await page.getByRole('link', { name: /sign up|register/i }).click() + await expect(page).toHaveURL('/register?returnTo=%2Fdashboard%2Ftokens') + }) +}) diff --git a/web/e2e/search-card-interaction.spec.ts b/web/e2e/search-card-interaction.spec.ts new file mode 100644 index 00000000..3fca443a --- /dev/null +++ b/web/e2e/search-card-interaction.spec.ts @@ -0,0 +1,438 @@ +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 + + for (let attempt = 0; attempt < 4; attempt += 1) { + await page.waitForLoadState('networkidle') + await expect(page.getByRole('textbox', { name: 'Search skills...' })).toBeVisible({ timeout: 8_000 }) + + if (await cards.count() > 0) { + return cards + } + + if (attempt < 3) { + const responsePromise = encodedKeyword + ? 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: 12_000 }).catch(() => null) + : Promise.resolve(null) + + await page.waitForTimeout(750 * (attempt + 1)) + await page.reload({ waitUntil: 'networkidle' }) + await responsePromise + } + } + + 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 }) + }) +}) diff --git a/web/e2e/search-page-full.spec.ts b/web/e2e/search-page-full.spec.ts new file mode 100644 index 00000000..f5775bfb --- /dev/null +++ b/web/e2e/search-page-full.spec.ts @@ -0,0 +1,324 @@ +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 guidance + test('TC_SEARCH_INPUT_003: empty search shows keyword guidance instead of a default list', async ({ page }) => { + await page.goto(searchUrl('')) + await expect(page).toHaveURL(/\/search/) + await expect(page.getByRole('heading', { name: 'No results found' })).toBeVisible() + await expect(page.getByText('Please enter a search keyword')).toBeVisible() + }) + + // 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) + 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("") + 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) + }) +})