From e9921c4ed063267b668b86c5a8b0009e8be674f9 Mon Sep 17 00:00:00 2001 From: huishi3 Date: Thu, 9 Apr 2026 09:07:01 +0530 Subject: [PATCH 1/8] fix(web): map register API errors to field messages --- web/src/pages/register.tsx | 184 +++++++++++++++++++++++++++++++++++-- 1 file changed, 175 insertions(+), 9 deletions(-) diff --git a/web/src/pages/register.tsx b/web/src/pages/register.tsx index 93d67803..de6b8cf6 100644 --- a/web/src/pages/register.tsx +++ b/web/src/pages/register.tsx @@ -1,6 +1,7 @@ import { Link, useNavigate, useSearch } from '@tanstack/react-router' import { useState } from 'react' import { useTranslation } from 'react-i18next' +import { ApiError } from '@/api/client' import { LoginButton } from '@/features/auth/login-button' import { useLocalRegister } from '@/features/auth/use-local-auth' import { Button } from '@/shared/ui/button' @@ -8,6 +9,44 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/sha import { Input } from '@/shared/ui/input' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' +const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,64}$/ +const EMAIL_PATTERN = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/ + +type RegisterFieldErrors = { + username?: string + email?: string + password?: string +} + +function countPasswordCharacterTypes(password: string) { + let typeCount = 0 + if (/[a-z]/.test(password)) { + typeCount += 1 + } + if (/[A-Z]/.test(password)) { + typeCount += 1 + } + if (/\d/.test(password)) { + typeCount += 1 + } + if (/[^A-Za-z0-9]/.test(password)) { + typeCount += 1 + } + return typeCount +} + +function isDuplicateUsernameError(errorKey: string) { + return errorKey === 'error.auth.local.username.exists' + || errorKey.includes('Username already exists') + || errorKey.includes('用户名已存在') +} + +function isDuplicateEmailError(errorKey: string) { + return errorKey === 'error.auth.local.email.exists' + || errorKey.includes('Email already exists') + || errorKey.includes('邮箱已存在') +} + /** * Registration page for local accounts with an alternate OAuth-based entry path. */ @@ -19,16 +58,109 @@ export function RegisterPage() { const [username, setUsername] = useState('') const [email, setEmail] = useState('') const [password, setPassword] = useState('') + const [fieldErrors, setFieldErrors] = useState({}) + const [formError, setFormError] = useState(null) const returnTo = search.returnTo && search.returnTo.startsWith('/') ? search.returnTo : '/dashboard' + function validateUsername(value: string) { + const trimmed = value.trim() + if (!trimmed) { + return t('register.usernameRequired') + } + if (!USERNAME_PATTERN.test(trimmed)) { + return t('register.usernameInvalid') + } + return undefined + } + + function validateEmail(value: string) { + const trimmed = value.trim().toLowerCase() + if (!trimmed) { + return undefined + } + if (!EMAIL_PATTERN.test(trimmed)) { + return t('register.emailInvalid') + } + return undefined + } + + function validatePassword(value: string) { + if (!value) { + return t('register.passwordRequired') + } + if (value.length < 8) { + return t('register.passwordTooShort') + } + if (countPasswordCharacterTypes(value) < 3) { + return t('register.passwordTooWeak') + } + return undefined + } + + function mapRegisterApiError(error: unknown): { fieldErrors?: RegisterFieldErrors, formError?: string } { + if (!(error instanceof ApiError)) { + return { + formError: error instanceof Error ? error.message : t('apiError.unknown'), + } + } + + const errorKey = error.serverMessageKey ?? error.serverMessage ?? error.message + + switch (errorKey) { + case 'validation.auth.local.username.notBlank': + return { fieldErrors: { username: t('register.usernameRequired') } } + case 'validation.auth.local.password.notBlank': + return { fieldErrors: { password: t('register.passwordRequired') } } + case 'validation.auth.local.email.invalid': + return { fieldErrors: { email: t('register.emailInvalid') } } + case 'error.auth.local.username.invalid': + return { fieldErrors: { username: t('register.usernameInvalid') } } + case 'error.auth.local.password.tooShort': + return { fieldErrors: { password: t('register.passwordTooShort') } } + case 'error.auth.local.password.tooWeak': + return { fieldErrors: { password: t('register.passwordTooWeak') } } + case 'error.auth.local.username.exists': + return { fieldErrors: { username: t('register.usernameExists') } } + case 'error.auth.local.email.exists': + return { fieldErrors: { email: t('register.emailExists') } } + default: + if (isDuplicateUsernameError(errorKey)) { + return { fieldErrors: { username: t('register.usernameExists') } } + } + if (isDuplicateEmailError(errorKey)) { + return { fieldErrors: { email: t('register.emailExists') } } + } + return { formError: error.serverMessage || error.message || t('apiError.unknown') } + } + } + async function handleSubmit(event: React.FormEvent) { event.preventDefault() + const trimmedUsername = username.trim() + const trimmedEmail = email.trim().toLowerCase() + const nextFieldErrors: RegisterFieldErrors = {} + + nextFieldErrors.username = validateUsername(username) + nextFieldErrors.email = validateEmail(email) + nextFieldErrors.password = validatePassword(password) + + if (nextFieldErrors.username || nextFieldErrors.email || nextFieldErrors.password) { + setFieldErrors(nextFieldErrors) + setFormError(null) + registerMutation.reset() + return + } + + setFieldErrors({}) + setFormError(null) try { - await registerMutation.mutateAsync({ username, email, password }) + await registerMutation.mutateAsync({ username: trimmedUsername, email: trimmedEmail, password }) await navigate({ to: returnTo }) - } catch { - // mutation state drives the error UI + } catch (error) { + const { fieldErrors: nextApiFieldErrors, formError: nextFormError } = mapRegisterApiError(error) + setFieldErrors(nextApiFieldErrors ?? {}) + setFormError(nextFormError ?? null) } } @@ -54,9 +186,21 @@ export function RegisterPage() { id="register-username" autoComplete="username" value={username} - onChange={(event) => setUsername(event.target.value)} + onChange={(event) => { + setUsername(event.target.value) + if (fieldErrors.username || formError) { + setFieldErrors((current) => ({ ...current, username: undefined })) + setFormError(null) + registerMutation.reset() + } + }} placeholder={t('register.usernamePlaceholder')} + aria-invalid={fieldErrors.username ? 'true' : 'false'} + onBlur={() => { + setFieldErrors((current) => ({ ...current, username: validateUsername(username) })) + }} /> + {fieldErrors.username ?

