mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-07 02:57:51 +00:00
test(e2e): add auth validation and search coverage
This commit is contained in:
parent
83b90c4334
commit
69a299e40f
6 changed files with 1722 additions and 56 deletions
265
web/e2e/helpers/search-seed.ts
Normal file
265
web/e2e/helpers/search-seed.ts
Normal file
|
|
@ -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<void>
|
||||
}
|
||||
|
||||
interface PublisherSession {
|
||||
builder: E2eTestDataBuilder
|
||||
context: Awaited<ReturnType<Browser['newContext']>>
|
||||
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<PublisherSession> {
|
||||
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<PublisherSession> {
|
||||
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<SearchSeedContext> {
|
||||
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<PreparedSearchSeed> {
|
||||
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'),
|
||||
})
|
||||
}
|
||||
|
|
@ -2,6 +2,29 @@ import { expect, type Page, type TestInfo } from '@playwright/test'
|
|||
|
||||
const password = 'Passw0rd!123'
|
||||
const cachedUserByWorker = new Map<number, string>()
|
||||
const cachedSessionByAccount = new Map<string, SessionSnapshot>()
|
||||
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<number, SessionSnapshot>()
|
||||
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<SessionSnapshot | null> {
|
||||
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<SessionSnapshot | null> {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<T> {
|
||||
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<SeededSkill> {
|
||||
const unique = `${this.suffix}_${Math.random().toString(36).slice(2, 6)}`
|
||||
const zipBuffer = buildSkillPackageZipBuffer(unique)
|
||||
async waitForSearchResult(query: string, expectedSlug?: string): Promise<void> {
|
||||
const encodedQuery = encodeURIComponent(query)
|
||||
|
||||
let result: SeededSkill
|
||||
try {
|
||||
result = await parseEnvelope<SeededSkill>(
|
||||
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<void> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
await parseEnvelope<ReviewTaskSummary>(
|
||||
await this.request.post(`/api/web/reviews/${reviewTaskId}/approve`, {
|
||||
data: { comment },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async publishSkill(namespaceSlug: string, options?: SeedSkillOptions): Promise<SeededSkill> {
|
||||
const unique = `${this.suffix}_${Math.random().toString(36).slice(2, 6)}`
|
||||
const zipBuffer = buildSkillPackageZipBuffer(unique, options)
|
||||
|
||||
const result = await parseEnvelope<SeededSkill>(
|
||||
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()
|
||||
})
|
||||
|
|
|
|||
308
web/e2e/register-login-validation.spec.ts
Normal file
308
web/e2e/register-login-validation.spec.ts
Normal file
|
|
@ -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("<script>alert('xss')</script>")
|
||||
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')
|
||||
})
|
||||
})
|
||||
438
web/e2e/search-card-interaction.spec.ts
Normal file
438
web/e2e/search-card-interaction.spec.ts
Normal file
|
|
@ -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<unknown> } }
|
||||
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 })
|
||||
})
|
||||
})
|
||||
324
web/e2e/search-page-full.spec.ts
Normal file
324
web/e2e/search-page-full.spec.ts
Normal file
|
|
@ -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("<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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue