feat(publish): support metadata display names independent of skill slugs

Use optional metadata.display-name for skill titles while preserving stable
slugs and URLs. Keep published titles unchanged until review approval, and
cover localized title display and search with generic example fixtures.

Restore the fallback version title and summary when yanking the latest
published version, with regression coverage for the lifecycle boundaries.

Document the metadata field and retain existing behavior when it is absent.

Signed-off-by: Raymond <21071287+raymondtc@users.noreply.github.com>
This commit is contained in:
Raymond 2026-10-13 14:51:13 +08:00
parent accdfeba94
commit 2bdb83bdb6
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))