{fieldErrors.username}

: null}
@@ -65,9 +209,21 @@ export function RegisterPage() { type="email" autoComplete="email" value={email} - onChange={(event) => setEmail(event.target.value)} + onChange={(event) => { + setEmail(event.target.value) + if (fieldErrors.email || formError) { + setFieldErrors((current) => ({ ...current, email: undefined })) + setFormError(null) + registerMutation.reset() + } + }} placeholder={t('register.emailPlaceholder')} + aria-invalid={fieldErrors.email ? 'true' : 'false'} + onBlur={() => { + setFieldErrors((current) => ({ ...current, email: validateEmail(email) })) + }} /> + {fieldErrors.email ?

{fieldErrors.email}

: null}
@@ -76,13 +232,23 @@ export function RegisterPage() { type="password" autoComplete="new-password" value={password} - onChange={(event) => setPassword(event.target.value)} + onChange={(event) => { + setPassword(event.target.value) + if (fieldErrors.password || formError) { + setFieldErrors((current) => ({ ...current, password: undefined })) + setFormError(null) + registerMutation.reset() + } + }} placeholder={t('register.passwordPlaceholder')} + aria-invalid={fieldErrors.password ? 'true' : 'false'} + onBlur={() => { + setFieldErrors((current) => ({ ...current, password: validatePassword(password) })) + }} /> + {fieldErrors.password ?

{fieldErrors.password}

: null}
- {registerMutation.error ? ( -

{registerMutation.error.message}

- ) : null} + {formError ?

{formError}

: null} From 2f6481b1ab97b82fd4a0d95e1978ed4c0e7fc4a4 Mon Sep 17 00:00:00 2001 From: huishi3 Date: Thu, 9 Apr 2026 09:17:41 +0530 Subject: [PATCH 2/8] feat(i18n): add register validation messages --- web/src/i18n/locales/en.json | 8 ++++++++ web/src/i18n/locales/zh.json | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index b1e35669..04307172 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -234,6 +234,14 @@ "usernamePlaceholder": "3-64 characters: letters, numbers, or underscores", "emailPlaceholder": "Optional, for account identification", "passwordPlaceholder": "At least 8 characters with 3 character types", + "usernameRequired": "Username is required", + "usernameInvalid": "Username must be 3-64 characters and contain only letters, numbers, or underscores", + "emailInvalid": "Email format is invalid", + "passwordRequired": "Password is required", + "passwordTooShort": "Password must be at least 8 characters", + "passwordTooWeak": "Password must include at least 3 character types", + "usernameExists": "Username already exists", + "emailExists": "Email already exists", "submitting": "Registering...", "submit": "Register & Login", "hasAccount": "Already have an account?", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 41972c55..817a8f04 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -234,6 +234,14 @@ "usernamePlaceholder": "3-64 位字母、数字或下划线", "emailPlaceholder": "可选,用于后续账号识别", "passwordPlaceholder": "至少 8 位,包含 3 种字符类型", + "usernameRequired": "请输入用户名", + "usernameInvalid": "用户名需为 3-64 位,且只能包含字母、数字或下划线", + "emailInvalid": "邮箱格式不正确", + "passwordRequired": "请输入密码", + "passwordTooShort": "密码至少需要 8 位", + "passwordTooWeak": "密码至少需要包含 3 种字符类型", + "usernameExists": "用户名已存在", + "emailExists": "邮箱已存在", "submitting": "注册中...", "submit": "注册并登录", "hasAccount": "已有账号?", From 21679813922349bdb6f1c28d9f711c51f0abf3e4 Mon Sep 17 00:00:00 2001 From: huishi3 Date: Thu, 9 Apr 2026 09:21:29 +0530 Subject: [PATCH 3/8] fix(review): sync approval state before returning tasks --- .../com/iflytek/skillhub/TestRedisConfig.java | 83 +++++++- ...ApprovalVisibilityFlowIntegrationTest.java | 184 ++++++++++++++++++ .../domain/review/PromotionService.java | 24 ++- .../skillhub/domain/review/ReviewService.java | 23 ++- .../domain/review/PromotionServiceTest.java | 28 +-- .../domain/review/ReviewServiceTest.java | 14 +- 6 files changed, 333 insertions(+), 23 deletions(-) create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillApprovalVisibilityFlowIntegrationTest.java diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/TestRedisConfig.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/TestRedisConfig.java index 2070d360..24087c0d 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/TestRedisConfig.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/TestRedisConfig.java @@ -1,5 +1,9 @@ package com.iflytek.skillhub; +import java.time.Instant; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; import org.mockito.Mockito; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -7,6 +11,13 @@ import org.springframework.context.annotation.Primary; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.when; @Configuration public class TestRedisConfig { @@ -27,6 +38,76 @@ public class TestRedisConfig { @Bean @Primary public StringRedisTemplate stringRedisTemplate() { - return Mockito.mock(StringRedisTemplate.class); + StringRedisTemplate template = Mockito.mock(StringRedisTemplate.class); + @SuppressWarnings("unchecked") + ValueOperations valueOps = Mockito.mock(ValueOperations.class); + Map values = new ConcurrentHashMap<>(); + Map expirations = new ConcurrentHashMap<>(); + + when(template.opsForValue()).thenReturn(valueOps); + + when(valueOps.get(anyString())).thenAnswer(invocation -> { + String key = invocation.getArgument(0, String.class); + evictExpired(values, expirations, key); + return values.get(key); + }); + + when(valueOps.increment(anyString())).thenAnswer(invocation -> { + String key = invocation.getArgument(0, String.class); + evictExpired(values, expirations, key); + long next = Long.parseLong(values.getOrDefault(key, "0")) + 1L; + values.put(key, Long.toString(next)); + return next; + }); + + doAnswer(invocation -> { + String key = invocation.getArgument(0, String.class); + String value = invocation.getArgument(1, String.class); + Long timeout = invocation.getArgument(2, Long.class); + TimeUnit unit = invocation.getArgument(3, TimeUnit.class); + values.put(key, value); + expirations.put(key, Instant.now().plusMillis(unit.toMillis(timeout))); + return null; + }).when(valueOps).set(anyString(), anyString(), anyLong(), any(TimeUnit.class)); + + when(template.delete(anyString())).thenAnswer(invocation -> { + String key = invocation.getArgument(0, String.class); + boolean removed = values.remove(key) != null; + expirations.remove(key); + return removed; + }); + + when(template.expire(anyString(), any())).thenAnswer(invocation -> { + String key = invocation.getArgument(0, String.class); + java.time.Duration ttl = invocation.getArgument(1, java.time.Duration.class); + if (!values.containsKey(key)) { + return false; + } + expirations.put(key, Instant.now().plus(ttl)); + return true; + }); + + when(template.getExpire(anyString())).thenAnswer(invocation -> { + String key = invocation.getArgument(0, String.class); + evictExpired(values, expirations, key); + Instant expiresAt = expirations.get(key); + if (expiresAt == null) { + return -1L; + } + long seconds = java.time.Duration.between(Instant.now(), expiresAt).getSeconds(); + return Math.max(seconds, -1L); + }); + + return template; + } + + private static void evictExpired(Map values, + Map expirations, + String key) { + Instant expiresAt = expirations.get(key); + if (expiresAt != null && expiresAt.isBefore(Instant.now())) { + values.remove(key); + expirations.remove(key); + } } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillApprovalVisibilityFlowIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillApprovalVisibilityFlowIntegrationTest.java new file mode 100644 index 00000000..431223a8 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillApprovalVisibilityFlowIntegrationTest.java @@ -0,0 +1,184 @@ +package com.iflytek.skillhub.controller.portal; + +import com.iflytek.skillhub.SkillhubApplication; +import com.iflytek.skillhub.TestRedisConfig; +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.rbac.RbacService; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceType; +import com.iflytek.skillhub.domain.review.ReviewTask; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.infra.jpa.ReviewTaskJpaRepository; +import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentEntity; +import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentJpaRepository; +import com.iflytek.skillhub.search.SearchEmbeddingService; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(classes = SkillhubApplication.class) +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import(TestRedisConfig.class) +class SkillApprovalVisibilityFlowIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private NamespaceRepository namespaceRepository; + + @Autowired + private SkillRepository skillRepository; + + @Autowired + private SkillVersionRepository skillVersionRepository; + + @Autowired + private ReviewTaskJpaRepository reviewTaskJpaRepository; + + @Autowired + private SkillSearchDocumentJpaRepository skillSearchDocumentJpaRepository; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @MockBean + private SearchEmbeddingService searchEmbeddingService; + + @MockBean + private RbacService rbacService; + + @BeforeEach + void setUp() { + when(searchEmbeddingService.embed(anyString())).thenReturn(""); + when(searchEmbeddingService.similarity(anyString(), anyString())).thenReturn(0.0d); + when(rbacService.getUserRoleCodes("super-1")).thenReturn(Set.of("SUPER_ADMIN")); + } + + @Test + void approveReview_indexesGlobalSkillOnlyAfterApproval() throws Exception { + PendingSkillGraph graph = createPendingGlobalSkill("local-user"); + + assertThat(skillSearchDocumentJpaRepository.findBySkillId(graph.skill().getId())).isEmpty(); + assertThat(skillRepository.findById(graph.skill().getId())).get().extracting(Skill::getLatestVersionId).isNull(); + assertThat(skillVersionRepository.findById(graph.version().getId())).get() + .extracting(SkillVersion::getStatus) + .isEqualTo(SkillVersionStatus.PENDING_REVIEW); + + mockMvc.perform(post("/api/v1/reviews/" + graph.reviewTask().getId() + "/approve") + .contentType("application/json") + .content("{\"comment\":\"ship it\"}") + .with(authentication(apiAuth("super-1", "SUPER_ADMIN"))) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.id").value(graph.reviewTask().getId())) + .andExpect(jsonPath("$.data.status").value("APPROVED")) + .andExpect(jsonPath("$.data.reviewedBy").value("super-1")) + .andExpect(jsonPath("$.data.reviewComment").value("ship it")); + + Skill savedSkill = skillRepository.findById(graph.skill().getId()).orElseThrow(); + SkillVersion savedVersion = skillVersionRepository.findById(graph.version().getId()).orElseThrow(); + + assertThat(savedSkill.getLatestVersionId()).isEqualTo(graph.version().getId()); + assertThat(savedVersion.getStatus()).isEqualTo(SkillVersionStatus.PUBLISHED); + assertThat(savedVersion.getPublishedAt()).isNotNull(); + + SkillSearchDocumentEntity indexedDocument = awaitIndexedDocument(graph.skill().getId()); + assertThat(indexedDocument.getSkillId()).isEqualTo(graph.skill().getId()); + assertThat(indexedDocument.getNamespaceId()).isEqualTo(graph.namespace().getId()); + assertThat(indexedDocument.getNamespaceSlug()).isEqualTo(graph.namespace().getSlug()); + assertThat(indexedDocument.getVisibility()).isEqualTo("PUBLIC"); + assertThat(indexedDocument.getStatus()).isEqualTo("ACTIVE"); + assertThat(indexedDocument.getTitle()).isEqualTo(graph.skill().getDisplayName()); + } + + private PendingSkillGraph createPendingGlobalSkill(String ownerId) { + String suffix = UUID.randomUUID().toString().substring(0, 8); + + Namespace namespace = new Namespace("global-approval-" + suffix, "Global Approval " + suffix, "system"); + namespace.setType(NamespaceType.GLOBAL); + namespace = namespaceRepository.save(namespace); + + Skill skill = new Skill(namespace.getId(), "approval-skill-" + suffix, ownerId, SkillVisibility.PUBLIC); + skill.setDisplayName("Approval Skill " + suffix); + skill.setSummary("Visible in search only after approval."); + skill.setCreatedBy(ownerId); + skill.setUpdatedBy(ownerId); + skill = skillRepository.save(skill); + skillRepository.flush(); + + SkillVersion version = new SkillVersion(skill.getId(), "1.0.0", ownerId); + version.setStatus(SkillVersionStatus.PENDING_REVIEW); + version.setRequestedVisibility(SkillVisibility.PUBLIC); + version = skillVersionRepository.save(version); + skillVersionRepository.flush(); + + ReviewTask reviewTask = reviewTaskJpaRepository.saveAndFlush(new ReviewTask(version.getId(), namespace.getId(), ownerId)); + + return new PendingSkillGraph(namespace, skill, version, reviewTask); + } + + private SkillSearchDocumentEntity awaitIndexedDocument(Long skillId) throws InterruptedException { + Instant deadline = Instant.now().plus(Duration.ofSeconds(5)); + Optional indexed = skillSearchDocumentJpaRepository.findBySkillId(skillId); + while (indexed.isEmpty() && Instant.now().isBefore(deadline)) { + Thread.sleep(100L); + indexed = skillSearchDocumentJpaRepository.findBySkillId(skillId); + } + return indexed.orElseThrow(() -> new AssertionError("Expected search document for skill " + skillId)); + } + + private UsernamePasswordAuthenticationToken apiAuth(String userId, String... roles) { + PlatformPrincipal principal = new PlatformPrincipal( + userId, + userId, + userId + "@example.com", + "", + "session", + Set.of(roles) + ); + List authorities = java.util.Arrays.stream(roles) + .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) + .toList(); + return new UsernamePasswordAuthenticationToken(principal, null, authorities); + } + + private record PendingSkillGraph(Namespace namespace, Skill skill, SkillVersion version, ReviewTask reviewTask) { + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java index 16f888b9..eb533b8a 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java @@ -14,6 +14,7 @@ import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import com.iflytek.skillhub.domain.skill.*; +import jakarta.persistence.EntityManager; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -43,6 +44,7 @@ public class PromotionService { private final ReviewPermissionChecker permissionChecker; private final ApplicationEventPublisher eventPublisher; private final GovernanceNotificationService governanceNotificationService; + private final EntityManager entityManager; private final Clock clock; public PromotionService(PromotionRequestRepository promotionRequestRepository, @@ -53,6 +55,7 @@ public class PromotionService { ReviewPermissionChecker permissionChecker, ApplicationEventPublisher eventPublisher, GovernanceNotificationService governanceNotificationService, + EntityManager entityManager, Clock clock) { this.promotionRequestRepository = promotionRequestRepository; this.skillRepository = skillRepository; @@ -62,6 +65,7 @@ public class PromotionService { this.permissionChecker = permissionChecker; this.eventPublisher = eventPublisher; this.governanceNotificationService = governanceNotificationService; + this.entityManager = entityManager; this.clock = clock; } @@ -193,9 +197,9 @@ public class PromotionService { if (updated == 0) { throw new ConcurrentModificationException("Promotion request was modified concurrently"); } - - PromotionRequest approvedRequest = promotionRequestRepository.findById(promotionId) - .orElseThrow(() -> new DomainNotFoundException("promotion.not_found", promotionId)); + syncPromotionRequestState(request, ReviewTaskStatus.APPROVED, reviewerId, comment); + entityManager.detach(request); + PromotionRequest approvedRequest = request; Skill sourceSkill = skillRepository.findById(approvedRequest.getSourceSkillId()) .orElseThrow(() -> new DomainNotFoundException("skill.not_found", approvedRequest.getSourceSkillId())); @@ -282,6 +286,8 @@ public class PromotionService { if (updated == 0) { throw new ConcurrentModificationException("Promotion request was modified concurrently"); } + syncPromotionRequestState(request, ReviewTaskStatus.REJECTED, reviewerId, comment); + entityManager.detach(request); eventPublisher.publishEvent(new PromotionRejectedEvent( request.getId(), request.getSourceSkillId(), reviewerId, request.getSubmittedBy(), comment)); @@ -294,7 +300,7 @@ public class PromotionService { "{\"status\":\"REJECTED\"}" ); - return promotionRequestRepository.findById(promotionId).orElse(request); + return request; } public boolean canViewPromotion(PromotionRequest request, String userId, Set platformRoles) { @@ -313,4 +319,14 @@ public class PromotionService { private Instant currentTime() { return Instant.now(clock); } + + private void syncPromotionRequestState(PromotionRequest request, + ReviewTaskStatus status, + String reviewedBy, + String comment) { + request.setStatus(status); + request.setReviewedBy(reviewedBy); + request.setReviewComment(comment); + request.setReviewedAt(currentTime()); + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java index b1c66925..bd31218a 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java @@ -20,6 +20,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata; import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; +import jakarta.persistence.EntityManager; import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; @@ -51,6 +52,7 @@ public class ReviewService { private final ObjectMapper objectMapper; private final SkillGovernanceService skillGovernanceService; private final GovernanceNotificationService governanceNotificationService; + private final EntityManager entityManager; private final Clock clock; public ReviewService(ReviewTaskRepository reviewTaskRepository, @@ -62,6 +64,7 @@ public class ReviewService { ObjectMapper objectMapper, SkillGovernanceService skillGovernanceService, GovernanceNotificationService governanceNotificationService, + EntityManager entityManager, Clock clock) { this.reviewTaskRepository = reviewTaskRepository; this.skillVersionRepository = skillVersionRepository; @@ -72,6 +75,7 @@ public class ReviewService { this.objectMapper = objectMapper; this.skillGovernanceService = skillGovernanceService; this.governanceNotificationService = governanceNotificationService; + this.entityManager = entityManager; this.clock = clock; } @@ -191,6 +195,8 @@ public class ReviewService { if (updated == 0) { throw new ConcurrentModificationException("Review task was modified concurrently"); } + syncReviewTaskState(task, ReviewTaskStatus.APPROVED, reviewerId, comment); + entityManager.detach(task); Skill skill = skillRepository.findById(skillVersion.getSkillId()) .orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId())); @@ -234,8 +240,7 @@ public class ReviewService { "{\"status\":\"APPROVED\"}" ); - // Reload to return updated state - return reviewTaskRepository.findById(reviewTaskId).orElse(task); + return task; } /** @@ -267,6 +272,8 @@ public class ReviewService { if (updated == 0) { throw new ConcurrentModificationException("Review task was modified concurrently"); } + syncReviewTaskState(task, ReviewTaskStatus.REJECTED, reviewerId, comment); + entityManager.detach(task); SkillVersion skillVersion = skillVersionRepository.findById(task.getSkillVersionId()) .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId())); @@ -286,7 +293,7 @@ public class ReviewService { "{\"status\":\"REJECTED\"}" ); - return reviewTaskRepository.findById(reviewTaskId).orElse(task); + return task; } /** @@ -358,4 +365,14 @@ public class ReviewService { private Instant currentTime() { return Instant.now(clock); } + + private void syncReviewTaskState(ReviewTask task, + ReviewTaskStatus status, + String reviewedBy, + String comment) { + task.setStatus(status); + task.setReviewedBy(reviewedBy); + task.setReviewComment(comment); + task.setReviewedAt(currentTime()); + } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java index 31695868..ea00a449 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java @@ -10,6 +10,7 @@ import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import com.iflytek.skillhub.domain.skill.*; +import jakarta.persistence.EntityManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -42,6 +43,7 @@ class PromotionServiceTest { @Mock private ReviewPermissionChecker permissionChecker; @Mock private ApplicationEventPublisher eventPublisher; @Mock private GovernanceNotificationService governanceNotificationService; + @Mock private EntityManager entityManager; private PromotionService promotionService; @@ -58,7 +60,7 @@ class PromotionServiceTest { void setUp() { promotionService = new PromotionService( promotionRequestRepository, skillRepository, skillVersionRepository, - skillFileRepository, namespaceRepository, permissionChecker, eventPublisher, governanceNotificationService, CLOCK); + skillFileRepository, namespaceRepository, permissionChecker, eventPublisher, governanceNotificationService, entityManager, CLOCK); } private static void setField(Object target, String fieldName, Object value) { @@ -391,17 +393,11 @@ class PromotionServiceTest { @Test void shouldApprovePromotionSuccessfully() { PromotionRequest pr = createPendingPromotion(); - PromotionRequest approvedPromotion = createPendingPromotion(); - setField(approvedPromotion, "status", ReviewTaskStatus.APPROVED); - setField(approvedPromotion, "version", 2); - setField(approvedPromotion, "reviewedBy", REVIEWER_ID); - setField(approvedPromotion, "reviewComment", "LGTM"); Skill sourceSkill = createSourceSkill(); SkillVersion sourceVersion = createPublishedVersion(); List sourceFiles = createSourceFiles(); - when(promotionRequestRepository.findById(PROMOTION_ID)) - .thenReturn(Optional.of(pr), Optional.of(approvedPromotion)); + when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(pr)); when(permissionChecker.canReviewPromotion(pr, REVIEWER_ID, Set.of("SKILL_ADMIN"))).thenReturn(true); when(promotionRequestRepository.updateStatusWithVersion( PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "LGTM", null, pr.getVersion())) @@ -420,12 +416,16 @@ class PromotionServiceTest { }); when(skillFileRepository.findByVersionId(SOURCE_VERSION_ID)).thenReturn(sourceFiles); when(skillFileRepository.saveAll(anyList())).thenAnswer(inv -> inv.getArgument(0)); - when(promotionRequestRepository.save(approvedPromotion)).thenReturn(approvedPromotion); + when(promotionRequestRepository.save(pr)).thenReturn(pr); PromotionRequest result = promotionService.approvePromotion( PROMOTION_ID, REVIEWER_ID, "LGTM", Set.of("SKILL_ADMIN")); assertNotNull(result); + assertEquals(ReviewTaskStatus.APPROVED, result.getStatus()); + assertEquals(REVIEWER_ID, result.getReviewedBy()); + assertEquals("LGTM", result.getReviewComment()); + assertEquals(Instant.now(CLOCK), result.getReviewedAt()); // Verify new skill created in global namespace ArgumentCaptor skillCaptor = ArgumentCaptor.forClass(Skill.class); @@ -467,8 +467,8 @@ class PromotionServiceTest { assertEquals(REVIEWER_ID, event.publisherId()); // Verify targetSkillId updated on promotion request - verify(promotionRequestRepository).save(approvedPromotion); - assertEquals(NEW_SKILL_ID, approvedPromotion.getTargetSkillId()); + verify(promotionRequestRepository).save(pr); + assertEquals(NEW_SKILL_ID, pr.getTargetSkillId()); } @Test @@ -556,12 +556,14 @@ class PromotionServiceTest { when(promotionRequestRepository.updateStatusWithVersion( PROMOTION_ID, ReviewTaskStatus.REJECTED, REVIEWER_ID, "Not ready", null, pr.getVersion())) .thenReturn(1); - when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(pr)); - PromotionRequest result = promotionService.rejectPromotion( PROMOTION_ID, REVIEWER_ID, "Not ready", Set.of("SKILL_ADMIN")); assertNotNull(result); + assertEquals(ReviewTaskStatus.REJECTED, result.getStatus()); + assertEquals(REVIEWER_ID, result.getReviewedBy()); + assertEquals("Not ready", result.getReviewComment()); + assertEquals(Instant.now(CLOCK), result.getReviewedAt()); verify(promotionRequestRepository).updateStatusWithVersion( PROMOTION_ID, ReviewTaskStatus.REJECTED, REVIEWER_ID, "Not ready", null, pr.getVersion()); verify(eventPublisher, never()).publishEvent(any()); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java index 76453d1b..90246ed1 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java @@ -18,6 +18,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata; +import jakarta.persistence.EntityManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -54,6 +55,7 @@ class ReviewServiceTest { @Mock private ApplicationEventPublisher eventPublisher; @Mock private SkillGovernanceService skillGovernanceService; @Mock private GovernanceNotificationService governanceNotificationService; + @Mock private EntityManager entityManager; private ReviewService reviewService; @@ -70,7 +72,7 @@ class ReviewServiceTest { objectMapper = new ObjectMapper(); reviewService = new ReviewService( reviewTaskRepository, skillVersionRepository, skillRepository, - namespaceRepository, permissionChecker, eventPublisher, objectMapper, skillGovernanceService, governanceNotificationService, CLOCK); + namespaceRepository, permissionChecker, eventPublisher, objectMapper, skillGovernanceService, governanceNotificationService, entityManager, CLOCK); } private SkillVersion createDraftSkillVersion() { @@ -253,6 +255,10 @@ class ReviewServiceTest { Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of()); assertNotNull(result); + assertEquals(ReviewTaskStatus.APPROVED, result.getStatus()); + assertEquals(REVIEWER_ID, result.getReviewedBy()); + assertEquals("LGTM", result.getReviewComment()); + assertEquals(Instant.now(CLOCK), result.getReviewedAt()); assertEquals(SkillVersionStatus.PUBLISHED, sv.getStatus()); assertEquals(Instant.now(CLOCK), sv.getPublishedAt()); assertEquals(SKILL_VERSION_ID, skill.getLatestVersionId()); @@ -335,9 +341,13 @@ class ReviewServiceTest { when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(createSkill())); when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); - reviewService.rejectReview(REVIEW_TASK_ID, REVIEWER_ID, "Needs work", + ReviewTask result = reviewService.rejectReview(REVIEW_TASK_ID, REVIEWER_ID, "Needs work", Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of()); + assertEquals(ReviewTaskStatus.REJECTED, result.getStatus()); + assertEquals(REVIEWER_ID, result.getReviewedBy()); + assertEquals("Needs work", result.getReviewComment()); + assertEquals(Instant.now(CLOCK), result.getReviewedAt()); verify(governanceNotificationService).notifyUser(eq(USER_ID), eq("REVIEW"), eq("REVIEW_TASK"), eq(REVIEW_TASK_ID), eq("Review rejected"), any()); } From 83b90c43348d95bebeddaf428259c4a21c8e5b0a Mon Sep 17 00:00:00 2001 From: huishi3 Date: Thu, 9 Apr 2026 09:24:39 +0530 Subject: [PATCH 4/8] fix(web): refine search empty state and card interaction --- web/src/features/skill/skill-card.tsx | 109 ++++++++++++---------- web/src/pages/search.tsx | 45 +++++++-- web/src/shared/hooks/use-skill-queries.ts | 2 +- 3 files changed, 101 insertions(+), 55 deletions(-) diff --git a/web/src/features/skill/skill-card.tsx b/web/src/features/skill/skill-card.tsx index fa0c2980..ff8fcd1b 100644 --- a/web/src/features/skill/skill-card.tsx +++ b/web/src/features/skill/skill-card.tsx @@ -21,59 +21,72 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard const { data: starStatus } = useStar(skill.id, highlightStarred && isAuthenticated) const showStarredHighlight = highlightStarred && isAuthenticated && starStatus?.starred const headlineVersion = getHeadlineVersion(skill) + const isInteractive = typeof onClick === 'function' return ( -
-
-
-

- {skill.displayName} -

-
-
- -
+ className="h-full p-5 cursor-pointer group relative overflow-hidden bg-white border shadow-sm transition-shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/70 focus-visible:ring-offset-2" + style={{ borderColor: 'hsl(var(--border-card))' }} + onClick={onClick} + onKeyDown={(event) => { + if (!isInteractive) { + return + } + + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onClick() + } + }} + role={isInteractive ? 'link' : undefined} + tabIndex={isInteractive ? 0 : undefined} + > +
+
+
+

+ {skill.displayName} +

- - {skill.summary && ( -

- {skill.summary} -

- )} - -
- {headlineVersion && ( - - v{headlineVersion.version} - - )} - - - - - {formatCompactCount(skill.downloadCount)} - - - - {skill.starCount} - - {skill.ratingAvg !== undefined && skill.ratingCount > 0 && ( - - - - - {skill.ratingAvg.toFixed(1)} - - )} +
+
- + + {skill.summary && ( +

+ {skill.summary} +

+ )} + +
+ {headlineVersion && ( + + v{headlineVersion.version} + + )} + + + + + {formatCompactCount(skill.downloadCount)} + + + + {skill.starCount} + + {skill.ratingAvg !== undefined && skill.ratingCount > 0 && ( + + + + + {skill.ratingAvg.toFixed(1)} + + )} +
+
+ ) } diff --git a/web/src/pages/search.tsx b/web/src/pages/search.tsx index 58137415..5e01a375 100644 --- a/web/src/pages/search.tsx +++ b/web/src/pages/search.tsx @@ -1,4 +1,4 @@ -import { startTransition, useEffect, useState } from 'react' +import { startTransition, useEffect, useRef, useState } from 'react' import { useNavigate, useSearch } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { Loader2 } from 'lucide-react' @@ -60,11 +60,38 @@ export function SearchPage() { const page = searchParams.page ?? 0 const starredOnly = searchParams.starredOnly ?? false const [queryInput, setQueryInput] = useState(q) + const previousPageRef = useRef(page) useEffect(() => { setQueryInput(q) }, [q]) + useEffect(() => { + if (previousPageRef.current !== page) { + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur() + } + + let secondFrame = 0 + const firstFrame = window.requestAnimationFrame(() => { + window.scrollTo({ top: 0, behavior: 'auto' }) + secondFrame = window.requestAnimationFrame(() => { + window.scrollTo({ top: 0, behavior: 'auto' }) + }) + }) + + previousPageRef.current = page + return () => { + window.cancelAnimationFrame(firstFrame) + if (secondFrame) { + window.cancelAnimationFrame(secondFrame) + } + } + } + + previousPageRef.current = page + }, [page]) + const { data, isLoading, isFetching } = useSearchSkills({ q, label: selectedLabel || undefined, @@ -79,6 +106,7 @@ export function SearchPage() { isLoading: isLoadingStarred, isFetching: isFetchingStarred, } = useMyStars(starredOnly && isAuthenticated) + const shouldShowGuidance = !starredOnly && !q && !selectedLabel useEffect(() => { // Debounce URL updates while the user is typing so query state stays shareable without @@ -117,6 +145,9 @@ export function SearchPage() { } const handlePageChange = (newPage: number) => { + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur() + } navigate({ to: '/search', search: { q, label: selectedLabel, sort, page: newPage, starredOnly } }) } @@ -154,10 +185,10 @@ export function SearchPage() { : data ? Math.ceil(data.total / data.size) : 0 - const displayItems = starredOnly ? starredPageItems : (data?.items ?? []) - const isPageLoading = starredOnly ? isLoadingStarred : isLoading - const isUpdatingResults = starredOnly ? isFetchingStarred && !isLoadingStarred : isFetching && !isLoading - const resultCount = starredOnly ? filteredStarredSkills.length : (data?.total ?? 0) + const displayItems = shouldShowGuidance ? [] : (starredOnly ? starredPageItems : (data?.items ?? [])) + const isPageLoading = shouldShowGuidance ? false : (starredOnly ? isLoadingStarred : isLoading) + const isUpdatingResults = shouldShowGuidance ? false : (starredOnly ? isFetchingStarred && !isLoadingStarred : isFetching && !isLoading) + const resultCount = shouldShowGuidance ? 0 : (starredOnly ? filteredStarredSkills.length : (data?.total ?? 0)) return (
@@ -265,7 +296,9 @@ export function SearchPage() { searchSkills(params), - enabled: params.starredOnly !== true, + enabled: params.starredOnly !== true && Boolean(params.q || params.label), }) } From 69a299e40f03bb889e0e6046eb086cee18852cc9 Mon Sep 17 00:00:00 2001 From: huishi3 Date: Thu, 9 Apr 2026 09:24:51 +0530 Subject: [PATCH 5/8] 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) + }) +}) From c25ea62ee704a4990ecdadea03a8529b842701ae Mon Sep 17 00:00:00 2001 From: huishi3 Date: Thu, 9 Apr 2026 10:25:25 +0530 Subject: [PATCH 6/8] fix(web): guard search page browser globals in tests --- web/src/pages/search.tsx | 53 ++++++++++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/web/src/pages/search.tsx b/web/src/pages/search.tsx index 5e01a375..6258ca19 100644 --- a/web/src/pages/search.tsx +++ b/web/src/pages/search.tsx @@ -18,6 +18,37 @@ import { APP_SHELL_PAGE_CLASS_NAME } from '@/app/page-shell-style' const PAGE_SIZE = 12 +function blurActiveElement() { + if (typeof document === 'undefined' || typeof HTMLElement === 'undefined') { + return + } + + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur() + } +} + +function scrollToTopOnPageChange() { + if (typeof window === 'undefined') { + return () => {} + } + + let secondFrame = 0 + const firstFrame = window.requestAnimationFrame(() => { + window.scrollTo({ top: 0, behavior: 'auto' }) + secondFrame = window.requestAnimationFrame(() => { + window.scrollTo({ top: 0, behavior: 'auto' }) + }) + }) + + return () => { + window.cancelAnimationFrame(firstFrame) + if (secondFrame) { + window.cancelAnimationFrame(secondFrame) + } + } +} + /** * Skill discovery page with synchronized URL state. * @@ -68,24 +99,12 @@ export function SearchPage() { useEffect(() => { if (previousPageRef.current !== page) { - if (document.activeElement instanceof HTMLElement) { - document.activeElement.blur() - } - - let secondFrame = 0 - const firstFrame = window.requestAnimationFrame(() => { - window.scrollTo({ top: 0, behavior: 'auto' }) - secondFrame = window.requestAnimationFrame(() => { - window.scrollTo({ top: 0, behavior: 'auto' }) - }) - }) + blurActiveElement() + const cleanupScroll = scrollToTopOnPageChange() previousPageRef.current = page return () => { - window.cancelAnimationFrame(firstFrame) - if (secondFrame) { - window.cancelAnimationFrame(secondFrame) - } + cleanupScroll() } } @@ -145,9 +164,7 @@ export function SearchPage() { } const handlePageChange = (newPage: number) => { - if (document.activeElement instanceof HTMLElement) { - document.activeElement.blur() - } + blurActiveElement() navigate({ to: '/search', search: { q, label: selectedLabel, sort, page: newPage, starredOnly } }) } From 1c9baed57186ed772983e5d90b0c608bee448fbd Mon Sep 17 00:00:00 2001 From: huishi3 Date: Thu, 9 Apr 2026 11:33:28 +0530 Subject: [PATCH 7/8] test(e2e): run real-request playwright flows with one worker --- web/playwright.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/playwright.config.ts b/web/playwright.config.ts index b7206587..00b74867 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ timeout: process.env.CI ? 90_000 : 45_000, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : 2, + workers: Number(process.env.PLAYWRIGHT_WORKERS ?? 1), reporter: 'html', use: { baseURL: 'http://localhost:3000', From 25c0a2140422e0106845b3e6ebcdf0ab360493aa Mon Sep 17 00:00:00 2001 From: tenten-shih <410538051@qq.com> Date: Thu, 9 Apr 2026 13:19:06 +0530 Subject: [PATCH 8/8] test(e2e): stabilize duplicate registration and search cards --- web/e2e/helpers/search-seed.ts | 4 ++-- web/e2e/register-login-validation.spec.ts | 25 ++++++++++++----------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/web/e2e/helpers/search-seed.ts b/web/e2e/helpers/search-seed.ts index c7112b32..f7f0ab1d 100644 --- a/web/e2e/helpers/search-seed.ts +++ b/web/e2e/helpers/search-seed.ts @@ -253,13 +253,13 @@ export async function prepareSearchSeed( } export function getSearchCard(page: Page, skillName: string): Locator { - return page.locator('.cursor-pointer.group').filter({ + return getSearchCards(page).filter({ has: page.getByRole('heading', { name: skillName, exact: true }), }).first() } export function getSearchCards(page: Page): Locator { - return page.locator('.cursor-pointer.group').filter({ + return page.getByRole('link').filter({ has: page.locator('h3'), }) } diff --git a/web/e2e/register-login-validation.spec.ts b/web/e2e/register-login-validation.spec.ts index 25e12f77..2d0c8531 100644 --- a/web/e2e/register-login-validation.spec.ts +++ b/web/e2e/register-login-validation.spec.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test' import { setEnglishLocale } from './helpers/auth-fixtures' +import { createFreshSession } from './helpers/session' // TC_UN_* 用户名输入框 / TC_EM_* 邮箱输入框 / TC_PW_* 密码输入框 // TC_REG_* 注册/登录流程 / TC_UI_* UI/UX @@ -144,18 +145,15 @@ test.describe('Register Flow (Real API)', () => { }) // TC_REG_003 P0 - duplicate username - test('TC_REG_003: shows error when registering with existing username', async ({ page }) => { + test('TC_REG_003: shows error when registering with existing username', async ({ browser, page }, testInfo) => { 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') + const seedContext = await browser.newContext() + const seedPage = await seedContext.newPage() + const seedCredentials = await createFreshSession(seedPage, testInfo) + username = seedCredentials.username existingRegisteredUsername = username + await seedContext.close() } // Now try to register with the same username again @@ -163,15 +161,18 @@ test.describe('Register Flow (Real API)', () => { await setEnglishLocale(page) await page.getByLabel(/username/i).fill(username) await page.getByLabel(/^password/i).fill('Test123!@') + const main = page.getByRole('main') + const duplicateUsernameError = main.getByText(DUPLICATE_USERNAME_ERROR).first() + const registerRateLimitError = main.getByText(REGISTER_RATE_LIMIT_ERROR).first() 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)) { + if (await duplicateUsernameError.isVisible().catch(() => false)) { return } - if (attempt < 2 && await page.getByText(REGISTER_RATE_LIMIT_ERROR).isVisible().catch(() => false)) { + if (attempt < 2 && await registerRateLimitError.isVisible().catch(() => false)) { await page.waitForTimeout(1_500 * (attempt + 1)) continue } @@ -179,7 +180,7 @@ test.describe('Register Flow (Real API)', () => { break } - await expect(page.getByText(DUPLICATE_USERNAME_ERROR)).toBeVisible() + await expect(duplicateUsernameError).toBeVisible() }) // TC_REG_002 P0 - registration without email