Merge remote-tracking branch 'origin/main'

# Conflicts:
#	web/src/api/client.ts
#	web/src/bootstrap.ts
This commit is contained in:
翟二远 2026-04-08 17:09:01 +08:00
commit 681bdbffbe
32 changed files with 1082 additions and 108 deletions

48
.github/ISSUE_TEMPLATE/reward-task.yml vendored Normal file
View file

@ -0,0 +1,48 @@
name: 💰 Reward Task
description: Task issue with Reward
title: '[Reward] '
labels:
- reward
body:
- type: textarea
id: description
attributes:
label: Task description
validations:
required: true
- type: dropdown
id: currency
attributes:
label: Reward currency
options:
- 'USD $'
- 'CAD C$'
- 'AUD A$'
- 'GBP £'
- 'EUR €'
- 'CNY ¥'
- 'HKD HK$'
- 'TWD NT$'
- 'SGD S$'
- 'KRW ₩'
- 'JPY ¥'
- 'INR ₹'
- 'UAH ₴'
validations:
required: true
- type: input
id: amount
attributes:
label: Reward amount
validations:
required: true
- type: input
id: payer
attributes:
label: Reward payer
description: GitHub username of the payer (optional, defaults to issue creator)
validations:
required: false

66
.github/scripts/count-reward.ts vendored Normal file
View file

@ -0,0 +1,66 @@
import { $, YAML } from "npm:zx";
import { Reward } from "./type.ts";
$.verbose = true;
const rawTags =
await $`git tag --list "reward-*" --format="%(refname:short) %(creatordate:short)"`;
const lastMonth = new Date();
lastMonth.setMonth(lastMonth.getMonth() - 1);
const lastMonthStr = lastMonth.toJSON().slice(0, 7);
const rewardTags = rawTags.stdout
.split("\n")
.filter((line) => line.split(/\s+/)[1] >= lastMonthStr)
.map((line) => line.split(/\s+/)[0]);
let rawYAML = "";
for (const tag of rewardTags)
rawYAML += (await $`git tag -l --format="%(contents)" ${tag}`) + "\n";
if (!rawYAML.trim()) {
console.warn("No reward data is found for the last month.");
process.exit(0);
}
const rewards = YAML.parse(rawYAML) as Reward[];
const groupedRewards = Object.groupBy(rewards, ({ payee }) => payee);
const summaryList = Object.entries(groupedRewards).map(([payee, rewards]) => {
const reward = rewards!.reduce(
(acc, { currency, reward }) => {
acc[currency] ??= 0;
acc[currency] += reward;
return acc;
},
{} as Record<string, number>,
);
return {
payee,
reward,
accounts: rewards!.map(({ payee: _, ...account }) => account),
};
});
const summaryText = YAML.stringify(summaryList);
console.log(summaryText);
const tagName = `statistic-${new Date().toJSON().slice(0, 7)}`;
await $`git config user.name "github-actions[bot]"`;
await $`git config user.email "github-actions[bot]@users.noreply.github.com"`;
await $`git tag -a ${tagName} $(git rev-parse HEAD) -m ${summaryText}`;
await $`git push origin --tags --no-verify`;
await $`git config unset user.name`;
await $`git config unset user.email`;
await $`gh release create ${tagName} --notes ${summaryText}`;

3
.github/scripts/deno.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"nodeModulesDir": "none"
}

125
.github/scripts/share-reward.ts vendored Normal file
View file

@ -0,0 +1,125 @@
import "npm:array-unique-proposal";
import { components } from "npm:@octokit/openapi-types";
import { $, argv, YAML } from "npm:zx";
import { Reward } from "./type.ts";
$.verbose = true;
const [
repositoryOwner,
repositoryName,
issueNumber,
payer, // GitHub username of the payer (provided by workflow, defaults to issue creator)
currency,
reward,
] = argv._;
interface PRMeta {
author: components["schemas"]["simple-user"];
assignees: components["schemas"]["simple-user"][];
}
const graphqlQuery = `
query($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
issue(number: $number) {
closedByPullRequestsReferences(first: 10) {
nodes {
url
merged
mergeCommit {
oid
}
}
}
}
}
}
`;
const PR_DATA = await $`gh api graphql \
-f query=${graphqlQuery} \
-f owner=${repositoryOwner} \
-f name=${repositoryName} \
-F number=${issueNumber} \
--jq '.data.repository.issue.closedByPullRequestsReferences.nodes[] | select(.merged == true) | {url: .url, mergeCommitSha: .mergeCommit.oid}' | head -n 1`;
const prData = PR_DATA.text().trim();
if (!prData)
throw new ReferenceError("No merged PR is found for the given issue number.");
const { url: PR_URL, mergeCommitSha } = JSON.parse(prData);
if (!PR_URL || !mergeCommitSha)
throw new Error("Missing required fields in PR data");
console.table({ PR_URL, mergeCommitSha });
const { author, assignees }: PRMeta = await (
await $`gh pr view ${PR_URL} --json author,assignees`
).json();
function isBotUser(login: string) {
const lowerLogin = login.toLowerCase();
return (
lowerLogin.includes("copilot") ||
lowerLogin.includes("[bot]") ||
lowerLogin === "github-actions[bot]" ||
lowerLogin.endsWith("[bot]")
);
}
// Filter out Bot users from the list
const allUsers = [
author.login,
...assignees.map(({ login }) => login),
].uniqueBy();
const users = allUsers.filter((login) => !isBotUser(login));
console.log(`All users: ${allUsers.join(", ")}`);
console.log(`Filtered users (excluding bots): ${users.join(", ")}`);
if (!users[0])
throw new ReferenceError(
"No real users found (all users are bots). Skipping reward distribution.",
);
const rewardNumber = parseFloat(reward);
if (isNaN(rewardNumber) || rewardNumber <= 0)
throw new RangeError(
`Reward amount is not a valid number, can not proceed with reward distribution. Received reward value: ${reward}`,
);
const averageReward = (rewardNumber / users.length).toFixed(2);
const list: Reward[] = users.map((login) => ({
issue: `#${issueNumber}`,
payer: `@${payer}`,
payee: `@${login}`,
currency,
reward: parseFloat(averageReward),
}));
const listText = YAML.stringify(list);
console.log(listText);
await $`git config user.name "github-actions[bot]"`;
await $`git config user.email "github-actions[bot]@users.noreply.github.com"`;
await $`git tag -a "reward-${issueNumber}" ${mergeCommitSha} -m ${listText}`;
await $`git push origin --tags --no-verify`;
await $`git config unset user.name`;
await $`git config unset user.email`;
const commentBody = `## Reward data
\`\`\`yml
${listText}
\`\`\`
`;
await $`gh issue comment ${issueNumber} --body ${commentBody}`;

