mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-14 23:21:08 +00:00
Merge pull request #269 from iflytek/pr/exclude-playwright-report
fix register validation, review visibility, and search e2e coverage
This commit is contained in:
commit
77e271f24c
19 changed files with 2366 additions and 144 deletions
|
|
@ -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<String, String> valueOps = Mockito.mock(ValueOperations.class);
|
||||
Map<String, String> values = new ConcurrentHashMap<>();
|
||||
Map<String, Instant> 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<String, String> values,
|
||||
Map<String, Instant> expirations,
|
||||
String key) {
|
||||
Instant expiresAt = expirations.get(key);
|
||||
if (expiresAt != null && expiresAt.isBefore(Instant.now())) {
|
||||
values.remove(key);
|
||||
expirations.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SkillSearchDocumentEntity> 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<SimpleGrantedAuthority> 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) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String> 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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SkillFile> 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<Skill> 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());
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
265
web/e2e/helpers/search-seed.ts
Normal file
265
web/e2e/helpers/search-seed.ts
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
import type { Browser, Locator, Page, TestInfo } from '@playwright/test'
|
||||
import { createFreshSession, loginWithCredentials, registerSession } from './session'
|
||||
import { E2eTestDataBuilder, type SeededNamespace, type SeededSkill } from './test-data-builder'
|
||||
|
||||
export const DEFAULT_SEARCH_KEYWORD = 'agent'
|
||||
|
||||
export interface SearchSeedContext {
|
||||
builder: E2eTestDataBuilder
|
||||
keyword: string
|
||||
namespace: SeededNamespace
|
||||
skills: SeededSkill[]
|
||||
skillNames: string[]
|
||||
}
|
||||
|
||||
export interface PreparedSearchSeed extends SearchSeedContext {
|
||||
dispose: () => Promise<void>
|
||||
}
|
||||
|
||||
interface PublisherSession {
|
||||
builder: E2eTestDataBuilder
|
||||
context: Awaited<ReturnType<Browser['newContext']>>
|
||||
namespace: SeededNamespace
|
||||
page: Page
|
||||
}
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const value = process.env[name]
|
||||
if (!value) {
|
||||
throw new Error(`Missing required E2E env: ${name}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function getOptionalEnv(name: string): string | undefined {
|
||||
const value = process.env[name]?.trim()
|
||||
return value ? value : undefined
|
||||
}
|
||||
|
||||
function publisherCredentials() {
|
||||
return {
|
||||
username: requireEnv('E2E_PUBLISH_USERNAME'),
|
||||
password: requireEnv('E2E_PUBLISH_PASSWORD'),
|
||||
}
|
||||
}
|
||||
|
||||
function adminCredentials() {
|
||||
return {
|
||||
username: getOptionalEnv('E2E_ADMIN_USERNAME') ?? getOptionalEnv('BOOTSTRAP_ADMIN_USERNAME') ?? 'admin',
|
||||
password: getOptionalEnv('E2E_ADMIN_PASSWORD') ?? getOptionalEnv('BOOTSTRAP_ADMIN_PASSWORD') ?? 'ChangeMe!2026',
|
||||
}
|
||||
}
|
||||
|
||||
function hasPublisherCredentials() {
|
||||
return Boolean(getOptionalEnv('E2E_PUBLISH_USERNAME') && getOptionalEnv('E2E_PUBLISH_PASSWORD'))
|
||||
}
|
||||
|
||||
async function openProvidedPublisherSession(browser: Browser, testInfo: TestInfo): Promise<PublisherSession> {
|
||||
const context = await browser.newContext()
|
||||
const page = await context.newPage()
|
||||
const builder = new E2eTestDataBuilder(page, testInfo)
|
||||
|
||||
await loginWithCredentials(page, publisherCredentials(), testInfo)
|
||||
await builder.init()
|
||||
|
||||
return {
|
||||
builder,
|
||||
context,
|
||||
namespace: await builder.ensureWritableNamespace(),
|
||||
page,
|
||||
}
|
||||
}
|
||||
|
||||
async function openAdhocPublisherSession(browser: Browser, testInfo: TestInfo): Promise<PublisherSession> {
|
||||
const context = await browser.newContext()
|
||||
const page = await context.newPage()
|
||||
const builder = new E2eTestDataBuilder(page, testInfo)
|
||||
|
||||
try {
|
||||
await createFreshSession(page, testInfo)
|
||||
} catch {
|
||||
// Fall back to a regular worker session when transient registration issues happen
|
||||
// after Playwright restarts the worker following an earlier test failure.
|
||||
await registerSession(page, testInfo)
|
||||
}
|
||||
await builder.init()
|
||||
|
||||
return {
|
||||
builder,
|
||||
context,
|
||||
namespace: await builder.ensureWritableNamespace(),
|
||||
page,
|
||||
}
|
||||
}
|
||||
|
||||
async function publishSearchSkillsChunk(
|
||||
session: PublisherSession,
|
||||
keyword: string,
|
||||
description: string,
|
||||
seedSuffix: string,
|
||||
startIndex: number,
|
||||
count: number,
|
||||
) {
|
||||
const skills: SeededSkill[] = []
|
||||
const skillNames: string[] = []
|
||||
|
||||
for (let offset = 0; offset < count; offset += 1) {
|
||||
const skillIndex = startIndex + offset + 1
|
||||
const skillName = `${keyword}-search-${skillIndex}-${seedSuffix}`.slice(0, 48)
|
||||
const skill = await session.builder.publishSkill(session.namespace.slug, {
|
||||
name: skillName,
|
||||
description,
|
||||
})
|
||||
skills.push(skill)
|
||||
skillNames.push(skillName)
|
||||
}
|
||||
|
||||
return { skillNames, skills }
|
||||
}
|
||||
|
||||
export async function seedPublicSearchSkills(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
options?: {
|
||||
awaitSearchIndexed?: boolean
|
||||
count?: number
|
||||
keyword?: string
|
||||
description?: string
|
||||
},
|
||||
): Promise<SearchSeedContext> {
|
||||
const count = options?.count ?? 1
|
||||
const builder = new E2eTestDataBuilder(page, testInfo)
|
||||
const seedSuffix = `${testInfo.parallelIndex ?? 0}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
|
||||
const keyword = options?.keyword || `agent-${seedSuffix}`.slice(0, 32)
|
||||
|
||||
await loginWithCredentials(page, publisherCredentials(), testInfo)
|
||||
await builder.init()
|
||||
|
||||
const namespace = await builder.ensureWritableNamespace()
|
||||
const skills: SeededSkill[] = []
|
||||
const skillNames: string[] = []
|
||||
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const skillName = `${keyword}-search-${index + 1}-${seedSuffix}`.slice(0, 48)
|
||||
const skill = await builder.publishSkill(namespace.slug, {
|
||||
name: skillName,
|
||||
description: options?.description || `Searchable ${keyword} skill ${index + 1} for Playwright E2E coverage.`,
|
||||
})
|
||||
skills.push(skill)
|
||||
skillNames.push(skillName)
|
||||
}
|
||||
|
||||
if (options?.awaitSearchIndexed ?? true) {
|
||||
await builder.waitForSearchResults(keyword, skills.map((skill) => skill.slug))
|
||||
}
|
||||
|
||||
return {
|
||||
builder,
|
||||
keyword,
|
||||
namespace,
|
||||
skills,
|
||||
skillNames,
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanupSearchSeed(seed?: SearchSeedContext) {
|
||||
if (seed) {
|
||||
await seed.builder.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareSearchSeed(
|
||||
browser: Browser,
|
||||
testInfo: TestInfo,
|
||||
options?: {
|
||||
awaitSearchIndexed?: boolean
|
||||
count?: number
|
||||
keyword?: string
|
||||
description?: string
|
||||
},
|
||||
): Promise<PreparedSearchSeed> {
|
||||
const count = options?.count ?? 1
|
||||
const seedSuffix = `${testInfo.parallelIndex ?? 0}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
|
||||
const keyword = options?.keyword || `agent-${seedSuffix}`.slice(0, 32)
|
||||
const description = options?.description || `Searchable ${keyword} skill for Playwright E2E coverage.`
|
||||
const useProvidedPublisher = count <= 3 && hasPublisherCredentials()
|
||||
const publisherSessions: PublisherSession[] = [
|
||||
useProvidedPublisher
|
||||
? await openProvidedPublisherSession(browser, testInfo)
|
||||
: await openAdhocPublisherSession(browser, testInfo),
|
||||
]
|
||||
const skills: SeededSkill[] = []
|
||||
const skillNames: string[] = []
|
||||
let publishedCount = 0
|
||||
|
||||
while (publishedCount < count) {
|
||||
if (publishedCount >= 10 && publisherSessions.length === 1) {
|
||||
publisherSessions.push(await openAdhocPublisherSession(browser, testInfo))
|
||||
}
|
||||
|
||||
const activeSession = publishedCount < 10 ? publisherSessions[0] : publisherSessions[publisherSessions.length - 1]
|
||||
const chunkSize = publishedCount < 10 ? Math.min(10 - publishedCount, count - publishedCount) : count - publishedCount
|
||||
const chunk = await publishSearchSkillsChunk(
|
||||
activeSession,
|
||||
keyword,
|
||||
description,
|
||||
seedSuffix,
|
||||
publishedCount,
|
||||
chunkSize,
|
||||
)
|
||||
skills.push(...chunk.skills)
|
||||
skillNames.push(...chunk.skillNames)
|
||||
publishedCount += chunkSize
|
||||
}
|
||||
|
||||
const seed: SearchSeedContext = {
|
||||
builder: publisherSessions[0].builder,
|
||||
keyword,
|
||||
namespace: publisherSessions[0].namespace,
|
||||
skills,
|
||||
skillNames,
|
||||
}
|
||||
|
||||
const adminContext = await browser.newContext()
|
||||
const adminPage = await adminContext.newPage()
|
||||
const adminBuilder = new E2eTestDataBuilder(adminPage, testInfo)
|
||||
|
||||
await loginWithCredentials(adminPage, adminCredentials(), testInfo)
|
||||
await adminBuilder.init()
|
||||
|
||||
for (const skill of seed.skills) {
|
||||
const reviewTaskId = await adminBuilder.waitForPendingReview(skill.namespace, skill.slug, skill.version)
|
||||
await adminBuilder.approveReview(reviewTaskId)
|
||||
}
|
||||
|
||||
await seed.builder.waitForSearchResults(seed.keyword, seed.skills.map((skill) => skill.slug))
|
||||
|
||||
return {
|
||||
...seed,
|
||||
dispose: async () => {
|
||||
await adminContext.close()
|
||||
for (let index = publisherSessions.length - 1; index >= 0; index -= 1) {
|
||||
await cleanupSearchSeed({
|
||||
builder: publisherSessions[index].builder,
|
||||
keyword: seed.keyword,
|
||||
namespace: publisherSessions[index].namespace,
|
||||
skills: [],
|
||||
skillNames: [],
|
||||
})
|
||||
await publisherSessions[index].context.close()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function getSearchCard(page: Page, skillName: string): Locator {
|
||||
return getSearchCards(page).filter({
|
||||
has: page.getByRole('heading', { name: skillName, exact: true }),
|
||||
}).first()
|
||||
}
|
||||
|
||||
export function getSearchCards(page: Page): Locator {
|
||||
return page.getByRole('link').filter({
|
||||
has: page.locator('h3'),
|
||||
})
|
||||
}
|
||||
|
|
@ -2,6 +2,29 @@ import { expect, type Page, type TestInfo } from '@playwright/test'
|
|||
|
||||
const password = 'Passw0rd!123'
|
||||
const cachedUserByWorker = new Map<number, string>()
|
||||
const cachedSessionByAccount = new Map<string, SessionSnapshot>()
|
||||
const requestTimeoutMs = process.env.CI ? 12_000 : 8_000
|
||||
|
||||
export interface TestCredentials {
|
||||
password: string
|
||||
username: string
|
||||
}
|
||||
|
||||
interface SessionSnapshot {
|
||||
username: string
|
||||
cookies: Array<{
|
||||
name: string
|
||||
value: string
|
||||
domain: string
|
||||
path: string
|
||||
expires: number
|
||||
httpOnly: boolean
|
||||
secure: boolean
|
||||
sameSite: 'Strict' | 'Lax' | 'None'
|
||||
}>
|
||||
}
|
||||
|
||||
const cachedSessionByWorker = new Map<number, SessionSnapshot>()
|
||||
|
||||
function usernameForWorker(testInfo?: TestInfo): string {
|
||||
const worker = testInfo?.parallelIndex ?? 0
|
||||
|
|
@ -25,12 +48,14 @@ function isRetryableStatus(status: number): boolean {
|
|||
async function loginWithRetry(
|
||||
request: Page['request'],
|
||||
username: string,
|
||||
currentPassword = password,
|
||||
retries = process.env.CI ? 10 : 6,
|
||||
): Promise<boolean> {
|
||||
for (let i = 0; i < retries; i += 1) {
|
||||
try {
|
||||
const login = await request.post('/api/v1/auth/local/login', {
|
||||
data: { username, password },
|
||||
data: { username, password: currentPassword },
|
||||
timeout: requestTimeoutMs,
|
||||
})
|
||||
|
||||
if (login.ok()) {
|
||||
|
|
@ -51,30 +76,148 @@ async function loginWithRetry(
|
|||
return false
|
||||
}
|
||||
|
||||
async function hasActiveSession(page: Page): Promise<boolean> {
|
||||
try {
|
||||
const response = await page.context().request.get('/api/v1/auth/me', {
|
||||
timeout: requestTimeoutMs,
|
||||
})
|
||||
return response.ok()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function cacheSession(page: Page, worker: number, username: string) {
|
||||
const snapshot = {
|
||||
username,
|
||||
cookies: await page.context().cookies(),
|
||||
}
|
||||
cachedSessionByWorker.set(worker, snapshot)
|
||||
cachedSessionByAccount.set(username, snapshot)
|
||||
}
|
||||
|
||||
async function cacheAccountSession(page: Page, username: string) {
|
||||
cachedSessionByAccount.set(username, {
|
||||
username,
|
||||
cookies: await page.context().cookies(),
|
||||
})
|
||||
}
|
||||
|
||||
async function restoreCachedSession(page: Page, worker: number): Promise<SessionSnapshot | null> {
|
||||
const snapshot = cachedSessionByWorker.get(worker)
|
||||
if (!snapshot) {
|
||||
return null
|
||||
}
|
||||
|
||||
await page.context().addCookies(snapshot.cookies)
|
||||
if (await hasActiveSession(page)) {
|
||||
return snapshot
|
||||
}
|
||||
|
||||
cachedSessionByWorker.delete(worker)
|
||||
return null
|
||||
}
|
||||
|
||||
async function restoreCachedSessionForAccount(page: Page, username: string): Promise<SessionSnapshot | null> {
|
||||
const snapshot = cachedSessionByAccount.get(username)
|
||||
if (!snapshot) {
|
||||
return null
|
||||
}
|
||||
|
||||
await page.context().addCookies(snapshot.cookies)
|
||||
if (await hasActiveSession(page)) {
|
||||
return snapshot
|
||||
}
|
||||
|
||||
cachedSessionByAccount.delete(username)
|
||||
return null
|
||||
}
|
||||
|
||||
async function primeAuthProviders(page: Page) {
|
||||
try {
|
||||
await page.context().request.get('/api/v1/auth/providers', { timeout: requestTimeoutMs })
|
||||
} catch {
|
||||
// Best effort warm-up.
|
||||
}
|
||||
}
|
||||
|
||||
async function tryBootstrapMockSession(page: Page, worker: number): Promise<{ username: string, password: string } | null> {
|
||||
try {
|
||||
await page.context().request.get('/api/v1/auth/providers', {
|
||||
headers: { 'X-Mock-User-Id': 'local-user' },
|
||||
timeout: requestTimeoutMs,
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!(await hasActiveSession(page))) {
|
||||
return null
|
||||
}
|
||||
|
||||
await cacheSession(page, worker, 'local-user')
|
||||
cachedUserByWorker.set(worker, 'local-user')
|
||||
return { username: 'local-user', password }
|
||||
}
|
||||
|
||||
async function registerSessionOnce(page: Page, testInfo?: TestInfo) {
|
||||
const worker = testInfo?.parallelIndex ?? 0
|
||||
const cached = cachedUserByWorker.get(worker)
|
||||
const username = usernameForWorker(testInfo)
|
||||
const request = page.context().request
|
||||
|
||||
// Prime auth provider endpoint to stabilize cookie/bootstrap behavior.
|
||||
try {
|
||||
await request.get('/api/v1/auth/providers')
|
||||
} catch {
|
||||
// Best effort warm-up.
|
||||
await primeAuthProviders(page)
|
||||
|
||||
// Avoid hammering auth endpoints on every test run for the same worker.
|
||||
const restored = await restoreCachedSession(page, worker)
|
||||
if (restored) {
|
||||
cachedUserByWorker.set(worker, restored.username)
|
||||
return { username: restored.username, password }
|
||||
}
|
||||
|
||||
const mockSession = await tryBootstrapMockSession(page, worker)
|
||||
if (mockSession) {
|
||||
return mockSession
|
||||
}
|
||||
|
||||
// Prefer the known-good cached account to avoid repeated failed-logins on a fixed username.
|
||||
if (cached && await loginWithRetry(request, cached)) {
|
||||
await cacheSession(page, worker, cached)
|
||||
return { username: cached, password }
|
||||
}
|
||||
|
||||
// Support environments where a deterministic worker account already exists.
|
||||
if (!cached && await loginWithRetry(request, username, process.env.CI ? 4 : 3)) {
|
||||
if (!cached && await loginWithRetry(request, username, password, process.env.CI ? 4 : 3)) {
|
||||
cachedUserByWorker.set(worker, username)
|
||||
await cacheSession(page, worker, username)
|
||||
return { username, password }
|
||||
}
|
||||
|
||||
try {
|
||||
const register = await request.post('/api/v1/auth/local/register', {
|
||||
data: {
|
||||
username,
|
||||
password,
|
||||
email: `${username}@example.test`,
|
||||
},
|
||||
timeout: requestTimeoutMs,
|
||||
})
|
||||
|
||||
if (register.ok()) {
|
||||
cachedUserByWorker.set(worker, username)
|
||||
await cacheSession(page, worker, username)
|
||||
return { username, password }
|
||||
}
|
||||
|
||||
if (register.status() === 409 && await loginWithRetry(request, username, password, process.env.CI ? 8 : 6)) {
|
||||
cachedUserByWorker.set(worker, username)
|
||||
await cacheSession(page, worker, username)
|
||||
return { username, password }
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the unique-account fallback below.
|
||||
}
|
||||
|
||||
// Registering creates session cookies for the current request context.
|
||||
// Prefer creating a new unique account to avoid password drift and login throttling.
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
|
|
@ -87,10 +230,12 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo) {
|
|||
password,
|
||||
email: `${uniqueUsername}@example.test`,
|
||||
},
|
||||
timeout: requestTimeoutMs,
|
||||
})
|
||||
|
||||
if (register.ok()) {
|
||||
cachedUserByWorker.set(worker, uniqueUsername)
|
||||
await cacheSession(page, worker, uniqueUsername)
|
||||
return { username: uniqueUsername, password }
|
||||
}
|
||||
|
||||
|
|
@ -118,8 +263,9 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo) {
|
|||
// Final fallback for environments where registration is temporarily unavailable.
|
||||
const fallbackCandidates = [cached, username].filter((candidate): candidate is string => Boolean(candidate))
|
||||
for (const candidate of fallbackCandidates) {
|
||||
if (await loginWithRetry(request, candidate, process.env.CI ? 12 : 8)) {
|
||||
if (await loginWithRetry(request, candidate, password, process.env.CI ? 12 : 8)) {
|
||||
cachedUserByWorker.set(worker, candidate)
|
||||
await cacheSession(page, worker, candidate)
|
||||
return { username: candidate, password }
|
||||
}
|
||||
}
|
||||
|
|
@ -127,6 +273,50 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo) {
|
|||
throw new Error(`Failed to establish e2e session for worker ${worker}`)
|
||||
}
|
||||
|
||||
async function createFreshSessionOnce(page: Page, testInfo?: TestInfo) {
|
||||
const worker = testInfo?.parallelIndex ?? 0
|
||||
const request = page.context().request
|
||||
|
||||
await primeAuthProviders(page)
|
||||
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
const uniqueUsername = `${uniqueUsernameForWorker(testInfo)}_${i}`
|
||||
|
||||
try {
|
||||
const register = await request.post('/api/v1/auth/local/register', {
|
||||
data: {
|
||||
username: uniqueUsername,
|
||||
password,
|
||||
email: `${uniqueUsername}@example.test`,
|
||||
},
|
||||
timeout: requestTimeoutMs,
|
||||
})
|
||||
|
||||
if (register.ok()) {
|
||||
cachedUserByWorker.set(worker, uniqueUsername)
|
||||
await cacheSession(page, worker, uniqueUsername)
|
||||
return { username: uniqueUsername, password }
|
||||
}
|
||||
|
||||
const status = register.status()
|
||||
if (status === 409 || status === 400) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (isRetryableStatus(status)) {
|
||||
await sleep(300 * (i + 1))
|
||||
continue
|
||||
}
|
||||
|
||||
expect(register.ok()).toBeTruthy()
|
||||
} catch {
|
||||
await sleep(300 * (i + 1))
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Failed to create fresh e2e session for worker ${worker}`)
|
||||
}
|
||||
|
||||
export async function registerSession(page: Page, testInfo?: TestInfo) {
|
||||
let lastError: unknown
|
||||
|
||||
|
|
@ -143,3 +333,42 @@ export async function registerSession(page: Page, testInfo?: TestInfo) {
|
|||
|
||||
throw lastError
|
||||
}
|
||||
|
||||
export async function createFreshSession(page: Page, testInfo?: TestInfo) {
|
||||
let lastError: unknown
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
return await createFreshSessionOnce(page, testInfo)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
if (attempt < 2) {
|
||||
await sleep(500 * (attempt + 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError
|
||||
}
|
||||
|
||||
export async function loginWithCredentials(page: Page, credentials: TestCredentials, _testInfo?: TestInfo) {
|
||||
const request = page.context().request
|
||||
|
||||
await primeAuthProviders(page)
|
||||
|
||||
const restored = await restoreCachedSessionForAccount(page, credentials.username)
|
||||
if (restored) {
|
||||
return credentials
|
||||
}
|
||||
|
||||
const loggedIn = await loginWithRetry(
|
||||
request,
|
||||
credentials.username,
|
||||
credentials.password,
|
||||
process.env.CI ? 12 : 8,
|
||||
)
|
||||
expect(loggedIn).toBeTruthy()
|
||||
|
||||
await cacheAccountSession(page, credentials.username)
|
||||
return credentials
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,15 @@ export interface SeededReviewData {
|
|||
skill: SeededSkill
|
||||
}
|
||||
|
||||
interface ReviewTaskSummary {
|
||||
id: number
|
||||
namespace: string
|
||||
skillSlug: string
|
||||
status: string
|
||||
submittedBy: string
|
||||
version: string
|
||||
}
|
||||
|
||||
interface ApiEnvelope<T> {
|
||||
code: number
|
||||
msg: string
|
||||
|
|
@ -36,6 +45,13 @@ interface ApiFailure extends Error {
|
|||
code?: number
|
||||
}
|
||||
|
||||
export interface SeedSkillOptions {
|
||||
name?: string
|
||||
description?: string
|
||||
version?: string
|
||||
readmeHeading?: string
|
||||
}
|
||||
|
||||
function asApiErrorBody(value: unknown): string {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return ''
|
||||
|
|
@ -49,26 +65,39 @@ function uniqueSuffix(testInfo?: TestInfo): string {
|
|||
return `${worker}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
function buildSkillPackageZipBuffer(suffix: string): Buffer {
|
||||
const tempRoot = mkdtempSync(path.join(tmpdir(), 'skillhub-e2e-'))
|
||||
try {
|
||||
const packageDir = path.join(tempRoot, `pkg-${suffix}`)
|
||||
const zipPath = path.join(tempRoot, `pkg-${suffix}.zip`)
|
||||
const skillName = `e2e-skill-${suffix}`.slice(0, 48)
|
||||
const skillMd = `---
|
||||
function buildSkillPackageContent(suffix: string, options?: SeedSkillOptions) {
|
||||
const skillName = (options?.name || `e2e-skill-${suffix}`).slice(0, 48)
|
||||
const description = options?.description || 'E2E generated skill for real-request tests'
|
||||
const version = options?.version || '1.0.0'
|
||||
const readmeHeading = options?.readmeHeading || skillName
|
||||
const skillMd = `---
|
||||
name: ${skillName}
|
||||
description: E2E generated skill for real-request tests
|
||||
version: 1.0.0
|
||||
description: ${description}
|
||||
version: ${version}
|
||||
---
|
||||
|
||||
# ${skillName}
|
||||
# ${readmeHeading}
|
||||
|
||||
Generated by Playwright E2E.
|
||||
`
|
||||
|
||||
return {
|
||||
readmeHeading,
|
||||
skillMd,
|
||||
skillName,
|
||||
}
|
||||
}
|
||||
|
||||
function buildSkillPackageZipBuffer(suffix: string, options?: SeedSkillOptions): Buffer {
|
||||
const tempRoot = mkdtempSync(path.join(tmpdir(), 'skillhub-e2e-'))
|
||||
try {
|
||||
const packageDir = path.join(tempRoot, `pkg-${suffix}`)
|
||||
const zipPath = path.join(tempRoot, `pkg-${suffix}.zip`)
|
||||
const { readmeHeading, skillMd } = buildSkillPackageContent(suffix, options)
|
||||
|
||||
execFileSync('mkdir', ['-p', packageDir])
|
||||
writeFileSync(path.join(packageDir, 'SKILL.md'), skillMd, 'utf8')
|
||||
writeFileSync(path.join(packageDir, 'README.md'), `# ${skillName}\n`, 'utf8')
|
||||
writeFileSync(path.join(packageDir, 'README.md'), `# ${readmeHeading}\n`, 'utf8')
|
||||
execFileSync('zip', ['-q', '-r', zipPath, 'SKILL.md', 'README.md'], { cwd: packageDir })
|
||||
return readFileSync(zipPath)
|
||||
} finally {
|
||||
|
|
@ -76,25 +105,15 @@ Generated by Playwright E2E.
|
|||
}
|
||||
}
|
||||
|
||||
function createSkillPackageZipFile(suffix: string): { filePath: string; cleanup: () => void } {
|
||||
function createSkillPackageZipFile(suffix: string, options?: SeedSkillOptions): { filePath: string; cleanup: () => void } {
|
||||
const tempRoot = mkdtempSync(path.join(tmpdir(), 'skillhub-e2e-file-'))
|
||||
const packageDir = path.join(tempRoot, `pkg-${suffix}`)
|
||||
const zipPath = path.join(tempRoot, `pkg-${suffix}.zip`)
|
||||
const skillName = `e2e-skill-${suffix}`.slice(0, 48)
|
||||
const skillMd = `---
|
||||
name: ${skillName}
|
||||
description: E2E generated skill for real-request tests
|
||||
version: 1.0.0
|
||||
---
|
||||
|
||||
# ${skillName}
|
||||
|
||||
Generated by Playwright E2E.
|
||||
`
|
||||
const { readmeHeading, skillMd } = buildSkillPackageContent(suffix, options)
|
||||
|
||||
execFileSync('mkdir', ['-p', packageDir])
|
||||
writeFileSync(path.join(packageDir, 'SKILL.md'), skillMd, 'utf8')
|
||||
writeFileSync(path.join(packageDir, 'README.md'), `# ${skillName}\n`, 'utf8')
|
||||
writeFileSync(path.join(packageDir, 'README.md'), `# ${readmeHeading}\n`, 'utf8')
|
||||
execFileSync('zip', ['-q', '-r', zipPath, 'SKILL.md', 'README.md'], { cwd: packageDir })
|
||||
|
||||
return {
|
||||
|
|
@ -239,32 +258,115 @@ export class E2eTestDataBuilder {
|
|||
}
|
||||
}
|
||||
|
||||
async publishSkill(namespaceSlug: string): Promise<SeededSkill> {
|
||||
const unique = `${this.suffix}_${Math.random().toString(36).slice(2, 6)}`
|
||||
const zipBuffer = buildSkillPackageZipBuffer(unique)
|
||||
async waitForSearchResult(query: string, expectedSlug?: string): Promise<void> {
|
||||
const encodedQuery = encodeURIComponent(query)
|
||||
|
||||
let result: SeededSkill
|
||||
try {
|
||||
result = await parseEnvelope<SeededSkill>(
|
||||
await this.request.post(`/api/web/skills/${encodeURIComponent(namespaceSlug)}/publish`, {
|
||||
multipart: {
|
||||
file: {
|
||||
name: 'sample-skill.zip',
|
||||
mimeType: 'application/zip',
|
||||
buffer: zipBuffer,
|
||||
},
|
||||
visibility: 'PUBLIC',
|
||||
},
|
||||
}),
|
||||
)
|
||||
} catch (error) {
|
||||
const fallback = await this.getMySkillInNamespace(namespaceSlug)
|
||||
if (!fallback) {
|
||||
throw error
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
try {
|
||||
const page = await parseEnvelope<{
|
||||
items: Array<{ slug: string }>
|
||||
}>(
|
||||
await this.request.get(`/api/web/skills?q=${encodedQuery}&sort=relevance&page=0&size=50`),
|
||||
)
|
||||
if (!expectedSlug || page.items.some((item) => item.slug === expectedSlug)) {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Search indexing can lag briefly behind publish in local environments.
|
||||
}
|
||||
return fallback
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 300 * (attempt + 1)))
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for search result "${query}"${expectedSlug ? ` (${expectedSlug})` : ''}`)
|
||||
}
|
||||
|
||||
async waitForSearchResults(query: string, expectedSlugs: string[]): Promise<void> {
|
||||
const pending = new Set(expectedSlugs)
|
||||
if (pending.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const encodedQuery = encodeURIComponent(query)
|
||||
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
try {
|
||||
const page = await parseEnvelope<{
|
||||
items: Array<{ slug: string }>
|
||||
}>(
|
||||
await this.request.get(`/api/web/skills?q=${encodedQuery}&sort=relevance&page=0&size=50`),
|
||||
)
|
||||
|
||||
for (const item of page.items) {
|
||||
pending.delete(item.slug)
|
||||
}
|
||||
|
||||
if (pending.size === 0) {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Search indexing can lag briefly behind publish in local environments.
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 300 * (attempt + 1)))
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for search results "${query}" (${Array.from(pending).join(', ')})`)
|
||||
}
|
||||
|
||||
async waitForPendingReview(namespaceSlug: string, skillSlug: string, version: string): Promise<number> {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
try {
|
||||
const page = await parseEnvelope<{
|
||||
items: ReviewTaskSummary[]
|
||||
}>(
|
||||
await this.request.get('/api/web/reviews?status=PENDING&page=0&size=100&sortDirection=DESC'),
|
||||
)
|
||||
|
||||
const matched = page.items.find((item) =>
|
||||
item.namespace === namespaceSlug &&
|
||||
item.skillSlug === skillSlug &&
|
||||
item.version === version &&
|
||||
item.status === 'PENDING',
|
||||
)
|
||||
if (matched) {
|
||||
return matched.id
|
||||
}
|
||||
} catch {
|
||||
// Review list can lag behind publish very briefly.
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 300 * (attempt + 1)))
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for pending review ${namespaceSlug}/${skillSlug}@${version}`)
|
||||
}
|
||||
|
||||
async approveReview(reviewTaskId: number, comment = 'Approved by Playwright E2E'): Promise<void> {
|
||||
await parseEnvelope<ReviewTaskSummary>(
|
||||
await this.request.post(`/api/web/reviews/${reviewTaskId}/approve`, {
|
||||
data: { comment },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async publishSkill(namespaceSlug: string, options?: SeedSkillOptions): Promise<SeededSkill> {
|
||||
const unique = `${this.suffix}_${Math.random().toString(36).slice(2, 6)}`
|
||||
const zipBuffer = buildSkillPackageZipBuffer(unique, options)
|
||||
|
||||
const result = await parseEnvelope<SeededSkill>(
|
||||
await this.request.post(`/api/web/skills/${encodeURIComponent(namespaceSlug)}/publish`, {
|
||||
multipart: {
|
||||
file: {
|
||||
name: 'sample-skill.zip',
|
||||
mimeType: 'application/zip',
|
||||
buffer: zipBuffer,
|
||||
},
|
||||
visibility: 'PUBLIC',
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
this.cleanupTasks.push(async () => {
|
||||
await this.request.delete(`/api/web/skills/${encodeURIComponent(result.namespace)}/${encodeURIComponent(result.slug)}`)
|
||||
})
|
||||
|
|
@ -272,9 +374,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()
|
||||
})
|
||||
|
|
|
|||
309
web/e2e/register-login-validation.spec.ts
Normal file
309
web/e2e/register-login-validation.spec.ts
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
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
|
||||
|
||||
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 ({ browser, page }, testInfo) => {
|
||||
let username = existingRegisteredUsername
|
||||
if (!username) {
|
||||
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
|
||||
await page.goto('/register')
|
||||
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 duplicateUsernameError.isVisible().catch(() => false)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (attempt < 2 && await registerRateLimitError.isVisible().catch(() => false)) {
|
||||
await page.waitForTimeout(1_500 * (attempt + 1))
|
||||
continue
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
await expect(duplicateUsernameError).toBeVisible()
|
||||
})
|
||||
|
||||
// TC_REG_002 P0 - registration without email
|
||||
test('TC_REG_002: registers successfully without email (email is optional)', async ({ page }) => {
|
||||
await page.goto('/register')
|
||||
const suffix = Date.now().toString(36) + Math.random().toString(36).slice(2, 5)
|
||||
await page.getByLabel(/username/i).fill(`noemail_${suffix}`)
|
||||
await page.getByLabel(/^password/i).fill('Test123!@')
|
||||
await page.getByRole('button', { name: 'Register' }).click()
|
||||
await expect(page).not.toHaveURL('/register')
|
||||
})
|
||||
|
||||
// TC_REG_005 P0 - required fields empty on submit
|
||||
test('TC_REG_005: shows validation errors when submitting empty required fields', async ({ page }) => {
|
||||
await page.goto('/register')
|
||||
await page.getByRole('button', { name: 'Register' }).click()
|
||||
await expect(page.getByText(/username.*required|required.*username/i)).toBeVisible()
|
||||
await expect(page.getByText(/password.*required|required.*password/i)).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Login Flow (Real API)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setEnglishLocale(page)
|
||||
})
|
||||
|
||||
// TC_REG_006 P0 - successful login (already tested in auth-entry.spec.ts partially; extend here)
|
||||
test('TC_REG_006: shows required field errors when submitting empty login form', async ({ page }) => {
|
||||
await page.goto('/login')
|
||||
await page.getByRole('button', { name: 'Login' }).click()
|
||||
await expect(page.getByText('Username is required')).toBeVisible()
|
||||
await expect(page.getByText('Password is required')).toBeVisible()
|
||||
})
|
||||
|
||||
// TC_REG_007 P0 - wrong password
|
||||
test('TC_REG_007: shows error for wrong password on existing account', async ({ page }) => {
|
||||
// First register a user, then attempt login with wrong password
|
||||
const suffix = Date.now().toString(36)
|
||||
const username = `logintest_${suffix}`
|
||||
|
||||
await page.goto('/register')
|
||||
await page.getByLabel(/username/i).fill(username)
|
||||
await page.getByLabel(/^password/i).fill('Test123!@')
|
||||
await page.getByRole('button', { name: 'Register' }).click()
|
||||
await expect(page).not.toHaveURL('/register')
|
||||
|
||||
await page.goto('/login')
|
||||
await setEnglishLocale(page)
|
||||
await page.getByLabel(/username/i).fill(username)
|
||||
await page.getByLabel(/^password/i).fill('WrongPassword999!')
|
||||
await page.getByRole('button', { name: 'Login' }).click()
|
||||
await expect(page.getByText(/invalid|incorrect|wrong|username.*password/i)).toBeVisible()
|
||||
})
|
||||
|
||||
// TC_REG_008 P0 - non-existent username
|
||||
test('TC_REG_008: shows error for non-existent username login attempt', async ({ page }) => {
|
||||
await page.goto('/login')
|
||||
await page.getByLabel(/username/i).fill('nonexistent_user_xyz99999')
|
||||
await page.getByLabel(/^password/i).fill('Test123!@')
|
||||
await page.getByRole('button', { name: 'Login' }).click()
|
||||
await expect(page.getByText(/invalid|incorrect|wrong|username.*password|not found/i)).toBeVisible()
|
||||
})
|
||||
|
||||
// TC_REG_010 P1 - SQL injection safety
|
||||
test('TC_REG_010: safely handles SQL injection input in username field', async ({ page }) => {
|
||||
await page.goto('/login')
|
||||
await page.getByLabel(/username/i).fill("admin' OR '1'='1")
|
||||
await page.getByLabel(/^password/i).fill('anything')
|
||||
await page.getByRole('button', { name: 'Login' }).click()
|
||||
// Should not log in; should show error or validation message, NOT redirect to dashboard
|
||||
await expect(page).not.toHaveURL('/dashboard')
|
||||
})
|
||||
|
||||
// TC_REG_011 P1 - XSS in input
|
||||
test('TC_REG_011: safely handles XSS payload in username field without executing script', async ({ page }) => {
|
||||
let alerted = false
|
||||
page.on('dialog', () => { alerted = true })
|
||||
|
||||
await page.goto('/login')
|
||||
await page.getByLabel(/username/i).fill("<script>alert('xss')</script>")
|
||||
await page.getByLabel(/^password/i).fill('anything')
|
||||
await page.getByRole('button', { name: 'Login' }).click()
|
||||
await expect(page).not.toHaveURL('/dashboard')
|
||||
expect(alerted).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Register/Login UI (Real API)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setEnglishLocale(page)
|
||||
})
|
||||
|
||||
// TC_UI_003 P2 - password visibility toggle
|
||||
test('TC_UI_003: password visibility toggle switches between masked and plain text', async ({ page }) => {
|
||||
await page.goto('/register')
|
||||
const passwordInput = page.getByLabel(/^password/i)
|
||||
await expect(passwordInput).toHaveAttribute('type', 'password')
|
||||
|
||||
const toggleBtn = page.getByRole('button', { name: /show|hide|toggle/i })
|
||||
.or(page.locator('[data-testid*="password-toggle"], [aria-label*="password"]'))
|
||||
if (await toggleBtn.isVisible()) {
|
||||
await toggleBtn.click()
|
||||
await expect(passwordInput).toHaveAttribute('type', 'text')
|
||||
}
|
||||
})
|
||||
|
||||
// TC_UI_005 P2 - Enter key submits form
|
||||
test('TC_UI_005: pressing Enter in the last input field submits the login form', async ({ page }) => {
|
||||
await page.goto('/login')
|
||||
await page.getByLabel(/username/i).fill('someuser')
|
||||
await page.getByLabel(/^password/i).fill('SomePass123!')
|
||||
await page.getByLabel(/^password/i).press('Enter')
|
||||
// Form should attempt submission (either error msg or redirect)
|
||||
await expect(
|
||||
page.getByText(/invalid|incorrect|dashboard/i)
|
||||
.or(page.locator('[role="alert"]'))
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
// returnTo param preservation (from auth-entry.spec.ts - extended)
|
||||
test('preserves returnTo param when navigating from register link on login page', async ({ page }) => {
|
||||
await page.goto('/login?returnTo=%2Fdashboard%2Ftokens')
|
||||
await page.getByRole('link', { name: /sign up|register/i }).click()
|
||||
await expect(page).toHaveURL('/register?returnTo=%2Fdashboard%2Ftokens')
|
||||
})
|
||||
})
|
||||
438
web/e2e/search-card-interaction.spec.ts
Normal file
438
web/e2e/search-card-interaction.spec.ts
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { setEnglishLocale } from './helpers/auth-fixtures'
|
||||
import {
|
||||
getSearchCard,
|
||||
getSearchCards,
|
||||
prepareSearchSeed,
|
||||
type PreparedSearchSeed,
|
||||
} from './helpers/search-seed'
|
||||
import { registerSession } from './helpers/session'
|
||||
|
||||
const SEARCH_URL = (q: string, sort = 'relevance', page = 0) =>
|
||||
`/search?q=${encodeURIComponent(q)}&sort=${sort}&page=${page}&starredOnly=false`
|
||||
|
||||
function latestSeed(seed: PreparedSearchSeed) {
|
||||
return {
|
||||
skill: seed.skills[seed.skills.length - 1],
|
||||
skillName: seed.skillNames[seed.skillNames.length - 1],
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForCards(page: Page) {
|
||||
const cards = getSearchCards(page)
|
||||
|
||||
if (basicSeed) {
|
||||
await basicSeed.builder.waitForSearchResults(
|
||||
basicSeed.keyword,
|
||||
basicSeed.skills.map((skill) => skill.slug),
|
||||
)
|
||||
}
|
||||
|
||||
const keyword = basicSeed?.keyword
|
||||
const encodedKeyword = keyword ? encodeURIComponent(keyword) : null
|
||||
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
await page.waitForLoadState('networkidle')
|
||||
await expect(page.getByRole('textbox', { name: 'Search skills...' })).toBeVisible({ timeout: 8_000 })
|
||||
|
||||
if (await cards.count() > 0) {
|
||||
return cards
|
||||
}
|
||||
|
||||
if (attempt < 3) {
|
||||
const responsePromise = encodedKeyword
|
||||
? page.waitForResponse(async (response) => {
|
||||
if (!response.url().includes('/api/web/skills?') || !response.url().includes(`q=${encodedKeyword}`)) {
|
||||
return false
|
||||
}
|
||||
if (response.status() !== 200) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await response.json() as { data?: { items?: Array<unknown> } }
|
||||
return Array.isArray(payload.data?.items) && payload.data.items.length > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, { timeout: 12_000 }).catch(() => null)
|
||||
: Promise.resolve(null)
|
||||
|
||||
await page.waitForTimeout(750 * (attempt + 1))
|
||||
await page.reload({ waitUntil: 'networkidle' })
|
||||
await responsePromise
|
||||
}
|
||||
}
|
||||
|
||||
return cards
|
||||
}
|
||||
|
||||
let basicSeed: PreparedSearchSeed | undefined
|
||||
|
||||
test.setTimeout(300_000)
|
||||
|
||||
test.beforeAll(async ({ browser }, testInfo) => {
|
||||
test.setTimeout(300_000)
|
||||
basicSeed = await prepareSearchSeed(browser, testInfo, { count: 13 })
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await basicSeed?.dispose()
|
||||
basicSeed = undefined
|
||||
})
|
||||
|
||||
// ─── Card Display After Search ────────────────────────────────────────────────
|
||||
|
||||
test.describe('Search Card Display (Real API)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setEnglishLocale(page)
|
||||
})
|
||||
|
||||
// TC_SEARCH_INTERACT_001 P0
|
||||
test('TC_SEARCH_INTERACT_001: cards appear immediately after search', async ({ page }) => {
|
||||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||||
const cards = await waitForCards(page)
|
||||
await expect(cards.first()).toBeVisible({ timeout: 8_000 })
|
||||
})
|
||||
|
||||
// TC_SEARCH_INTERACT_005 P0 - cards show complete info
|
||||
test('TC_SEARCH_INTERACT_005: each card shows name, description, and version', async ({ page }) => {
|
||||
const current = latestSeed(basicSeed!)
|
||||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||||
const firstCard = getSearchCard(page, current.skillName)
|
||||
await expect(firstCard).toBeVisible({ timeout: 8_000 })
|
||||
await expect(firstCard.getByRole('heading', { name: current.skillName, exact: true })).toBeVisible()
|
||||
await expect(firstCard.getByText(`v${current.skill.version}`)).toBeVisible()
|
||||
})
|
||||
|
||||
// TC_SEARCH_INTERACT_039 P0 - version number format
|
||||
test('TC_SEARCH_INTERACT_039: version number is displayed in v1.2.3 format', async ({ page }) => {
|
||||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||||
await expect(page.getByText(/v\d+\.\d+\.\d+/).first()).toBeVisible({ timeout: 8_000 })
|
||||
})
|
||||
|
||||
// TC_SEARCH_INTERACT_038 P0 - long descriptions truncated
|
||||
test('TC_SEARCH_INTERACT_038: long descriptions are truncated with ellipsis', async ({ page }) => {
|
||||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||||
const cards = await waitForCards(page)
|
||||
expect(await cards.count()).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
// TC_SEARCH_INTERACT_031 P0 - no results shows empty state
|
||||
test('TC_SEARCH_INTERACT_031: no results shows empty state instead of cards', async ({ page }) => {
|
||||
await page.goto(SEARCH_URL('xyznonexistentkeyword99999abc'))
|
||||
await page.waitForLoadState('networkidle')
|
||||
await expect(getSearchCards(page)).toHaveCount(0)
|
||||
await expect(page.getByRole('heading', { name: 'No results found' })).toBeVisible({ timeout: 8_000 })
|
||||
})
|
||||
|
||||
// TC_SEARCH_INTERACT_035 P0 - large results show pagination
|
||||
test('TC_SEARCH_INTERACT_035: large result sets show pagination controls', async ({ page }) => {
|
||||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||||
await page.waitForLoadState('networkidle')
|
||||
await expect(page.getByRole('button', { name: /next|›/i })).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Card Content & Search Relevance ─────────────────────────────────────────
|
||||
|
||||
test.describe('Search Card Content (Real API)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setEnglishLocale(page)
|
||||
})
|
||||
|
||||
// TC_SEARCH_INTERACT_003 P0 - card count matches count indicator
|
||||
test('TC_SEARCH_INTERACT_003: displayed card count is consistent with skill count indicator', async ({ page }) => {
|
||||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||||
const cards = getSearchCards(page)
|
||||
const cardCount = await cards.count()
|
||||
const countText = await page.getByText(/\d+\s+skills found/i).first().textContent()
|
||||
const totalMatch = countText?.match(/\d+/)
|
||||
if (totalMatch) {
|
||||
const total = parseInt(totalMatch[0], 10)
|
||||
expect(total).toBeGreaterThanOrEqual(cardCount)
|
||||
}
|
||||
})
|
||||
|
||||
// TC_SEARCH_INTERACT_040 P0 - download count formatted
|
||||
test('TC_SEARCH_INTERACT_040: download counts are formatted correctly (numbers or K/M)', async ({ page }) => {
|
||||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||||
await expect(page.locator('body')).not.toContainText(/error|500/i)
|
||||
await expect(getSearchCards(page).first()).toContainText(/\d/)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Card Click Navigation ────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Search Card Navigation (Real API)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setEnglishLocale(page)
|
||||
})
|
||||
|
||||
// TC_SEARCH_INTERACT_007 P0 - clicking card navigates to detail page
|
||||
test('TC_SEARCH_INTERACT_007: clicking a skill card navigates to the skill detail page', async ({ page }, testInfo) => {
|
||||
const current = latestSeed(basicSeed!)
|
||||
await registerSession(page, testInfo)
|
||||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||||
const firstCard = getSearchCard(page, current.skillName)
|
||||
await expect(firstCard).toBeVisible({ timeout: 8_000 })
|
||||
await firstCard.click()
|
||||
await expect(page).toHaveURL(new RegExp(`/space/${current.skill.namespace}/${current.skill.slug}`))
|
||||
})
|
||||
|
||||
// TC_SEARCH_INTERACT_008 P0 - detail page matches clicked card
|
||||
test('TC_SEARCH_INTERACT_008: skill detail page matches the card that was clicked', async ({ page }, testInfo) => {
|
||||
const current = latestSeed(basicSeed!)
|
||||
await registerSession(page, testInfo)
|
||||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||||
const firstCard = getSearchCard(page, current.skillName)
|
||||
await expect(firstCard).toBeVisible({ timeout: 8_000 })
|
||||
await firstCard.click()
|
||||
await expect(page).toHaveURL(new RegExp(`/space/${current.skill.namespace}/${current.skill.slug}`))
|
||||
await expect(page.getByRole('heading', { name: current.skillName, exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
// TC_SEARCH_INTERACT_009 P1 - Ctrl+click opens in new tab
|
||||
test('TC_SEARCH_INTERACT_009: Ctrl+click on card opens skill detail in new tab', async ({ page, context }) => {
|
||||
test.skip(true, 'Skill cards render as clickable divs, so browser-level new-tab semantics do not apply.')
|
||||
const current = latestSeed(basicSeed!)
|
||||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||||
const firstCard = getSearchCard(page, current.skillName)
|
||||
await expect(firstCard).toBeVisible({ timeout: 8_000 })
|
||||
|
||||
const [newPage] = await Promise.all([
|
||||
context.waitForEvent('page'),
|
||||
firstCard.click({ modifiers: ['Meta'] }),
|
||||
])
|
||||
await newPage.waitForLoadState()
|
||||
await expect(newPage).toHaveURL(/\/space\//)
|
||||
await newPage.close()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Sort Switching Updates Cards ────────────────────────────────────────────
|
||||
|
||||
test.describe('Search Card Sort Interaction (Real API)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setEnglishLocale(page)
|
||||
})
|
||||
|
||||
// TC_SEARCH_INTERACT_021 P0 - switching sort updates cards
|
||||
test('TC_SEARCH_INTERACT_021: switching sort tab re-renders card list', async ({ page }) => {
|
||||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||||
await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 })
|
||||
|
||||
await page.getByRole('button', { name: 'Downloads' }).click()
|
||||
await page.waitForLoadState('networkidle')
|
||||
await expect(page).toHaveURL(/sort=downloads/)
|
||||
await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 })
|
||||
})
|
||||
|
||||
// TC_SEARCH_INTERACT_026 P0 - re-search replaces cards
|
||||
test('TC_SEARCH_INTERACT_026: re-searching with new keyword replaces card list', async ({ page }) => {
|
||||
await page.goto(SEARCH_URL(''))
|
||||
const searchInput = page.getByPlaceholder('Search skills...')
|
||||
await searchInput.fill(basicSeed!.keyword)
|
||||
await searchInput.press('Enter')
|
||||
await page.waitForLoadState('networkidle')
|
||||
await expect(page).toHaveURL(new RegExp(`q=${basicSeed!.keyword}`))
|
||||
await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 })
|
||||
})
|
||||
|
||||
// TC_SEARCH_INTERACT_027 P0 - re-search resets page to 0
|
||||
test('TC_SEARCH_INTERACT_027: re-searching resets page number to 0', async ({ page }) => {
|
||||
await page.goto(SEARCH_URL(basicSeed!.keyword, 'relevance', 1))
|
||||
const searchInput = page.getByPlaceholder('Search skills...')
|
||||
await searchInput.fill(basicSeed!.keyword)
|
||||
await searchInput.press('Enter')
|
||||
await expect(page).toHaveURL(/page=0/)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Pagination Card Updates ──────────────────────────────────────────────────
|
||||
|
||||
test.describe('Search Card Pagination (Real API)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setEnglishLocale(page)
|
||||
})
|
||||
|
||||
// TC_SEARCH_INTERACT_023 P0 - switching page updates cards
|
||||
test('TC_SEARCH_INTERACT_023: switching to next page shows different cards', async ({ page }) => {
|
||||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const nextBtn = page.getByRole('button', { name: /next|›/i })
|
||||
const firstCardTitle = await getSearchCards(page).first().getByRole('heading').textContent()
|
||||
await expect(nextBtn).toBeVisible({ timeout: 10_000 })
|
||||
await nextBtn.click()
|
||||
await page.waitForLoadState('networkidle')
|
||||
await expect(page).toHaveURL(/page=1/)
|
||||
await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 })
|
||||
const secondPageFirstTitle = await getSearchCards(page).first().getByRole('heading').textContent()
|
||||
expect(secondPageFirstTitle).not.toBe(firstCardTitle)
|
||||
})
|
||||
|
||||
// TC_SEARCH_INTERACT_025 P1 - page switch scrolls to top
|
||||
test('TC_SEARCH_INTERACT_025: switching page scrolls back to top of results', async ({ page }) => {
|
||||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const nextBtn = page.getByRole('button', { name: /next|›/i })
|
||||
await expect(nextBtn).toBeVisible({ timeout: 10_000 })
|
||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight))
|
||||
await nextBtn.click()
|
||||
await page.waitForLoadState('networkidle')
|
||||
await expect.poll(
|
||||
() => page.evaluate(() => window.scrollY),
|
||||
{ timeout: 5_000, intervals: [100, 250, 500, 1_000] },
|
||||
).toBeLessThan(300)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Loading State ────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Search Card Loading State (Real API)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setEnglishLocale(page)
|
||||
})
|
||||
|
||||
// TC_SEARCH_INTERACT_030 P0 - skeleton disappears after load
|
||||
test('TC_SEARCH_INTERACT_030: skeleton screen disappears and real cards appear after load', async ({ page }) => {
|
||||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||||
await page.waitForLoadState('networkidle')
|
||||
await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 })
|
||||
await expect(page.locator('[class*="skeleton"], [class*="shimmer"]')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Responsive Layout ────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Search Card Responsive Layout (Real API)', () => {
|
||||
test.describe.configure({ retries: 2 })
|
||||
test.use({ hasTouch: true })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setEnglishLocale(page)
|
||||
})
|
||||
|
||||
// TC_SEARCH_INTERACT_042 P0 - desktop 3-column grid
|
||||
test('TC_SEARCH_INTERACT_042: desktop viewport shows 3-column card grid', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||||
await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 })
|
||||
const grid = page.locator('[class*="grid"]').first()
|
||||
await expect(grid).toBeVisible()
|
||||
})
|
||||
|
||||
// TC_SEARCH_INTERACT_044 P0 - mobile 1-column layout
|
||||
test('TC_SEARCH_INTERACT_044: mobile viewport shows single-column card layout', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 })
|
||||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||||
const cards = await waitForCards(page)
|
||||
await expect(cards.first()).toBeVisible({ timeout: 8_000 })
|
||||
await expect(page.locator('body')).not.toContainText(/error|500/i)
|
||||
})
|
||||
|
||||
// TC_SEARCH_INTERACT_043 P0 - tablet 2-column layout
|
||||
test('TC_SEARCH_INTERACT_043: tablet viewport shows 2-column card layout', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 768, height: 1024 })
|
||||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||||
const cards = await waitForCards(page)
|
||||
await expect(cards.first()).toBeVisible({ timeout: 8_000 })
|
||||
await expect(page.locator('body')).not.toContainText(/error|500/i)
|
||||
})
|
||||
|
||||
// TC_SEARCH_INTERACT_045 P1 - responsive layout adjusts on resize
|
||||
test('TC_SEARCH_INTERACT_045: card layout adjusts when browser window is resized', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||||
await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 })
|
||||
|
||||
await page.setViewportSize({ width: 375, height: 812 })
|
||||
await expect(getSearchCards(page).first()).toBeVisible()
|
||||
await expect(page.locator('body')).not.toContainText(/error|500/i)
|
||||
})
|
||||
|
||||
// TC_SEARCH_INTERACT_046 P0 - mobile touch interaction
|
||||
test('TC_SEARCH_INTERACT_046: mobile touch on card navigates to skill detail', async ({ page }, testInfo) => {
|
||||
const current = latestSeed(basicSeed!)
|
||||
await registerSession(page, testInfo)
|
||||
await page.setViewportSize({ width: 375, height: 812 })
|
||||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||||
const firstCard = getSearchCard(page, current.skillName)
|
||||
await expect(firstCard).toBeVisible({ timeout: 8_000 })
|
||||
await firstCard.tap()
|
||||
await expect(page).toHaveURL(new RegExp(`/space/${current.skill.namespace}/${current.skill.slug}`))
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Keyboard Navigation ──────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Search Card Keyboard Navigation (Real API)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setEnglishLocale(page)
|
||||
})
|
||||
|
||||
// TC_SEARCH_INTERACT_049 P1 - Tab key navigates between cards
|
||||
test('TC_SEARCH_INTERACT_049: Tab key can navigate between skill cards', async ({ page }) => {
|
||||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||||
await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 })
|
||||
|
||||
await page.keyboard.press('Tab')
|
||||
await page.keyboard.press('Tab')
|
||||
const focused = page.locator(':focus')
|
||||
await expect(focused).toBeVisible()
|
||||
})
|
||||
|
||||
// TC_SEARCH_INTERACT_050 P1 - Enter key opens focused card
|
||||
test('TC_SEARCH_INTERACT_050: pressing Enter on a focused card opens the skill detail', async ({ page }, testInfo) => {
|
||||
const current = latestSeed(basicSeed!)
|
||||
await registerSession(page, testInfo)
|
||||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||||
const firstCard = getSearchCard(page, current.skillName)
|
||||
await expect(firstCard).toBeVisible({ timeout: 8_000 })
|
||||
|
||||
await firstCard.focus()
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page).toHaveURL(/\/space\//)
|
||||
})
|
||||
|
||||
// TC_SEARCH_INTERACT_051 P1 - focus state visible on cards
|
||||
test('TC_SEARCH_INTERACT_051: focused card has a visible focus indicator', async ({ page }) => {
|
||||
const current = latestSeed(basicSeed!)
|
||||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||||
const firstCard = getSearchCard(page, current.skillName)
|
||||
await expect(firstCard).toBeVisible({ timeout: 8_000 })
|
||||
await firstCard.focus()
|
||||
const focused = page.locator(':focus')
|
||||
await expect(focused).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Error Handling ───────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Search Card Error Handling (Real API)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setEnglishLocale(page)
|
||||
})
|
||||
|
||||
// TC_SEARCH_INTERACT_033 P1 - single result displays correctly
|
||||
test('TC_SEARCH_INTERACT_033: single search result displays card layout correctly', async ({ page }) => {
|
||||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||||
const cards = await waitForCards(page)
|
||||
expect(await cards.count()).toBeGreaterThan(0)
|
||||
await expect(page.locator('body')).not.toContainText(/error|500/i)
|
||||
})
|
||||
|
||||
// TC_SEARCH_INTERACT_060 P1 - cache: returning to search page shows results quickly
|
||||
test('TC_SEARCH_INTERACT_060: returning to search page shows cached results quickly', async ({ page }, testInfo) => {
|
||||
await registerSession(page, testInfo)
|
||||
await page.goto(SEARCH_URL(basicSeed!.keyword))
|
||||
await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 })
|
||||
|
||||
await page.goto('/dashboard')
|
||||
await page.goBack()
|
||||
await expect(page).toHaveURL(/\/search/)
|
||||
await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 })
|
||||
})
|
||||
})
|
||||
324
web/e2e/search-page-full.spec.ts
Normal file
324
web/e2e/search-page-full.spec.ts
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
import { expect, test } from '@playwright/test'
|
||||
import { setEnglishLocale } from './helpers/auth-fixtures'
|
||||
import {
|
||||
DEFAULT_SEARCH_KEYWORD,
|
||||
getSearchCards,
|
||||
prepareSearchSeed,
|
||||
type PreparedSearchSeed,
|
||||
} from './helpers/search-seed'
|
||||
import { registerSession } from './helpers/session'
|
||||
|
||||
function searchUrl(query: string, sort = 'relevance', page = 0, starredOnly = false) {
|
||||
return `/search?q=${encodeURIComponent(query)}&sort=${sort}&page=${page}&starredOnly=${starredOnly}`
|
||||
}
|
||||
|
||||
let basicSeed: PreparedSearchSeed | undefined
|
||||
|
||||
test.setTimeout(300_000)
|
||||
|
||||
test.beforeAll(async ({ browser }, testInfo) => {
|
||||
test.setTimeout(300_000)
|
||||
basicSeed = await prepareSearchSeed(browser, testInfo, { count: 13 })
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await basicSeed?.dispose()
|
||||
basicSeed = undefined
|
||||
})
|
||||
|
||||
// ─── Search Input ────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Search Input (Real API)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setEnglishLocale(page)
|
||||
})
|
||||
|
||||
// TC_SEARCH_INPUT_001 P0
|
||||
test('TC_SEARCH_INPUT_001: searches with a single keyword and shows results', async ({ page }) => {
|
||||
await page.goto(searchUrl(basicSeed!.keyword))
|
||||
await expect(getSearchCards(page).first()).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
// TC_SEARCH_INPUT_003 P0 - empty search guidance
|
||||
test('TC_SEARCH_INPUT_003: empty search shows keyword guidance instead of a default list', async ({ page }) => {
|
||||
await page.goto(searchUrl(''))
|
||||
await expect(page).toHaveURL(/\/search/)
|
||||
await expect(page.getByRole('heading', { name: 'No results found' })).toBeVisible()
|
||||
await expect(page.getByText('Please enter a search keyword')).toBeVisible()
|
||||
})
|
||||
|
||||
// TC_SEARCH_INPUT_004 P0 - Enter key triggers search
|
||||
test('TC_SEARCH_INPUT_004: pressing Enter in search box triggers search', async ({ page }) => {
|
||||
await page.goto(searchUrl(''))
|
||||
const searchInput = page.getByPlaceholder('Search skills...')
|
||||
await searchInput.fill(basicSeed!.keyword)
|
||||
await searchInput.press('Enter')
|
||||
await expect(page).toHaveURL(new RegExp(`q=${basicSeed!.keyword}`))
|
||||
await expect(getSearchCards(page).first()).toBeVisible()
|
||||
})
|
||||
|
||||
// TC_SEARCH_INPUT_009 P0 - Chinese keyword search
|
||||
test('TC_SEARCH_INPUT_009: supports Chinese keyword search without error', async ({ page }) => {
|
||||
await page.goto(searchUrl('测试技能'))
|
||||
await expect(page).toHaveURL(/\/search/)
|
||||
await expect(page.locator('body')).not.toContainText(/error|500|crash/i)
|
||||
})
|
||||
|
||||
// TC_SEARCH_INPUT_010 P0 - English keyword search
|
||||
test('TC_SEARCH_INPUT_010: supports English keyword search', async ({ page }) => {
|
||||
await page.goto(searchUrl('skill'))
|
||||
await expect(page).toHaveURL(/q=skill/)
|
||||
await expect(page.locator('body')).not.toContainText(/error|500|crash/i)
|
||||
})
|
||||
|
||||
// TC_SEARCH_INPUT_007 P1 - special characters handled gracefully
|
||||
test('TC_SEARCH_INPUT_007: handles special characters in search without crashing', async ({ page }) => {
|
||||
await page.goto(searchUrl('@#$%'))
|
||||
await expect(page).toHaveURL(/\/search/)
|
||||
await expect(page.locator('body')).not.toContainText(/error|500|crash/i)
|
||||
})
|
||||
|
||||
// TC_SEARCH_INPUT_011 P1 - leading/trailing spaces trimmed
|
||||
test('TC_SEARCH_INPUT_011: trims leading and trailing spaces from search query', async ({ page }) => {
|
||||
await page.goto(searchUrl(''))
|
||||
const searchInput = page.getByPlaceholder('Search skills...')
|
||||
await searchInput.fill(` ${basicSeed!.keyword} `)
|
||||
await searchInput.press('Enter')
|
||||
await expect(page).toHaveURL(new RegExp(`q=${basicSeed!.keyword}`))
|
||||
await expect(getSearchCards(page).first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Sort / Filter ────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Search Sort and Filter (Authenticated Real API)', () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
await setEnglishLocale(page)
|
||||
await registerSession(page, testInfo)
|
||||
})
|
||||
|
||||
// TC_SEARCH_SORT_001 P0 - default relevance tab selected
|
||||
test('TC_SEARCH_SORT_001: relevance sort tab is selected by default', async ({ page }) => {
|
||||
await page.goto(searchUrl(basicSeed!.keyword, 'relevance'))
|
||||
await expect(page.getByRole('button', { name: 'Relevance' })).toBeVisible()
|
||||
})
|
||||
|
||||
// TC_SEARCH_SORT_004 P0 - downloads sort
|
||||
test('TC_SEARCH_SORT_004: clicking Downloads tab updates sort in URL', async ({ page }) => {
|
||||
await page.goto(searchUrl(basicSeed!.keyword, 'relevance'))
|
||||
await page.getByRole('button', { name: 'Downloads' }).click()
|
||||
await expect(page).toHaveURL(/sort=downloads/)
|
||||
})
|
||||
|
||||
// TC_SEARCH_SORT_005 P0 - newest sort
|
||||
test('TC_SEARCH_SORT_005: clicking Newest tab updates sort in URL', async ({ page }) => {
|
||||
await page.goto(searchUrl(basicSeed!.keyword, 'relevance'))
|
||||
await page.getByRole('button', { name: 'Newest' }).click()
|
||||
await expect(page).toHaveURL(/sort=newest|sort=created/)
|
||||
})
|
||||
|
||||
// TC_SEARCH_SORT_006 P0 - switching sort preserves search keyword
|
||||
test('TC_SEARCH_SORT_006: switching sort tab preserves the search keyword', async ({ page }) => {
|
||||
await page.goto(searchUrl(basicSeed!.keyword, 'relevance'))
|
||||
await page.getByRole('button', { name: 'Downloads' }).click()
|
||||
await expect(page).toHaveURL(new RegExp(`q=${basicSeed!.keyword}`))
|
||||
await expect(page).toHaveURL(/sort=downloads/)
|
||||
})
|
||||
|
||||
// TC_SEARCH_SORT_007 P0 - switching sort resets page to 0
|
||||
test('TC_SEARCH_SORT_007: switching sort tab resets page to 0', async ({ page }) => {
|
||||
await page.goto(searchUrl(basicSeed!.keyword, 'relevance', 1))
|
||||
await page.getByRole('button', { name: 'Downloads' }).click()
|
||||
await expect(page).toHaveURL(/page=0/)
|
||||
})
|
||||
|
||||
// TC_SEARCH_SORT_012 P1 - URL contains sort param
|
||||
test('TC_SEARCH_SORT_012: URL contains sort parameter after switching tabs', async ({ page }) => {
|
||||
await page.goto(searchUrl(basicSeed!.keyword, 'relevance'))
|
||||
await page.getByRole('button', { name: 'Downloads' }).click()
|
||||
await expect(page).toHaveURL(/sort=/)
|
||||
})
|
||||
|
||||
test('starred only filter stays on search page for authenticated user', async ({ page }) => {
|
||||
await page.goto(searchUrl(basicSeed!.keyword, 'relevance'))
|
||||
await page.getByRole('button', { name: 'Starred only' }).click()
|
||||
await expect(page).toHaveURL(/starredOnly=true/)
|
||||
await expect(page).not.toHaveURL(/\/login/)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Search Sort and Filter (Anonymous Real API)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setEnglishLocale(page)
|
||||
})
|
||||
|
||||
test('starred only filter redirects anonymous user to login', async ({ page }) => {
|
||||
await page.goto(searchUrl(DEFAULT_SEARCH_KEYWORD, 'relevance'))
|
||||
await page.getByRole('button', { name: 'Starred only' }).click()
|
||||
await expect(page).toHaveURL(/\/login\?returnTo=/)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Skill Count ──────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Search Skill Count Display (Real API)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setEnglishLocale(page)
|
||||
})
|
||||
|
||||
// TC_SEARCH_COUNT_001 P0 - count visible
|
||||
test('TC_SEARCH_COUNT_001: skill count indicator is visible on search page with results', async ({ page }) => {
|
||||
await page.goto(searchUrl(basicSeed!.keyword))
|
||||
await expect(page.getByText(/\d+\s+skills found/i)).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
// TC_SEARCH_COUNT_007 P0 - count updates after search
|
||||
test('TC_SEARCH_COUNT_007: skill count updates after performing a search', async ({ page }) => {
|
||||
await page.goto(searchUrl(''))
|
||||
const searchInput = page.getByPlaceholder('Search skills...')
|
||||
await searchInput.fill(basicSeed!.keyword)
|
||||
await searchInput.press('Enter')
|
||||
await expect(page.getByText(/\d+\s+skills found/i)).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
// TC_SEARCH_COUNT_009 P0 - zero results shows 0
|
||||
test('TC_SEARCH_COUNT_009: shows empty-state copy when search returns no results', async ({ page }) => {
|
||||
await page.goto(searchUrl('xyznonexistentkeyword99999'))
|
||||
await expect(page.getByRole('heading', { name: 'No results found' })).toBeVisible({ timeout: 8_000 })
|
||||
})
|
||||
|
||||
// TC_SEARCH_COUNT_008 P0 - count stays same when switching sort
|
||||
test('TC_SEARCH_COUNT_008: skill count remains the same after switching sort tab', async ({ page }) => {
|
||||
await page.goto(searchUrl(basicSeed!.keyword))
|
||||
const countText = await page.getByText(/\d+\s+skills found/i).textContent()
|
||||
const initialCount = Number(countText?.match(/\d+/)?.[0] ?? '0')
|
||||
await page.getByRole('button', { name: 'Downloads' }).click()
|
||||
const updatedCountText = await page.getByText(/\d+\s+skills found/i).textContent()
|
||||
const updatedCount = Number(updatedCountText?.match(/\d+/)?.[0] ?? '0')
|
||||
expect(updatedCount).toBe(initialCount)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Search Results ───────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Search Results (Real API)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setEnglishLocale(page)
|
||||
})
|
||||
|
||||
// TC_SEARCH_RESULT_001 P0 - results shown
|
||||
test('TC_SEARCH_RESULT_001: shows skill cards when search returns results', async ({ page }) => {
|
||||
await page.goto(searchUrl(basicSeed!.keyword))
|
||||
await expect(getSearchCards(page).first()).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
// TC_SEARCH_RESULT_002 P0 - no results message
|
||||
test('TC_SEARCH_RESULT_002: shows empty state message when no results found', async ({ page }) => {
|
||||
await page.goto(searchUrl('xyznonexistentkeyword99999'))
|
||||
await expect(page.getByRole('heading', { name: 'No results found' })).toBeVisible({ timeout: 8_000 })
|
||||
})
|
||||
|
||||
// TC_SEARCH_RESULT_006 P0 - loading state
|
||||
test('TC_SEARCH_RESULT_006: page renders without error during and after search', async ({ page }) => {
|
||||
await page.goto(searchUrl(basicSeed!.keyword))
|
||||
await expect(page.locator('body')).not.toContainText(/error|500|crash/i)
|
||||
})
|
||||
|
||||
// TC_SEARCH_RESULT_008 P0 - result count matches cards
|
||||
test('TC_SEARCH_RESULT_008: number of displayed cards matches the count indicator', async ({ page }) => {
|
||||
await page.goto(searchUrl(basicSeed!.keyword))
|
||||
await page.waitForLoadState('networkidle')
|
||||
const cards = getSearchCards(page)
|
||||
const visibleCount = await cards.count()
|
||||
const countText = await page.getByText(/\d+\s+skills found/i).textContent()
|
||||
const totalMatch = countText?.match(/\d+/)
|
||||
expect(totalMatch).toBeTruthy()
|
||||
expect(visibleCount).toBeGreaterThan(0)
|
||||
expect(Number(totalMatch?.[0])).toBeGreaterThanOrEqual(visibleCount)
|
||||
})
|
||||
|
||||
// TC_SEARCH_RESULT_009 P0 - downloads sort order
|
||||
test('TC_SEARCH_RESULT_009: results are sorted by downloads when Downloads tab is selected', async ({ page }) => {
|
||||
await page.goto(searchUrl(basicSeed!.keyword, 'downloads'))
|
||||
await expect(page).toHaveURL(/sort=downloads/)
|
||||
await expect(page.locator('body')).not.toContainText(/error|500/i)
|
||||
})
|
||||
|
||||
// TC_SEARCH_RESULT_010 P0 - newest sort order
|
||||
test('TC_SEARCH_RESULT_010: results are sorted by newest when Newest tab is selected', async ({ page }) => {
|
||||
await page.goto(searchUrl(basicSeed!.keyword, 'newest'))
|
||||
await expect(page.locator('body')).not.toContainText(/error|500/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Pagination ───────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Search Pagination (Real API)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setEnglishLocale(page)
|
||||
})
|
||||
|
||||
// TC_SEARCH_PAGE_011 P1 - URL contains page param
|
||||
test('TC_SEARCH_PAGE_011: URL contains page parameter', async ({ page }) => {
|
||||
await page.goto(searchUrl(basicSeed!.keyword, 'relevance', 0))
|
||||
await expect(page).toHaveURL(/page=/)
|
||||
})
|
||||
|
||||
// TC_SEARCH_PAGE_012 P0 - switching page preserves search and sort
|
||||
test('TC_SEARCH_PAGE_012: switching page preserves search keyword and sort', async ({ page }) => {
|
||||
await page.goto(searchUrl(basicSeed!.keyword, 'downloads', 0))
|
||||
const nextBtn = page.getByRole('button', { name: /next|›|»/i })
|
||||
await expect(nextBtn).toBeVisible({ timeout: 10_000 })
|
||||
await nextBtn.click()
|
||||
await expect(page).toHaveURL(new RegExp(`q=${basicSeed!.keyword}`))
|
||||
await expect(page).toHaveURL(/sort=downloads/)
|
||||
await expect(page).toHaveURL(/page=1/)
|
||||
})
|
||||
|
||||
// TC_SEARCH_PAGE_007 P0 - first page disables previous button
|
||||
test('TC_SEARCH_PAGE_007: previous page button is disabled on first page', async ({ page }) => {
|
||||
await page.goto(searchUrl(basicSeed!.keyword, 'relevance', 0))
|
||||
const prevBtn = page.getByRole('button', { name: /prev|‹|«/i })
|
||||
if (await prevBtn.isVisible()) {
|
||||
await expect(prevBtn).toBeDisabled()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Security ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Search Security (Real API)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setEnglishLocale(page)
|
||||
})
|
||||
|
||||
// TC_SEARCH_SEC_001 P0 - XSS in search box
|
||||
test('TC_SEARCH_SEC_001: XSS payload in search box is not executed', async ({ page }) => {
|
||||
let alerted = false
|
||||
page.on('dialog', () => { alerted = true })
|
||||
|
||||
await page.goto(searchUrl(''))
|
||||
const searchInput = page.getByPlaceholder('Search skills...')
|
||||
await searchInput.fill("<script>alert('xss')</script>")
|
||||
await searchInput.press('Enter')
|
||||
|
||||
await page.waitForTimeout(1_000)
|
||||
expect(alerted).toBe(false)
|
||||
await expect(page.locator('body')).not.toContainText(/error|500/i)
|
||||
})
|
||||
|
||||
// TC_SEARCH_SEC_002 P0 - SQL injection in search box
|
||||
test('TC_SEARCH_SEC_002: SQL injection payload in search box is handled safely', async ({ page }) => {
|
||||
await page.goto(searchUrl("' OR '1'='1"))
|
||||
await expect(page.locator('body')).not.toContainText(/sql|syntax error|database/i)
|
||||
await expect(page).toHaveURL(/\/search/)
|
||||
})
|
||||
|
||||
// TC_SEARCH_SEC_003 P1 - URL param tampering
|
||||
test('TC_SEARCH_SEC_003: tampered URL parameters are handled gracefully', async ({ page }, testInfo) => {
|
||||
await registerSession(page, testInfo)
|
||||
await page.goto('/search?q=agent&sort=INVALID_SORT&page=-1&starredOnly=invalid')
|
||||
await expect(page).toHaveURL(/\/search/)
|
||||
await expect(page.locator('body')).not.toContainText(/error|500|crash/i)
|
||||
})
|
||||
})
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Card
|
||||
className="h-full p-5 cursor-pointer group relative overflow-hidden bg-white border shadow-sm transition-shadow hover:shadow-md"
|
||||
style={{ borderColor: 'hsl(var(--border-card))' }}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-lg group-hover:text-primary transition-colors" style={{ color: 'hsl(var(--foreground))' }}>
|
||||
{skill.displayName}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<NamespaceBadge type="TEAM" name={`@${skill.namespace}`} />
|
||||
</div>
|
||||
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}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-lg group-hover:text-primary transition-colors" style={{ color: 'hsl(var(--foreground))' }}>
|
||||
{skill.displayName}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{skill.summary && (
|
||||
<p className="text-sm text-muted-foreground mb-4 line-clamp-2 leading-relaxed">
|
||||
{skill.summary}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-auto flex items-center gap-4 text-xs text-muted-foreground">
|
||||
{headlineVersion && (
|
||||
<span className="px-2.5 py-1 rounded-full bg-secondary/60 font-mono">
|
||||
v{headlineVersion.version}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
|
||||
</svg>
|
||||
{formatCompactCount(skill.downloadCount)}
|
||||
</span>
|
||||
<span
|
||||
className={`flex items-center gap-1 ${showStarredHighlight ? 'font-semibold text-primary' : ''}`}
|
||||
>
|
||||
<Bookmark className={`w-3.5 h-3.5 ${showStarredHighlight ? 'fill-current' : ''}`} />
|
||||
{skill.starCount}
|
||||
</span>
|
||||
{skill.ratingAvg !== undefined && skill.ratingCount > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-3.5 h-3.5 text-primary" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
{skill.ratingAvg.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<NamespaceBadge type="TEAM" name={`@${skill.namespace}`} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{skill.summary && (
|
||||
<p className="text-sm text-muted-foreground mb-4 line-clamp-2 leading-relaxed">
|
||||
{skill.summary}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-auto flex items-center gap-4 text-xs text-muted-foreground">
|
||||
{headlineVersion && (
|
||||
<span className="px-2.5 py-1 rounded-full bg-secondary/60 font-mono">
|
||||
v{headlineVersion.version}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
|
||||
</svg>
|
||||
{formatCompactCount(skill.downloadCount)}
|
||||
</span>
|
||||
<span
|
||||
className={`flex items-center gap-1 ${showStarredHighlight ? 'font-semibold text-primary' : ''}`}
|
||||
>
|
||||
<Bookmark className={`w-3.5 h-3.5 ${showStarredHighlight ? 'fill-current' : ''}`} />
|
||||
{skill.starCount}
|
||||
</span>
|
||||
{skill.ratingAvg !== undefined && skill.ratingCount > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-3.5 h-3.5 text-primary" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
{skill.ratingAvg.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?",
|
||||
|
|
|
|||
|
|
@ -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": "已有账号?",
|
||||
|
|
|
|||
|
|
@ -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<RegisterFieldErrors>({})
|
||||
const [formError, setFormError] = useState<string | null>(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<HTMLFormElement>) {
|
||||
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 ? <p className="text-sm text-red-600">{fieldErrors.username}</p> : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="register-email">{t('register.email')}</label>
|
||||
|
|
@ -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 ? <p className="text-sm text-red-600">{fieldErrors.email}</p> : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="register-password">{t('register.password')}</label>
|
||||
|
|
@ -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 ? <p className="text-sm text-red-600">{fieldErrors.password}</p> : null}
|
||||
</div>
|
||||
{registerMutation.error ? (
|
||||
<p className="text-sm text-red-600">{registerMutation.error.message}</p>
|
||||
) : null}
|
||||
{formError ? <p className="text-sm text-red-600">{formError}</p> : null}
|
||||
<Button className="w-full" disabled={registerMutation.isPending} type="submit">
|
||||
{registerMutation.isPending ? t('register.submitting') : t('register.submit')}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
@ -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.
|
||||
*
|
||||
|
|
@ -60,11 +91,26 @@ 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) {
|
||||
blurActiveElement()
|
||||
const cleanupScroll = scrollToTopOnPageChange()
|
||||
|
||||
previousPageRef.current = page
|
||||
return () => {
|
||||
cleanupScroll()
|
||||
}
|
||||
}
|
||||
|
||||
previousPageRef.current = page
|
||||
}, [page])
|
||||
|
||||
const { data, isLoading, isFetching } = useSearchSkills({
|
||||
q,
|
||||
label: selectedLabel || undefined,
|
||||
|
|
@ -79,6 +125,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 +164,7 @@ export function SearchPage() {
|
|||
}
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
blurActiveElement()
|
||||
navigate({ to: '/search', search: { q, label: selectedLabel, sort, page: newPage, starredOnly } })
|
||||
}
|
||||
|
||||
|
|
@ -154,10 +202,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 (
|
||||
<div className={APP_SHELL_PAGE_CLASS_NAME}>
|
||||
|
|
@ -265,7 +313,9 @@ export function SearchPage() {
|
|||
<EmptyState
|
||||
title={starredOnly ? t('search.noStarredResults') : t('search.noResults')}
|
||||
description={
|
||||
starredOnly
|
||||
shouldShowGuidance
|
||||
? t('search.enterKeyword')
|
||||
: starredOnly
|
||||
? (q ? t('search.noStarredResultsFor', { q }) : t('search.noStarredSkills'))
|
||||
: (q ? t('search.noResultsFor', { q }) : t('search.enterKeyword'))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export function useSearchSkills(params: SearchParams) {
|
|||
return useQuery({
|
||||
queryKey: ['skills', 'search', params],
|
||||
queryFn: () => searchSkills(params),
|
||||
enabled: params.starredOnly !== true,
|
||||
enabled: params.starredOnly !== true && Boolean(params.q || params.label),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue