Merge pull request #269 from iflytek/pr/exclude-playwright-report

fix register validation, review visibility, and search e2e coverage
This commit is contained in:
tenten-shih 2026-04-09 14:37:46 +05:30 committed by GitHub
commit 77e271f24c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 2366 additions and 144 deletions

View file

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

View file

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

View file

@ -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());
}
}

View file

@ -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());
}
}

View file

@ -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());

View file

@ -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());
}

View 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'),
})
}

View file

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

View file

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

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

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

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

View file

@ -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',

View file

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

View file

@ -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?",

View file

@ -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": "已有账号?",

View file

@ -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>

View file

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

View file

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