7
.github/scripts/type.ts vendored Normal file
View file

@ -0,0 +1,7 @@
export interface Reward {
issue: string;
payer: string;
payee: string;
currency: string;
reward: number;
}

View file

@ -0,0 +1,46 @@
name: Claim Issue Reward
on:
issues:
types:
- closed
concurrency:
group: claim-issue-reward-${{ github.event.issue.number }}
cancel-in-progress: false
jobs:
claim-issue-reward:
runs-on: ubuntu-latest
if: contains(github.event.issue.labels.*.name, 'reward')
permissions:
contents: write
issues: write
pull-requests: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
fetch-tags: true
- uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4
with:
deno-version: v2.x
- name: Get Issue details
id: parse_issue
uses: stefanbuck/github-issue-parser@10dcc54158ba4c137713d9d69d70a2da63b6bda3 # v3.2.3
with:
template-path: ".github/ISSUE_TEMPLATE/reward-task.yml"
- name: Calculate & Save Reward
env:
GH_TOKEN: ${{ github.token }}
run: |
deno --allow-run --allow-sys --allow-env --allow-read --allow-net=api.github.com \
.github/scripts/share-reward.ts \
"${{ github.repository_owner }}" \
"${{ github.event.repository.name }}" \
"${{ github.event.issue.number }}" \
"${{ steps.parse_issue.outputs.issueparser_payer || github.event.issue.user.login }}" \
"${{ steps.parse_issue.outputs.issueparser_currency }}" \
"${{ steps.parse_issue.outputs.issueparser_amount }}"

View file

@ -0,0 +1,43 @@
name: Statistic Member Reward
on:
schedule:
- cron: "0 0 1 * *" # Run at 00:00 on the first day of every month
jobs:
statistic-member-reward:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
fetch-tags: true
- name: Check for new commits since last statistic
run: |
last_tag=$(git describe --tags --abbrev=0 --match "statistic-*" || echo "")
if [ -z "$last_tag" ]; then
echo "No previous statistic tags found."
echo "NEW_COMMITS=true" >> $GITHUB_ENV
else
new_commits=$(git log $last_tag..HEAD --oneline)
if [ -z "$new_commits" ]; then
echo "No new commits since last statistic tag."
echo "NEW_COMMITS=false" >> $GITHUB_ENV
else
echo "New commits found."
echo "NEW_COMMITS=true" >> $GITHUB_ENV
fi
fi
- uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4
if: env.NEW_COMMITS == 'true'
with:
deno-version: v2.x
- name: Statistic rewards
if: env.NEW_COMMITS == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: deno --allow-run --allow-sys --allow-env --allow-read --allow-net=api.github.com .github/scripts/count-reward.ts

View file

@ -71,10 +71,21 @@ dev-all: ## 一键启动本地开发环境(依赖 + scanner + 后端 + 前端
$(DEV_PROCESS) start --pid-file $(DEV_SERVER_PID) --log-file $(DEV_SERVER_LOG) --cwd server -- /bin/sh -lc '$(DEV_SERVER_PREPARE) && exec env $(DEV_SERVER_SCANNER_ENV) $(DEV_SERVER_CMD)' >/dev/null; \
fi; \
done; \
if [ "$$backend_ready" -ne 1 ]; then \
echo "Backend failed to become ready. Check $(DEV_SERVER_LOG)"; \
exit 1; \
fi
if [ "$$backend_ready" -ne 1 ]; then \
echo ""; \
echo "Backend failed to become ready. Check $(DEV_SERVER_LOG)"; \
echo ""; \
echo "Common issues:"; \
echo " 1. Maven dependency download failed (network timeout)"; \
echo " -> Configure mirror in ~/.m2/settings.xml"; \
echo " -> See: https://maven.aliyun.com/mvn/guide"; \
echo " 2. Java version mismatch (requires Java 21+)"; \
echo " -> Run: java -version"; \
echo " 3. Port 8080 already in use"; \
echo " -> Run: lsof -i :8080"; \
echo ""; \
exit 1; \
fi
@echo "Waiting for scanner on $(DEV_SCANNER_URL) ..."
@scanner_ready=0; \
for i in $$(seq 1 30); do \

View file

@ -24,6 +24,10 @@
---
<div align="center">
<img src="https://xfyun-doc.xfyun.cn/lc-sp-skillhub-demo-1775551643410.gif" alt="SkillHub Demo" width="800" />
</div>
SkillHub is a self-hosted platform that gives teams a private,
governed place to share agent skills. Publish a skill package, push
it to a namespace, and let others find it through search or
@ -106,6 +110,8 @@ If deployment runs into problems, clear the existing runtime home and retry.
make dev-all
```
> **For developers in China**: If Maven dependency download times out, configure Aliyun mirror. See [Local Development Guide](https://iflytek.github.io/skillhub/quickstart.html#本地开发) for details.
Then open:
- Web UI: `http://localhost:3000`

View file

@ -17,6 +17,10 @@
---
<div align="center">
<img src="https://xfyun-doc.xfyun.cn/lc-sp-skillhub-demo-1775551643410.gif" alt="SkillHub Demo" width="800" />
</div>
SkillHub 是一个自托管平台,为团队提供私有的、受治理的智能体技能共享空间。发布技能包,推送到命名空间,让其他人通过搜索发现或通过 CLI 安装。专为防火墙后的本地部署而构建,提供与公共注册中心相同的精致体验。
## 文档
@ -126,6 +130,8 @@ make dev-backend # 仅后端
make dev-web # 仅前端
```
> **国内开发者**:如果 Maven 依赖下载超时,需配置阿里云镜像。详见 [本地开发指南](https://iflytek.github.io/skillhub/quickstart.html#本地开发)。
### 常用命令
```bash

View file

