Merge PR 950 for metadata consistency validation

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-10-15 13:40:33 +08:00
commit 3b61d2365f
19 changed files with 328 additions and 20 deletions

View file

@ -40,6 +40,7 @@ description: When to use # 必需,1-2 句话
解析规则:
- `name` 和 `description` 为必需字段,缺失则校验失败
- `name` 映射为 `skill.slug`(首次发布时),后续版本不可变更
- 可选的 `metadata.display-name` 映射为 `skill.display_name`,不参与 slug 生成。必须是字符串,去除首尾空白后最多 200 个字符;缺失、null 或空白时回退到 `name`。展示名沿用版本发布/审核流程,待审核更新不覆盖已发布标题;撤下最新版本并回退到旧版本时,标题和摘要同步恢复到该旧版本。
- `description` 映射为 `skill.summary`
- frontmatter 完整解析结果存入 `skill_version.parsed_metadata_json`

View file

@ -123,7 +123,7 @@ Status: current code-aligned reference
### 4.8 Yank 后指针修正
- yank 已发布版本时,若命中当前 `latestVersionId`,必须重算 latest published pointer
- yank 已发布版本时,若命中当前 `latestVersionId`,必须重算 latest published pointer,并从回退版本的 metadata 恢复展示名和摘要
- 若仍有其他 `PUBLISHED` 版本,则指向最新一个
- 若已无任何 `PUBLISHED` 版本,则 `latestVersionId = null`

View file

@ -72,6 +72,26 @@ When packaging an existing skill, keep its required scripts, references,
`agents/` metadata, and license files together. Pass that skill directory
(`./my-skill` in this example) to the publish command below.
To show a human-readable or localized title in lists, search results, and detail pages,
add the optional `metadata.display-name` field:
```yaml
---
name: example-skill
description: An example skill for demonstration.
metadata:
display-name: 示例技能
version: "1.0.0"
---
```
`name` still determines the slug, installation coordinate, and links. The display name
must be a string of at most 200 characters after trimming surrounding whitespace.
Missing, null, or blank display names fall back to `name`. Titles follow the version's
review lifecycle: an update awaiting review does not replace an existing published title.
No database migration is needed; existing skills can keep their `name` and publish a new
version with this optional metadata.
2. **Publish via CLI (Recommended)**
```bash

View file

@ -68,6 +68,23 @@ version: 1.0.0
Summarize the supplied text as numbered steps.
```
如需在列表、搜索结果和详情页显示中文或其他易读名称,可添加可选的 `metadata.display-name`:
```yaml
---
name: example-skill
description: 用于演示的示例技能。
metadata:
display-name: 示例技能
version: "1.0.0"
---
```
`name` 仍用于生成 slug、安装坐标和链接;`metadata.display-name` 仅用于展示。
展示名必须是字符串,去除首尾空白后最多 200 个字符;未填写、为 null 或空白时回退到 `name`。
展示名随技能版本发布:已有公开版本的标题在更新审核通过后才改变。
无需数据库迁移;已有技能可保持原 `name`,增加展示名后发布新版本。
打包已有技能时,应一并保留其所需的脚本、参考文件、`agents/` 元数据和许可证文件。
下一步发布命令的路径应指向该技能目录(本例为 `./my-skill`)。

View file

@ -335,3 +335,6 @@ error.authoring.stream.tooManySubscribers=Too many validation event subscribers.
error.skill.avatar.uploadExpired=The uploaded avatar expired. Please upload it again.
error.skill.avatar.invalidUpload=Select a valid uploaded avatar before submitting.
error.skill.avatar.skillMismatch=The uploaded package does not match the skill being updated.
error.skill.metadata.displayName.invalid=metadata.display-name must be a string
error.skill.metadata.displayName.tooLong=metadata.display-name must not exceed {0} characters

View file

@ -278,3 +278,6 @@ error.authoring.stream.tooManySubscribers=Достигнут предел под
error.skill.avatar.uploadExpired=Срок действия загрузки аватара истёк. Загрузите изображение снова.
error.skill.avatar.invalidUpload=Перед отправкой выберите успешно загруженный аватар.
error.skill.avatar.skillMismatch=Загруженный пакет не соответствует обновляемому навыку.
error.skill.metadata.displayName.invalid=metadata.display-name должно быть строкой
error.skill.metadata.displayName.tooLong=metadata.display-name не должно превышать {0} символов