@ -124,6 +124,18 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u
> **Note**: It is recommended to back up the database and object storage before upgrading. Database migrations are handled automatically by Flyway.
## Q: Why can't administrators (admin) and regular users create namespaces?
A: Older versions of SkillHub do not support creating namespaces, as this feature was introduced in later updates. Please upgrade your SkillHub instance to the latest version (`latest`).
Upgrade command example:
```bash
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --version latest
```
## Q: How do I search for or operate on a skill package within a specific namespace?
A: When using the OpenClaw CLI, you can specify the namespace using the `<namespace>--<skill-name>` format for operations like search or installation. If you encounter issues finding it on the web interface, you can also manage it by exporting the skill package and importing it into your target namespace.
## Q: What should I do if I encounter issues?
A: You can get help through the following channels:
@ -131,3 +143,74 @@ A: You can get help through the following channels:
- **GitHub Issues**: https://github.com/iflytek/skillhub/issues
- **Documentation**: Refer to the project README.md
- **Community Discussions**: https://github.com/iflytek/skillhub/discussions
## Q: What should I do if local development fails to start?
A: When `make dev-all` fails to start the backend, detailed error messages will be displayed. Common issues:
### 1. Maven dependency download failed (network timeout)
**Symptoms**: Backend logs show `Could not transfer artifact` or connection timeout
**Solution**: Configure Aliyun mirror
```bash
# Copy the project's built-in mirror configuration to user directory
mkdir -p ~/.m2
cp server/.mvn/settings.xml ~/.m2/settings.xml
```
Or manually create `~/.m2/settings.xml`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<settings>
<mirrors>
<mirror>
<id>aliyun</id>
<url>https://maven.aliyun.com/repository/public</url>
<mirrorOf>central</mirrorOf>
</mirror>
</mirrors>
</settings>
```
Reference: [Aliyun Maven Mirror Configuration Guide](https://maven.aliyun.com/mvn/guide)
### 2. Java version mismatch
**Symptoms**: `Unsupported class file major version` or `java.lang.NoSuchMethodError`
**Solution**: Install Java 21+
```bash
# macOS
brew install openjdk@21
# Verify version
java -version
```
### 3. Port already in use
**Symptoms**: `Port 8080 already in use`
**Solution**:
```bash
# Find the process using the port
lsof -i :8080
# Terminate the process
kill -9 <PID>
```
### 4. View detailed logs
If the above solutions don't help, check the backend logs:
```bash
make dev-logs SERVICE=backend
# Or view directly
cat .dev/server.log
```

View file

@ -64,6 +64,39 @@ cd skillhub
make dev-all
```
### Notes for Developers in China
If `make dev-all` fails to start the backend, common causes include:
1. **Maven dependency download timeout**
The project includes a built-in Aliyun mirror configuration (`server/.mvn/settings.xml`), but Maven does not automatically read project-level settings. You need to configure it manually:
```bash
# Option 1: Copy to user directory (recommended)
mkdir -p ~/.m2
cp server/.mvn/settings.xml ~/.m2/settings.xml
# Option 2: Specify on each build
cd server && ./mvnw -s .mvn/settings.xml package
```
2. **Java version mismatch**
SkillHub requires Java 21+:
```bash
java -version
```
3. **Port conflict**
Check if port 8080 is in use:
```bash
lsof -i :8080
```
For detailed troubleshooting steps, see [FAQ](faq.md#local-development-startup-failure).
## Logging In
### Option 1: Use the Built-in Admin Account

View file

@ -124,6 +124,18 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u
> **注意**:升级前建议先备份数据库和对象存储。数据库迁移由 Flyway 自动执行。
## Q: 为什么管理员(admin)和普通用户都无法创建命名空间?
A: 较旧版本的 SkillHub 不支持创建命名空间。该功能是在后续版本迭代中添加的。请将您的 SkillHub 升级到最新版本(latest)。
升级命令示例:
```bash
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --version latest
```
## Q: 如何搜索或操作指定命名空间中的技能包(Skill)?
A: 使用 OpenClaw CLI 命令行工具时,可以通过 `<namespace>--<skill-name>` 的格式来指定命名空间进行操作(例如搜索、安装)。如果在网页端搜索遇到问题,也可以尝试通过先导出技能、再导入到目标命名空间的方式来完成跨空间操作。
## Q: 遇到问题怎么办?
A: 可以通过以下方式获取帮助:
@ -131,3 +143,74 @@ A: 可以通过以下方式获取帮助:
- **GitHub Issues**: https://github.com/iflytek/skillhub/issues
- **文档**: 参考项目 README.md
- **社区讨论**: https://github.com/iflytek/skillhub/discussions
## Q: 本地开发启动失败怎么办?
A: `make dev-all` 后端启动失败时,会显示详细的错误提示。常见问题:
### 1. Maven 依赖下载失败(网络超时)
**症状**:后端日志显示 `Could not transfer artifact` 或连接超时
**解决方案**:配置阿里云镜像
```bash
# 复制项目内置的镜像配置到用户目录
mkdir -p ~/.m2
cp server/.mvn/settings.xml ~/.m2/settings.xml
```
或手动创建 `~/.m2/settings.xml`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<settings>
<mirrors>
<mirror>
<id>aliyun</id>
<url>https://maven.aliyun.com/repository/public</url>
<mirrorOf>central</mirrorOf>
</mirror>
</mirrors>
</settings>
```
参考:[阿里云 Maven 镜像配置指南](https://maven.aliyun.com/mvn/guide)
### 2. Java 版本不匹配
**症状**:`Unsupported class file major version` 或 `java.lang.NoSuchMethodError`
**解决方案**:安装 Java 21+
```bash
# macOS
brew install openjdk@21
# 验证版本
java -version
```
### 3. 端口被占用
**症状**:`Port 8080 already in use`
**解决方案**:
```bash
# 查看占用端口的进程
lsof -i :8080
# 终止进程
kill -9 <PID>
```
### 4. 查看详细日志
如果以上方案无法解决,查看后端日志:
```bash
make dev-logs SERVICE=backend
# 或直接查看
cat .dev/server.log
```

View file

@ -64,6 +64,39 @@ cd skillhub
make dev-all
```
### 国内开发者注意事项
如果 `make dev-all` 后端启动失败,常见原因:
1. **Maven 依赖下载超时**
项目已内置阿里云镜像配置(`server/.mvn/settings.xml`),但 Maven 不会自动读取项目级配置。需要手动配置:
```bash
# 方式一:复制到用户目录(推荐)
mkdir -p ~/.m2
cp server/.mvn/settings.xml ~/.m2/settings.xml
# 方式二:每次构建时指定
cd server && ./mvnw -s .mvn/settings.xml package
```
2. **Java 版本不匹配**
SkillHub 要求 Java 21+:
```bash
java -version
```
3. **端口冲突**
检查 8080 端口是否被占用:
```bash
lsof -i :8080
```
详细的错误排查步骤,请查看 [常见问题](faq.md#本地开发启动失败)。
## 登录系统
### 方式一:使用内置管理员账号

View file

@ -22,6 +22,7 @@ SKILLHUB_SCANNER_IMAGE_VALUE="${SKILLHUB_SCANNER_IMAGE:-}"
POSTGRES_IMAGE_VALUE="${POSTGRES_IMAGE:-}"
REDIS_IMAGE_VALUE="${REDIS_IMAGE:-}"
DISABLE_SCANNER=false
USE_ALIYUN=false
while [ "$#" -gt 0 ]; do
case "$1" in
@ -36,6 +37,7 @@ while [ "$#" -gt 0 ]; do
exit 1
fi
SKILLHUB_MIRROR_REGISTRY_VALUE="${SKILLHUB_ALIYUN_REGISTRY%/}/${SKILLHUB_ALIYUN_NAMESPACE}"
USE_ALIYUN=true
shift
;;
--mirror-registry)
@ -114,7 +116,13 @@ EOF
esac
done
SKILLHUB_RAW_BASE="${SKILLHUB_RAW_BASE:-https://raw.githubusercontent.com/iflytek/skillhub/$SKILLHUB_REF}"
if [ "$USE_ALIYUN" = "true" ]; then
SKILLHUB_RAW_BASE="${SKILLHUB_RAW_BASE:-https://imageless.oss-cn-beijing.aliyuncs.com}"
echo "Using Aliyun OSS for runtime files: $SKILLHUB_RAW_BASE"
else
SKILLHUB_RAW_BASE="${SKILLHUB_RAW_BASE:-https://raw.githubusercontent.com/iflytek/skillhub/$SKILLHUB_REF}"
echo "Using GitHub raw for runtime files: $SKILLHUB_RAW_BASE"
fi
COMPOSE_FILE="$SKILLHUB_HOME/compose.release.yml"
ENV_EXAMPLE_FILE="$SKILLHUB_HOME/.env.release.example"
ENV_FILE="$SKILLHUB_HOME/.env.release"

16
server/.mvn/settings.xml Normal file
View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<settings xmlns="http://maven.apache.org/SETTINGS/1.2.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.2.0
https://maven.apache.org/xsd/settings-1.2.0.xsd">
<mirrors>
<mirror>
<id>aliyun</id>
<name>Aliyun Maven Mirror</name>
<url>https://maven.aliyun.com/repository/public</url>
<mirrorOf>central</mirrorOf>
</mirror>
</mirrors>
</settings>

View file

@ -1,3 +1,3 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.13/apache-maven-3.9.13-bin.zip
distributionUrl=https://maven.aliyun.com/repository/public/org/apache/maven/apache-maven/3.9.13/apache-maven-3.9.13-bin.zip

View file

@ -14,6 +14,7 @@ import com.iflytek.skillhub.controller.support.MultipartPackageExtractor;
import com.iflytek.skillhub.controller.support.ZipPackageExtractor;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
@ -93,11 +94,11 @@ public class ClawHubCompatAppService {
String hash,
String userId,
Map<Long, NamespaceRole> userNsRoles) {
CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.findByLegacySlug(slug);
SkillCoordinate coord = resolveQueryCoordinate(slug);
SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion(
context.namespace().getSlug(),
context.skill().getSlug(),
coord.namespace(),
coord.slug(),
"latest".equals(version) ? null : version,
"latest".equals(version) ? "latest" : null,
hash,
@ -132,10 +133,22 @@ public class ClawHubCompatAppService {
}
public String downloadLocationByQuery(String slug, String version) {
CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.findByLegacySlug(slug);
SkillCoordinate coord = resolveQueryCoordinate(slug);
return "latest".equals(version)
? "/api/v1/skills/" + context.namespace().getSlug() + "/" + context.skill().getSlug() + "/download"
: "/api/v1/skills/" + context.namespace().getSlug() + "/" + context.skill().getSlug() + "/versions/" + version + "/download";
? "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/download"
: "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/versions/" + version + "/download";
}
private SkillCoordinate resolveQueryCoordinate(String slug) {
if (slug != null && slug.contains("--")) {
return mapper.fromCanonical(slug);
}
try {
CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.findByLegacySlug(slug);
return new SkillCoordinate(context.namespace().getSlug(), context.skill().getSlug());
} catch (DomainNotFoundException ex) {
return mapper.fromCanonical(slug);
}
}
public ClawHubSkillListResponse listSkills(int page,

View file

@ -22,6 +22,7 @@ import java.util.Set;
import java.math.BigDecimal;
import java.time.Instant;
import static org.mockito.Mockito.verify;
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;
@ -105,6 +106,56 @@ class ClawHubCompatControllerTest {
.andExpect(jsonPath("$.latestVersion.version").value("latest"));
}
@Test
void resolve_query_with_canonical_slug_returns_correct_downloadUrl() throws Exception {
when(skillQueryService.resolveVersion("team-ai", "my-skill", null, "latest", null, null, java.util.Map.of()))
.thenReturn(new SkillQueryService.ResolvedVersionDTO(
1L, "team-ai", "my-skill", "latest", 2L, "sha", true, "/api/v1/skills/team-ai/my-skill/download"));
mockMvc.perform(get("/api/v1/resolve")
.param("slug", "team-ai--my-skill")
.param("version", "latest"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.match.version").value("latest"))
.andExpect(jsonPath("$.latestVersion.version").value("latest"));
verify(skillQueryService).resolveVersion("team-ai", "my-skill", null, "latest", null, null, java.util.Map.of());
}
@Test
void resolve_query_with_legacy_slug_keeps_legacy_lookup_behavior() throws Exception {
when(skillQueryService.resolveVersion("global", "my-skill", null, "latest", null, null, java.util.Map.of()))
.thenReturn(new SkillQueryService.ResolvedVersionDTO(
1L, "global", "my-skill", "latest", 2L, "sha", true, "/api/v1/skills/global/my-skill/download"));
mockMvc.perform(get("/api/v1/resolve")
.param("slug", "my-skill")
.param("version", "latest"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.match.version").value("latest"))
.andExpect(jsonPath("$.latestVersion.version").value("latest"));
verify(skillQueryService).resolveVersion("global", "my-skill", null, "latest", null, null, java.util.Map.of());
}
@Test
void download_query_with_canonical_slug_redirects_to_namespace_skill_download() throws Exception {
mockMvc.perform(get("/api/v1/download")
.param("slug", "team-ai--my-skill")
.param("version", "latest"))
.andExpect(status().isFound())
.andExpect(header().string("Location", "/api/v1/skills/team-ai/my-skill/download"));
}
@Test
void download_query_with_legacy_slug_keeps_legacy_lookup_behavior() throws Exception {
mockMvc.perform(get("/api/v1/download")
.param("slug", "my-skill")
.param("version", "latest"))
.andExpect(status().isFound())
.andExpect(header().string("Location", "/api/v1/skills/global/my-skill/download"));
}
@Test
void resolve_with_version_returns_specified_version() throws Exception {
when(skillQueryService.resolveVersion("global", "my-skill", "1.0.0", null, null, null, java.util.Map.of()))

View file

@ -155,7 +155,12 @@ export class E2eTestDataBuilder {
}
async createNamespace(base = 'e2e-team'): Promise<SeededNamespace> {
const slug = `${base}-${this.suffix}`.slice(0, 64)
const rawSlug = `${base}-${this.suffix}`
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '')
const slug = rawSlug.slice(0, 64)
const displayName = `E2E ${slug}`
const created = await parseEnvelope<SeededNamespace>(

View file

@ -0,0 +1,83 @@
import { expect, test, type Page } from '@playwright/test'
import { setEnglishLocale } from './helpers/auth-fixtures'
type ReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
interface ApiEnvelope<T> {
code: number
msg: string
data: T
}
interface ReviewPageData {
total: number
size: number
}
async function fetchReviewPageMeta(page: Page, status: ReviewStatus): Promise<ReviewPageData> {
const response = await page.request.get(`/api/web/reviews?status=${status}&page=0&size=20&sortDirection=DESC`)
const body = await response.json() as ApiEnvelope<ReviewPageData>
if (!response.ok() || body.code !== 0) {
throw new Error(`Failed to query reviews for ${status}: status=${response.status()} code=${body.code} msg=${body.msg}`)
}
return body.data
}
test.describe('Review Management Pagination (Real API)', () => {
test.beforeEach(async ({ page }) => {
await setEnglishLocale(page)
await page.context().setExtraHTTPHeaders({
'X-Mock-User-Id': 'local-admin',
})
})
test('matches pagination rendering with real review totals', async ({ page }) => {
const statuses: ReviewStatus[] = ['PENDING', 'APPROVED', 'REJECTED']
const metaByStatus = new Map<ReviewStatus, ReviewPageData>()
for (const status of statuses) {
metaByStatus.set(status, await fetchReviewPageMeta(page, status))
}
await page.goto('/dashboard/reviews')
await expect(page.getByRole('heading', { name: 'Review Center' })).toBeVisible()
const tabMeta: Record<ReviewStatus, { tabLabel: string; summaryPrefix: string }> = {
PENDING: { tabLabel: 'Pending', summaryPrefix: 'Total' },
APPROVED: { tabLabel: 'Approved', summaryPrefix: 'Total' },
REJECTED: { tabLabel: 'Rejected', summaryPrefix: 'Total' },
}
for (const status of statuses) {
await page.getByRole('button', { name: tabMeta[status].tabLabel }).click()
const meta = metaByStatus.get(status)
if (!meta) {
throw new Error(`Missing metadata for ${status}`)
}
const totalPages = meta.size > 0 ? Math.ceil(meta.total / meta.size) : 0
if (meta.total === 0) {
await expect(page.getByText('No review tasks')).toBeVisible()
await expect(page.getByRole('button', { name: 'Previous' })).toHaveCount(0)
await expect(page.getByRole('button', { name: 'Next' })).toHaveCount(0)
continue
}
const previousButton = page.getByRole('button', { name: 'Previous' }).first()
const nextButton = page.getByRole('button', { name: 'Next' }).first()
await expect(previousButton).toBeVisible()
await expect(nextButton).toBeVisible()
await expect(previousButton).toBeDisabled()
if (totalPages > 1) {
await expect(page.getByText(new RegExp(`${tabMeta[status].summaryPrefix} ${meta.total} records, page 1`))).toBeVisible()
await expect(nextButton).toBeEnabled()
await nextButton.click()
await expect(page.getByText(new RegExp(`${tabMeta[status].summaryPrefix} ${meta.total} records, page 2`))).toBeVisible()
} else {
await expect(nextButton).toBeDisabled()
}
}
})
})

View file

@ -36,6 +36,7 @@ vi.mock('@/shared/lib/api-error', () => ({
import {
WEB_API_PREFIX,
buildApiUrl,
fetchText,
getDirectAuthRuntimeConfig,
getSessionBootstrapRuntimeConfig,
} from './client'
@ -45,6 +46,8 @@ beforeEach(() => {
})
afterEach(() => {
vi.unstubAllGlobals()
if (originalWindow) {
Object.defineProperty(globalThis, 'window', {
configurable: true,
@ -79,6 +82,38 @@ describe('buildApiUrl', () => {
const url = buildApiUrl('/api/v1/auth/me')
expect(url).toBe('https://api.example.com/api/v1/auth/me')
})
it('preserves base URL path prefixes', () => {
window.__SKILLHUB_RUNTIME_CONFIG__ = { apiBaseUrl: 'https://api.example.com/skill_hub' }
const url = buildApiUrl('/api/v1/auth/me')
expect(url).toBe('https://api.example.com/skill_hub/api/v1/auth/me')
})
it('supports relative base URL path prefixes', () => {
window.__SKILLHUB_RUNTIME_CONFIG__ = { apiBaseUrl: '/skill_hub' }
const url = buildApiUrl('/api/v1/auth/me')
expect(url).toBe('/skill_hub/api/v1/auth/me')
})
})
describe('fetchText', () => {
it('applies base URL path prefixes for fetch requests', async () => {
window.__SKILLHUB_RUNTIME_CONFIG__ = { apiBaseUrl: 'https://api.example.com/skill_hub' }
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
text: async () => 'ok',
})
vi.stubGlobal('fetch', fetchMock)
await fetchText('/api/v1/auth/me')
expect(fetchMock).toHaveBeenCalledWith(
'https://api.example.com/skill_hub/api/v1/auth/me',
expect.objectContaining({
headers: expect.any(Headers),
}),
)
})
})
describe('getDirectAuthRuntimeConfig', () => {

View file

@ -267,6 +267,12 @@ export function buildApiUrl(path: string): string {
// function ensureTrailingSlash(value: string): string {
// return value.endsWith('/') ? value : `${value}/`
// }
// function trimTrailingSlash(value: string): string {
// if (value.length > 1 && value.endsWith('/')) {
// return value.slice(0, -1)
// }
// return value
// }
export async function getCurrentUser(): Promise<User | null> {
try {
@ -347,7 +353,7 @@ export const authApi = {
},
async logout(): Promise<void> {
const response = await fetch(withBaseUrl('/api/v1/auth/logout'), {
const response = await fetch('/api/v1/auth/logout', {
method: 'POST',
headers: withCsrf(),
})

View file

@ -1,3 +1,4 @@
import { useMemo } from 'react'
import { common, createLowlight } from 'lowlight'
// Create lowlight instance with common languages
@ -14,30 +15,28 @@ interface CodeRendererProps {
/**
* Renders code with syntax highlighting using lowlight (highlight.js wrapper).
* Reuses the same styling as Markdown code blocks for visual consistency.
* Memoized to prevent re-highlighting on every render.
*/
export function CodeRenderer({ code, language, className }: CodeRendererProps) {
let highlightedCode: string
try {
if (language && lowlight.registered(language)) {
// Highlight with specified language
const tree = lowlight.highlight(language, code, { prefix: 'hljs-' })
highlightedCode = treeToHtml(tree)
} else {
// Fallback to plain text (no highlighting)
highlightedCode = escapeHtml(code)
// Cache syntax highlighting result
const highlightedCode = useMemo(() => {
try {
if (language && lowlight.registered(language)) {
const tree = lowlight.highlight(language, code, { prefix: 'hljs-' })
return treeToHtml(tree)
}
return escapeHtml(code)
} catch (error) {
console.error('Syntax highlighting failed:', error)
return escapeHtml(code)
}
} catch (error) {
// If highlighting fails, escape HTML and display as plain text
console.error('Syntax highlighting failed:', error)
highlightedCode = escapeHtml(code)
}
}, [code, language])
return (
<div className={className}>
{/* Reuse the same wrapper styling as Markdown code blocks */}
<div className="my-6 rounded-2xl border border-border/60 bg-gradient-to-br from-secondary/45 via-background to-secondary/20 p-1 shadow-sm">
<div className="max-w-full overflow-x-auto rounded-xl bg-background/80 px-4 py-4 backdrop-blur-sm">
{/* Simplified styling - removed gradient, blur, and shadow for better performance */}
<div className="my-4 rounded-lg border border-border/60 bg-secondary/30">
<div className="max-w-full overflow-x-auto rounded-lg bg-background px-4 py-3">
<pre className="m-0 min-w-max bg-transparent p-0 text-[13px] leading-6">
<code
className="hljs"

View file

@ -8,10 +8,14 @@ import * as mod from './file-tree-node'
*
* We verify the module shape so downstream consumers break fast
* if the export contract changes.
*
* Note: FileTreeNodeComponent is wrapped with React.memo, so typeof returns 'object'
* instead of 'function'. We check for both to handle the memo wrapper.
*/
describe('file-tree-node module exports', () => {
it('exports the FileTreeNodeComponent component', () => {
expect(mod.FileTreeNodeComponent).toBeDefined()
expect(typeof mod.FileTreeNodeComponent).toBe('function')
// React.memo wraps the component in an object, so typeof is 'object'
expect(['function', 'object']).toContain(typeof mod.FileTreeNodeComponent)
})
})

View file

@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, memo, useMemo } from 'react'
import { ChevronRight, ChevronDown, Folder, FolderOpen, FileText, FileCode, File } from 'lucide-react'
import type { FileTreeNode } from './file-tree-builder'
import { getFileIcon } from './file-type-utils'
@ -33,14 +33,23 @@ function formatFileSize(bytes: number): string {
/**
* Recursive file tree node component.
* Renders either a file or directory node with expand/collapse functionality.
* Memoized to prevent unnecessary re-renders when parent updates.
*/
export function FileTreeNodeComponent({ node, onFileClick, defaultExpanded = false }: FileTreeNodeProps) {
export const FileTreeNodeComponent = memo(function FileTreeNodeComponent({
node,
onFileClick,
defaultExpanded = false,
}: FileTreeNodeProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded)
// Cache icon computation
const IconComponent = useMemo(
() => getIconComponent(getFileIcon(node.name)),
[node.name]
)
// Render file node
if (node.type === 'file') {
const IconComponent = getIconComponent(getFileIcon(node.name))
return (
<div
className="flex items-center justify-between px-3 py-2 hover:bg-accent/10 cursor-pointer transition-colors group"
@ -94,4 +103,4 @@ export function FileTreeNodeComponent({ node, onFileClick, defaultExpanded = fal
)}
</div>
)
}
})

View file

@ -1,3 +1,4 @@
import { useMemo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { Folder } from 'lucide-react'
import type { SkillFile } from '@/api/types'
@ -18,13 +19,19 @@ interface FileTreeProps {
*/
export function FileTree({ files, onFileClick, bare }: FileTreeProps) {
const { t } = useTranslation()
const tree = buildFileTree(files)
const handleFileClick = (node: FileTreeNode) => {
if (node.type === 'file' && onFileClick) {
onFileClick(node)
}
}
// Cache tree structure to avoid rebuilding on every render
const tree = useMemo(() => buildFileTree(files), [files])
// Stable callback reference to prevent child re-renders
const handleFileClick = useCallback(
(node: FileTreeNode) => {
if (node.type === 'file' && onFileClick) {
onFileClick(node)
}
},
[onFileClick]
)
const treeContent = (
<div>

View file

@ -1,3 +1,4 @@
import { useMemo } from 'react'
import ReactMarkdown from 'react-markdown'
import rehypeHighlight from 'rehype-highlight'
import rehypeSanitize from 'rehype-sanitize'
@ -17,6 +18,7 @@ interface MarkdownRendererProps {
* Renders markdown from skill packages using a constrained plugin stack.
* Frontmatter is stripped before render because package metadata is surfaced in
* dedicated UI sections and should not appear twice in the document body.
* Memoized to prevent re-parsing on every render.
*/
export function MarkdownRenderer({ content, className }: MarkdownRendererProps) {
const containerClassName = [
@ -25,7 +27,12 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps)
]
.filter(Boolean)
.join(' ')
const normalizedContent = stripMarkdownFrontmatter(content)
// Cache the normalized content to prevent re-parsing on every render
const normalizedContent = useMemo(
() => stripMarkdownFrontmatter(content),
[content]
)
return (
<div className={containerClassName}>
@ -103,8 +110,8 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps)
</li>
),
pre: ({ children }) => (
<div className="my-6 rounded-2xl border border-border/60 bg-gradient-to-br from-secondary/45 via-background to-secondary/20 p-1 shadow-sm">
<div className="max-w-full overflow-x-auto rounded-xl bg-background/80 px-4 py-4 backdrop-blur-sm">
<div className="my-4 rounded-lg border border-border/60 bg-secondary/30">
<div className="max-w-full overflow-x-auto rounded-lg bg-background px-4 py-3">
<pre className="m-0 min-w-max bg-transparent p-0 text-[13px] leading-6">{children}</pre>
</div>
</div>
@ -132,7 +139,7 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps)
blockquote: ({ className: blockquoteClassName, children, ...props }) => (
<blockquote
className={cn(
'relative my-6 overflow-hidden rounded-r-xl border-l-4 border-l-primary/35 bg-secondary/30 px-5 py-4 text-foreground/80 shadow-sm',
'my-4 border-l-4 border-l-primary/40 bg-secondary/20 px-4 py-3 text-foreground/80',
blockquoteClassName
)}
{...props}
@ -144,7 +151,7 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps)
<hr className={cn('my-10 mx-auto w-full max-w-full border-border/50', hrClassName)} {...props} />
),
table: ({ children }) => (
<div className="my-6 overflow-hidden rounded-2xl border border-border/80 bg-card/80 shadow-sm">
<div className="my-4 overflow-hidden rounded-lg border border-border/60 bg-card/80">
<div className="max-w-full overflow-x-auto">
<table className="m-0 min-w-full border-separate border-spacing-0 text-sm">{children}</table>
</div>

View file

@ -1,4 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { createElement } from 'react'
vi.mock('@tanstack/react-router', () => ({
useParams: () => ({ slug: 'test-ns' }),
@ -19,10 +21,6 @@ vi.mock('@/shared/lib/date-time', () => ({
formatLocalDateTime: (v: string) => v,
}))
vi.mock('@/shared/ui/button', () => ({
Button: ({ children }: { children: unknown }) => children,
}))
vi.mock('@/shared/ui/card', () => ({
Card: ({ children }: { children: unknown }) => children,
}))
@ -42,12 +40,22 @@ vi.mock('@/shared/ui/tabs', () => ({
TabsTrigger: ({ children }: { children: unknown }) => children,
}))
vi.mock('@/shared/hooks/use-namespace-queries', () => ({
useNamespaceDetail: () => ({ data: null, isLoading: false }),
const paginationProps: Array<{ page: number; totalPages: number; onPageChange: (page: number) => void }> = []
vi.mock('@/shared/components/pagination', () => ({
Pagination: (props: { page: number; totalPages: number; onPageChange: (page: number) => void }) => {
paginationProps.push(props)
return null
},
}))
const useNamespaceDetailMock = vi.fn()
vi.mock('@/shared/hooks/use-namespace-queries', () => ({
useNamespaceDetail: (...args: unknown[]) => useNamespaceDetailMock(...args),
}))
const useReviewListMock = vi.fn()
vi.mock('@/features/review/use-review-list', () => ({
useReviewList: () => ({ data: null, isLoading: false }),
useReviewList: (...args: unknown[]) => useReviewListMock(...args),
}))
vi.mock('@/shared/components/dashboard-page-header', () => ({
@ -61,7 +69,89 @@ vi.mock('@/features/namespace/namespace-header', () => ({
import { NamespaceReviewsPage } from './namespace-reviews'
describe('NamespaceReviewsPage', () => {
function createReviewItem(id: number) {
return {
id,
namespace: 'demo-ns',
skillSlug: `skill-${id}`,
version: '1.0.0',
submittedBy: 'user-1',
submittedByName: 'User 1',
submittedAt: '2026-04-01T12:00:00Z',
reviewedBy: null,
reviewedByName: null,
reviewedAt: null,
reviewComment: null,
}
}
beforeEach(() => {
paginationProps.length = 0
useNamespaceDetailMock.mockReset()
useReviewListMock.mockReset()
useNamespaceDetailMock.mockReturnValue({
data: {
id: 100,
slug: 'test-ns',
displayName: 'Test Namespace',
type: 'CUSTOM',
status: 'ACTIVE',
},
isLoading: false,
})
useReviewListMock.mockImplementation((status: string, _namespaceId: unknown, page: number, _size: number, _sortDirection: string, enabled: boolean) => {
if (!enabled || status !== 'PENDING') {
return { data: null, isLoading: false }
}
return {
data: {
items: [createReviewItem(1)],
totalElements: 11,
totalPages: 2,
page,
size: 10,
total: 11,
},
isLoading: false,
}
})
})
it('exports a named component function', () => {
expect(typeof NamespaceReviewsPage).toBe('function')
})
it('renders pagination for namespace review list when totalPages > 1', () => {
const html = renderToStaticMarkup(createElement(NamespaceReviewsPage))
expect(html).toContain('nsReviews.pageSummary')
expect(paginationProps).toHaveLength(1)
expect(paginationProps[0]?.page).toBe(0)
expect(paginationProps[0]?.totalPages).toBe(2)
})
it('does not render pagination when there is only one page', () => {
useReviewListMock.mockImplementation((status: string, _namespaceId: unknown, page: number, _size: number, _sortDirection: string, enabled: boolean) => {
if (!enabled || status !== 'PENDING') {
return { data: null, isLoading: false }
}
return {
data: {
items: [createReviewItem(2)],
totalElements: 1,
totalPages: 1,
page,
size: 10,
total: 1,
},
isLoading: false,
}
})
renderToStaticMarkup(createElement(NamespaceReviewsPage))
expect(paginationProps).toHaveLength(0)
})
})

View file

@ -2,13 +2,13 @@ import { useState } from 'react'
import { useParams } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { formatLocalDateTime } from '@/shared/lib/date-time'
import { Button } from '@/shared/ui/button'
import { Card } from '@/shared/ui/card'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
import { useNamespaceDetail } from '@/shared/hooks/use-namespace-queries'
import { useReviewList } from '@/features/review/use-review-list'
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
import { Pagination } from '@/shared/components/pagination'
import { NamespaceHeader } from '@/features/namespace/namespace-header'
type ReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
@ -51,26 +51,7 @@ function ReviewListSection({ namespaceId }: { namespaceId?: number }) {
return (
<div className="flex flex-col gap-3 border-t border-border/60 px-5 py-4 text-sm text-muted-foreground md:flex-row md:items-center md:justify-between">
<p>{t('nsReviews.pageSummary', { total: totalElements, page: currentPage + 1 })}</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={currentPage === 0}
onClick={() => changePage(status, currentPage - 1)}
>
{t('nsReviews.prevPage')}
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={currentPage >= totalPages - 1}
onClick={() => changePage(status, currentPage + 1)}
>
{t('nsReviews.nextPage')}
</Button>
</div>
<Pagination page={currentPage} totalPages={totalPages} onPageChange={(nextPage) => changePage(status, nextPage)} />
</div>
)
}

View file

@ -1,4 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { createElement } from 'react'
vi.mock('@tanstack/react-router', () => ({
useNavigate: () => vi.fn(),
@ -19,10 +21,6 @@ vi.mock('react-i18next', async () => {
}
})
vi.mock('@/shared/ui/button', () => ({
Button: ({ children }: { children: unknown }) => children,
}))
vi.mock('@/shared/ui/card', () => ({
Card: ({ children }: { children: unknown }) => children,
CardContent: ({ children }: { children: unknown }) => children,
@ -55,12 +53,22 @@ vi.mock('@/shared/ui/table', () => ({
TableRow: ({ children }: { children: unknown }) => children,
}))
vi.mock('@/features/review/use-review-list', () => ({
useReviewList: () => ({ data: null, isLoading: false }),
const paginationProps: Array<{ page: number; totalPages: number; onPageChange: (page: number) => void }> = []
vi.mock('@/shared/components/pagination', () => ({
Pagination: (props: { page: number; totalPages: number; onPageChange: (page: number) => void }) => {
paginationProps.push(props)
return null
},
}))
const useReviewListMock = vi.fn()
vi.mock('@/features/review/use-review-list', () => ({
useReviewList: (...args: unknown[]) => useReviewListMock(...args),
}))
const hasRoleMock = vi.fn()
vi.mock('@/features/auth/use-auth', () => ({
useAuth: () => ({ hasRole: () => false }),
useAuth: () => ({ hasRole: hasRoleMock }),
}))
vi.mock('@/shared/components/dashboard-page-header', () => ({
@ -78,7 +86,86 @@ vi.mock('./profile-review-table', () => ({
import { ReviewsPage } from './reviews'
describe('ReviewsPage', () => {
function createReviewItem(id: number) {
return {
id,
namespace: 'demo',
skillSlug: `skill-${id}`,
version: '1.0.0',
submittedBy: 'user-1',
submittedByName: 'User 1',
submittedAt: '2026-04-01T12:00:00Z',
reviewedBy: null,
reviewedByName: null,
reviewedAt: null,
reviewComment: null,
}
}
beforeEach(() => {
paginationProps.length = 0
hasRoleMock.mockReset()
useReviewListMock.mockReset()
hasRoleMock.mockImplementation((role: string) => role === 'SKILL_ADMIN')
useReviewListMock.mockImplementation((status: string, _namespaceId: unknown, page: number, _size: number, _sortDirection: string, enabled: boolean) => {
if (!enabled) {
return { data: null, isLoading: false }
}
if (status === 'PENDING') {
return {
data: {
items: [createReviewItem(1)],
totalElements: 21,
totalPages: 2,
page,
size: 20,
total: 21,
},
isLoading: false,
}
}
return { data: null, isLoading: false }
})
})
it('exports a named component function', () => {
expect(typeof ReviewsPage).toBe('function')
})
it('renders pagination for pending reviews when totalPages > 1', () => {
const html = renderToStaticMarkup(createElement(ReviewsPage))
expect(html).toContain('reviews.pageSummary')
expect(paginationProps).toHaveLength(1)
expect(paginationProps[0]?.page).toBe(0)
expect(paginationProps[0]?.totalPages).toBe(2)
})
it('renders disabled-style pagination when there is only one page', () => {
useReviewListMock.mockImplementation((status: string, _namespaceId: unknown, page: number, _size: number, _sortDirection: string, enabled: boolean) => {
if (!enabled || status !== 'PENDING') {
return { data: null, isLoading: false }
}
return {
data: {
items: [createReviewItem(2)],
totalElements: 1,
totalPages: 1,
page,
size: 20,
total: 1,
},
isLoading: false,
}
})
renderToStaticMarkup(createElement(ReviewsPage))
expect(paginationProps).toHaveLength(1)
expect(paginationProps[0]?.page).toBe(0)
expect(paginationProps[0]?.totalPages).toBe(1)
})
})

View file

@ -2,7 +2,6 @@ import { useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { FileCheck2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
@ -17,6 +16,7 @@ import {
import { useReviewList } from '@/features/review/use-review-list'
import { useAuth } from '@/features/auth/use-auth'
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
import { Pagination } from '@/shared/components/pagination'
import { formatLocalDateTime } from '@/shared/lib/date-time'
import { ProfileReviewTable } from './profile-review-table'
@ -72,31 +72,11 @@ export function ReviewsPage() {
}
function renderPagination(status: ReviewStatus, totalElements: number, totalPages: number) {
if (totalPages <= 1) return null
const currentPage = pages[status]
return (
<div className="flex flex-col gap-3 border-t border-border/60 px-6 py-4 text-sm text-muted-foreground md:flex-row md:items-center md:justify-between">
<p>{t('reviews.pageSummary', { total: totalElements, page: currentPage + 1 })}</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={currentPage === 0}
onClick={() => changePage(status, currentPage - 1)}
>
{t('reviews.prevPage')}
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={currentPage >= totalPages - 1}
onClick={() => changePage(status, currentPage + 1)}
>
{t('reviews.nextPage')}
</Button>
</div>
<Pagination page={currentPage} totalPages={Math.max(totalPages, 1)} onPageChange={(nextPage) => changePage(status, nextPage)} />
</div>
)
}