View file

@ -337,3 +337,6 @@ error.authoring.stream.tooManySubscribers=校验事件连接数量已达上限
error.skill.avatar.uploadExpired=头像上传引用已过期,请重新上传。
error.skill.avatar.invalidUpload=请选择已上传成功的头像后再提交。
error.skill.avatar.skillMismatch=上传的技能包与正在更新的技能不一致。
error.skill.metadata.displayName.invalid=metadata.display-name 必须是字符串
error.skill.metadata.displayName.tooLong=metadata.display-name 不能超过 {0} 个字符

View file

@ -9,6 +9,7 @@ import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.event.SkillVersionYankedEvent;
import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
@ -130,6 +131,7 @@ class SkillYankLockingTest {
eventPublisher,
mock(SecurityScanService.class),
mock(SkillStorageDeletionCompensationService.class),
new ObjectMapper(),
Clock.systemUTC());
CountDownLatch firstAudited = new CountDownLatch(1);
CountDownLatch secondReady = new CountDownLatch(1);

View file

@ -413,7 +413,7 @@ public class ReviewService {
try {
SkillMetadata metadata = objectMapper.readValue(metadataJson, SkillMetadata.class);
skill.setDisplayName(metadata.name());
skill.setDisplayName(metadata.displayName());
skill.setSummary(metadata.description());
} catch (Exception e) {
throw new IllegalStateException("Failed to deserialize skill metadata", e);

View file

@ -1,5 +1,6 @@
package com.iflytek.skillhub.domain.skill.metadata;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import java.util.Map;
public record SkillMetadata(
@ -8,4 +9,34 @@ public record SkillMetadata(
String version,
String body,
Map<String, Object> frontmatter
) {}
) {
public SkillMetadata {
resolveDisplayName(name, frontmatter);
}
/** Human-readable label; the package name remains the source of the skill slug. */
public String displayName() {
return resolveDisplayName(name, frontmatter);
}
private static String resolveDisplayName(String name, Map<String, Object> frontmatter) {
if (frontmatter == null || !(frontmatter.get("metadata") instanceof Map<?, ?> metadata)) {
return name;
}
Object value = metadata.get("display-name");
if (value == null) {
return name;
}
if (!(value instanceof String text)) {
throw new DomainBadRequestException("error.skill.metadata.displayName.invalid");
}
String displayName = text.strip();
if (displayName.isEmpty()) {
return name;
}
if (displayName.length() > 200) {
throw new DomainBadRequestException("error.skill.metadata.displayName.tooLong", 200);
}
return displayName;
}
}

View file

@ -1,5 +1,6 @@
package com.iflytek.skillhub.domain.skill.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.domain.audit.AuditDetail;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.event.SkillStatusChangedEvent;
@ -17,12 +18,14 @@ import com.iflytek.skillhub.domain.skill.SkillStatus;
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.metadata.SkillMetadata;
import com.iflytek.skillhub.storage.ObjectStorageService;
import java.time.Clock;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationEventPublisher;
@ -49,6 +52,7 @@ public class SkillGovernanceService {
private final ApplicationEventPublisher eventPublisher;
private final SecurityScanService securityScanService;
private final SkillStorageDeletionCompensationService compensationService;
private final ObjectMapper objectMapper;
private final Clock clock;
public SkillGovernanceService(SkillRepository skillRepository,
@ -60,6 +64,7 @@ public class SkillGovernanceService {
ApplicationEventPublisher eventPublisher,
SecurityScanService securityScanService,
SkillStorageDeletionCompensationService compensationService,
ObjectMapper objectMapper,
Clock clock) {
this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository;
@ -70,6 +75,7 @@ public class SkillGovernanceService {
this.eventPublisher = eventPublisher;
this.securityScanService = securityScanService;
this.compensationService = compensationService;
this.objectMapper = objectMapper;
this.clock = clock;
}
@ -305,7 +311,9 @@ public class SkillGovernanceService {
SkillVersion saved = skillVersionRepository.save(version);
skillRepository.findById(version.getSkillId()).ifPresent(skill -> {
if (versionId.equals(skill.getLatestVersionId())) {
skill.setLatestVersionId(findLatestPublishedVersionId(skill.getId()));
Optional<SkillVersion> fallback = findLatestPublishedVersion(skill.getId());
skill.setLatestVersionId(fallback.map(SkillVersion::getId).orElse(null));
fallback.ifPresent(published -> applyPublishedMetadata(skill, published));
skill.setUpdatedBy(actorUserId);
skillRepository.save(skill);
}
@ -317,13 +325,29 @@ public class SkillGovernanceService {
}
private Long findLatestPublishedVersionId(Long skillId) {
return findLatestPublishedVersion(skillId).map(SkillVersion::getId).orElse(null);
}
private Optional<SkillVersion> findLatestPublishedVersion(Long skillId) {
return skillVersionRepository.findBySkillIdAndStatus(skillId, SkillVersionStatus.PUBLISHED).stream()
.max(java.util.Comparator
.comparing(SkillVersion::getPublishedAt, java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder()))
.thenComparing(SkillVersion::getCreatedAt, java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder()))
.thenComparing(SkillVersion::getId, java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder())))
.map(SkillVersion::getId)
.orElse(null);
.thenComparing(SkillVersion::getId, java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder())));
}
private void applyPublishedMetadata(Skill skill, SkillVersion version) {
String metadataJson = version.getParsedMetadataJson();
if (metadataJson == null || metadataJson.isBlank()) {
return;
}
try {
SkillMetadata metadata = objectMapper.readValue(metadataJson, SkillMetadata.class);
skill.setDisplayName(metadata.displayName());
skill.setSummary(metadata.description());
} catch (Exception e) {
throw new IllegalStateException("Failed to deserialize skill metadata", e);
}
}
private void assertCanManageLifecycle(Skill skill,

View file

@ -708,8 +708,11 @@ public class SkillPublishService {
}
// 12. Update skill metadata and move the published pointer for auto-publish flows
skill.setDisplayName(metadata.name());
skill.setSummary(metadata.description());
// A pending update must not replace the currently published title or summary.
if (autoPublish || visibility == SkillVisibility.PRIVATE || skill.getLatestVersionId() == null) {
skill.setDisplayName(metadata.displayName());
skill.setSummary(metadata.description());
}
if (autoPublish || visibility == SkillVisibility.PRIVATE) {
// Update latestVersionId for autoPublish or PRIVATE skill (UPLOADED status)
skill.setLatestVersionId(version.getId());

View file

@ -225,8 +225,9 @@ class ReviewServiceTest {
@Nested
class ApproveReview {
@Test
void shouldApproveReviewSuccessfully() {
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
void shouldApproveReviewSuccessfully(boolean hasDisplayName) {
ReviewTask task = createPendingReviewTask();
Namespace ns = createTeamNamespace();
SkillVersion sv = createPendingReviewSkillVersion();
@ -235,7 +236,8 @@ class ReviewServiceTest {
skill.setSummary("Published Summary");
skill.setUpdatedBy("previous-reviewer");
assertDoesNotThrow(() -> sv.setParsedMetadataJson(objectMapper.writeValueAsString(
new SkillMetadata("Approved Name", "Approved Summary", "1.0.0", "Body", Map.of())
new SkillMetadata("Approved Name", "Approved Summary", "1.0.0", "Body",
hasDisplayName ? Map.of("metadata", Map.of("display-name", "已审核的中文标题")) : Map.of())
)));
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
@ -262,7 +264,8 @@ class ReviewServiceTest {
assertEquals(SkillVersionStatus.PUBLISHED, sv.getStatus());
assertEquals(Instant.now(CLOCK), sv.getPublishedAt());
assertEquals(SKILL_VERSION_ID, skill.getLatestVersionId());
assertEquals("Approved Name", skill.getDisplayName());
assertEquals(hasDisplayName ? "已审核的中文标题" : "Approved Name", skill.getDisplayName());
assertEquals("my-skill", skill.getSlug());
assertEquals("Approved Summary", skill.getSummary());
assertEquals(REVIEWER_ID, skill.getUpdatedBy());
verify(eventPublisher).publishEvent(any(SkillPublishedEvent.class));

View file

@ -11,6 +11,56 @@ class SkillMetadataParserTest {
private final SkillMetadataParser parser = new SkillMetadataParser();
@Test
void parsesDisplayNameWithoutChangingPackageName() {
SkillMetadata metadata = parser.parse("""
---
name: example-skill
description: An example skill for demonstration
metadata:
version: "1.0.0"
display-name: " 示例技能 "
---
Instructions
""");
assertEquals("示例技能", metadata.displayName());
assertEquals("example-skill", metadata.name());
assertEquals("1.0.0", metadata.version());
assertEquals("示例技能", new com.fasterxml.jackson.databind.ObjectMapper()
.convertValue(metadata, SkillMetadata.class).displayName());
}
@Test
void fallsBackToPackageNameForMissingOrBlankDisplayName() {
for (String extra : new String[] {"", "metadata: {}", "metadata:\n display-name: null",
"metadata:\n display-name: ' '"}) {
SkillMetadata metadata = parser.parse("---\nname: my-skill\ndescription: Test\n"
+ extra + "\n---\nBody");
assertEquals("my-skill", metadata.displayName());
}
assertEquals("old-skill", new SkillMetadata("old-skill", "Test", "1.0.0", "Body", null).displayName());
}
@Test
void rejectsNonStringDisplayNames() {
for (String value : new String[] {"123", "true", "[one, two]", "{label: test}"}) {
DomainBadRequestException error = assertThrows(DomainBadRequestException.class,
() -> parser.parse("---\nname: my-skill\ndescription: Test\nmetadata:\n display-name: "
+ value + "\n---\nBody"));
assertEquals("error.skill.metadata.displayName.invalid", error.messageCode());
}
}
@Test
void validatesDisplayNameLengthBeforePublishing() {
String template = "---\nname: my-skill\ndescription: Test\nmetadata:\n display-name: %s\n---\nBody";
assertEquals("中".repeat(200), parser.parse(template.formatted("中".repeat(200))).displayName());
DomainBadRequestException error = assertThrows(DomainBadRequestException.class,
() -> parser.parse(template.formatted("中".repeat(201))));
assertEquals("error.skill.metadata.displayName.tooLong", error.messageCode());
}
@Test
void testParseStandardFrontmatterAndBody() {
String content = """

View file

@ -11,6 +11,7 @@ import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.event.SkillStatusChangedEvent;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
@ -36,6 +37,8 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ -81,6 +84,7 @@ class SkillGovernanceServiceTest {
eventPublisher,
securityScanService,
compensationService,
new ObjectMapper(),
CLOCK
);
}
@ -250,8 +254,9 @@ class SkillGovernanceServiceTest {
verify(objectStorageService, never()).deleteObject(any());
}
@Test
void yankVersion_recomputesLatestPublishedPointer() {
@ParameterizedTest
@ValueSource(booleans = {false, true})
void yankVersion_restoresLatestPublishedMetadata(boolean hasDisplayName) throws Exception {
SkillVersion yanked = new SkillVersion(2L, "2.0.0", "owner");
setField(yanked, "id", 22L);
yanked.setStatus(SkillVersionStatus.PUBLISHED);
@ -261,10 +266,18 @@ class SkillGovernanceServiceTest {
setField(fallback, "id", 11L);
fallback.setStatus(SkillVersionStatus.PUBLISHED);
fallback.setPublishedAt(Instant.parse("2026-03-17T10:00:00Z"));
fallback.setParsedMetadataJson(new ObjectMapper().writeValueAsString(Map.of(
"name", "example-skill",
"description", "Published summary from v1",
"frontmatter", hasDisplayName
? Map.of("metadata", Map.of("display-name", "示例技能一"))
: Map.of())));
Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC);
setField(skill, "id", 2L);
skill.setLatestVersionId(22L);
skill.setDisplayName("示例技能二");
skill.setSummary("Published summary from v2");
given(skillVersionRepository.findByIdForUpdate(22L)).willReturn(Optional.of(yanked));
given(skillVersionRepository.save(yanked)).willReturn(yanked);
@ -275,9 +288,57 @@ class SkillGovernanceServiceTest {
service.yankVersion(22L, "admin", "127.0.0.1", "JUnit", "broken");
assertThat(skill.getLatestVersionId()).isEqualTo(11L);
assertThat(skill.getDisplayName()).isEqualTo(hasDisplayName ? "示例技能一" : "example-skill");
assertThat(skill.getSummary()).isEqualTo("Published summary from v1");
verify(skillRepository).save(skill);
}
@Test
void yankVersion_preservesMetadataWhenYankingAnOlderVersion() {
SkillVersion older = new SkillVersion(2L, "1.0.0", "owner");
setField(older, "id", 11L);
older.setStatus(SkillVersionStatus.PUBLISHED);
Skill skill = new Skill(1L, "example-skill", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC);
setField(skill, "id", 2L);
skill.setLatestVersionId(22L);
skill.setDisplayName("示例技能二");
skill.setSummary("Published summary from v2");
given(skillVersionRepository.findByIdForUpdate(11L)).willReturn(Optional.of(older));
given(skillVersionRepository.save(older)).willReturn(older);
given(skillRepository.findById(2L)).willReturn(Optional.of(skill));
service.yankVersion(11L, "admin", "127.0.0.1", "JUnit", "broken");
assertThat(skill.getLatestVersionId()).isEqualTo(22L);
assertThat(skill.getDisplayName()).isEqualTo("示例技能二");
assertThat(skill.getSummary()).isEqualTo("Published summary from v2");
verify(skillRepository, never()).save(any());
}
@Test
void yankVersion_keepsHistoricalMetadataWhenNoPublishedVersionRemains() {
SkillVersion version = new SkillVersion(2L, "1.0.0", "owner");
setField(version, "id", 11L);
version.setStatus(SkillVersionStatus.PUBLISHED);
Skill skill = new Skill(1L, "example-skill", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC);
setField(skill, "id", 2L);
skill.setLatestVersionId(11L);
skill.setDisplayName("示例技能");
skill.setSummary("Historical summary");
given(skillVersionRepository.findByIdForUpdate(11L)).willReturn(Optional.of(version));
given(skillVersionRepository.save(version)).willReturn(version);
given(skillRepository.findById(2L)).willReturn(Optional.of(skill));
given(skillVersionRepository.findBySkillIdAndStatus(2L, SkillVersionStatus.PUBLISHED))
.willReturn(java.util.List.of());
given(skillRepository.save(skill)).willReturn(skill);
service.yankVersion(11L, "admin", "127.0.0.1", "JUnit", "broken");
assertThat(skill.getLatestVersionId()).isNull();
assertThat(skill.getDisplayName()).isEqualTo("示例技能");
assertThat(skill.getSummary()).isEqualTo("Historical summary");
}
@Test
void deleteVersion_removesDraftFilesAndBundle() {
Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC);

View file

@ -1931,6 +1931,45 @@ class SkillPublishServiceTest {
verify(skillVersionRepository, never()).save(any());
}
@Test
void publishesDisplayNameWithoutChangingSlugOrVersionMetadataName() throws Exception {
PublishFixture fixture = stubValidPublishInputs("test-ns", "user-100", "test-skill", "test-skill", "1.0.0", false);
when(skillMetadataParser.parse(anyString())).thenReturn(new SkillMetadata(
"test-skill", "Test", "1.0.0", "Body",
Map.of("metadata", Map.of("display-name", "示例技能"))));
SkillPublishService.PublishResult result = service.publishFromEntries(
"test-ns", fixture.entries(), "user-100", SkillVisibility.PUBLIC, Set.of("SUPER_ADMIN"));
assertEquals("test-skill", result.slug());
ArgumentCaptor<Skill> saved = ArgumentCaptor.forClass(Skill.class);
verify(skillRepository).save(saved.capture());
assertEquals("示例技能", saved.getValue().getDisplayName());
JsonNode snapshot = objectMapper.readTree(result.version().getParsedMetadataJson());
assertEquals("test-skill", snapshot.path("name").asText());
assertEquals("示例技能", snapshot.path("frontmatter").path("metadata").path("display-name").asText());
}
@Test
void pendingUpdateKeepsPublishedDisplayNameAndSummary() throws Exception {
PublishFixture fixture = stubValidPublishInputs("test-ns", "user-100", "test-skill", "test-skill", "2.0.0", true);
Skill skill = skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "test-skill", "user-100").orElseThrow();
skill.setLatestVersionId(9L);
skill.setDisplayName("已审核的标题");
skill.setSummary("Published summary");
when(skillMetadataParser.parse(anyString())).thenReturn(new SkillMetadata(
"test-skill", "Pending summary", "2.0.0", "Body",
Map.of("metadata", Map.of("display-name", "待审核的标题"))));
SkillPublishService.PublishResult result = service.publishFromEntries(
"test-ns", fixture.entries(), "user-100", SkillVisibility.PUBLIC, Set.of());
assertEquals(SkillVersionStatus.PENDING_REVIEW, result.version().getStatus());
assertEquals("已审核的标题", skill.getDisplayName());
assertEquals("Published summary", skill.getSummary());
assertEquals(9L, skill.getLatestVersionId());
}
private record PublishFixture(List<PackageEntry> entries) {
}

View file

@ -58,6 +58,7 @@ interface ApiFailure extends Error {
const cleanupTimeoutMs = process.env.CI ? 8_000 : 5_000
export interface SeedSkillOptions {
displayName?: string
name?: string
description?: string
version?: string
@ -109,7 +110,7 @@ function buildSkillPackageContent(suffix: string, options?: SeedSkillOptions) {
name: ${skillName}
description: ${description}
version: ${version}
---
${options?.displayName === undefined ? '' : `metadata:\n display-name: ${JSON.stringify(options.displayName)}\n`}---
# ${readmeHeading}

View file

@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test'
import { setEnglishLocale } from './helpers/auth-fixtures'
import { registerSession } from './helpers/session'
import { loginWithCredentials, registerSession } from './helpers/session'
import { E2eTestDataBuilder } from './helpers/test-data-builder'
test.describe('My Skills Navigation (Real API)', () => {
@ -9,22 +9,46 @@ test.describe('My Skills Navigation (Real API)', () => {
await registerSession(page, testInfo)
})
test('opens seeded skill detail from dashboard list and returns back', async ({ page }, testInfo) => {
test('shows a localized title while navigating with the original skill slug', async ({ page, browser }, testInfo) => {
const builder = new E2eTestDataBuilder(page, testInfo)
await builder.init()
try {
const namespace = await builder.ensureWritableNamespace()
const skill = await builder.publishSkill(namespace.slug)
const displayName = '示例技能'
const skill = await builder.publishSkill(namespace.slug, { displayName })
await page.goto('/dashboard/skills')
await expect(page.getByRole('heading', { name: 'My Skills' })).toBeVisible()
await page.getByText(`@${skill.namespace}`).first().click()
await expect(page.getByRole('heading', { name: displayName, exact: true })).toBeVisible()
await page.getByRole('heading', { name: displayName, exact: true }).click()
await expect(page).toHaveURL(new RegExp(`/space/${skill.namespace}/${skill.slug}\\?returnTo=%2Fdashboard%2Fskills$`))
await expect(page.getByRole('heading', { name: displayName, exact: true })).toBeVisible()
await page.getByRole('button', { name: 'Back' }).click()
await expect(page).toHaveURL('/dashboard/skills')
const adminContext = await browser.newContext()
try {
const adminPage = await adminContext.newPage()
await loginWithCredentials(adminPage, {
username: process.env.E2E_ADMIN_USERNAME ?? process.env.BOOTSTRAP_ADMIN_USERNAME ?? 'admin',
password: process.env.E2E_ADMIN_PASSWORD ?? process.env.BOOTSTRAP_ADMIN_PASSWORD ?? 'ChangeMe!2026',
}, testInfo)
const adminBuilder = new E2eTestDataBuilder(adminPage, testInfo)
await adminBuilder.init()
const reviewId = await adminBuilder.waitForPendingReview(skill.namespace, skill.slug, skill.version)
await adminBuilder.approveReview(reviewId)
} finally {
await adminContext.close()
}
// The Chinese query appears only in display-name, not the slug or description.
await builder.waitForSearchResult(displayName, skill.slug)
await page.goto(`/search?q=${encodeURIComponent(displayName)}`)
await expect(page.getByRole('heading', { name: displayName, exact: true })).toBeVisible()
} finally {
await builder.cleanup()
}

View file

@ -151,6 +151,29 @@ describe('MySkillsPage', () => {
})
})
it('renders a localized display name independently of the package slug', () => {
useMySkillsMock.mockReturnValue({
data: {
items: [{
id: 1,
displayName: '示例技能',
namespace: 'team-ai',
slug: 'example-skill',
downloadCount: 0,
status: 'ACTIVE',
visibility: 'PUBLIC',
}],
total: 1,
page: 0,
size: 10,
},
isLoading: false,
})
const markup = renderToStaticMarkup(createElement(MySkillsPage))
expect(markup).toMatch(/<h3[^>]*>示例技能<\/h3>/)
})
it('navigates to publish page with namespace and visibility when update is clicked', () => {
renderToStaticMarkup(createElement(MySkillsPage))