mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 01:18:45 +00:00
fix(webui): harden skill marketplace lifecycle
This commit is contained in:
parent
8a56eb06ad
commit
c440695aef
@ -765,6 +765,151 @@ async def test_webui_skills_marketplace_routes_search_and_install(
|
|||||||
await server_task
|
await server_task
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webui_skill_install_rejects_overlapping_requests(
|
||||||
|
bus: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
started = asyncio.Event()
|
||||||
|
finish = asyncio.Event()
|
||||||
|
|
||||||
|
async def install(
|
||||||
|
source: str,
|
||||||
|
skill_id: str,
|
||||||
|
workspace: Path,
|
||||||
|
*,
|
||||||
|
provider: str,
|
||||||
|
version: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
started.set()
|
||||||
|
await finish.wait()
|
||||||
|
skill_dir = workspace / "skills" / skill_id
|
||||||
|
skill_dir.mkdir(parents=True)
|
||||||
|
(skill_dir / "SKILL.md").write_text(
|
||||||
|
"---\nname: react-testing\ndescription: Test React apps.\n---\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return {"installed": True, "already_installed": False, "name": skill_id}
|
||||||
|
|
||||||
|
install_mock = AsyncMock(side_effect=install)
|
||||||
|
monkeypatch.setattr("nanobot.webui.ws_http.install_marketplace_skill", install_mock)
|
||||||
|
channel = _ch(
|
||||||
|
bus,
|
||||||
|
session_manager=_seed_session(tmp_path),
|
||||||
|
workspace_path=tmp_path,
|
||||||
|
port=_free_port(),
|
||||||
|
)
|
||||||
|
token = channel.gateway.tokens.issue_api_token(300)
|
||||||
|
path = (
|
||||||
|
"/api/webui/skills/install"
|
||||||
|
"?source=acme%2Fagent-skills&skill=react-testing"
|
||||||
|
)
|
||||||
|
request = _FakeReq(
|
||||||
|
{
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Host": "127.0.0.1:8765",
|
||||||
|
},
|
||||||
|
path=path,
|
||||||
|
)
|
||||||
|
|
||||||
|
first = asyncio.create_task(channel.gateway.http.dispatch(_LOCAL, request))
|
||||||
|
await started.wait()
|
||||||
|
overlapping = await channel.gateway.http.dispatch(_LOCAL, request)
|
||||||
|
|
||||||
|
assert overlapping.status_code == 409
|
||||||
|
assert "already in progress" in overlapping.body.decode()
|
||||||
|
assert install_mock.await_count == 1
|
||||||
|
|
||||||
|
finish.set()
|
||||||
|
completed = await first
|
||||||
|
assert completed.status_code == 200
|
||||||
|
assert install_mock.await_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webui_skill_delete_remains_local_only(
|
||||||
|
bus: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
delete = MagicMock()
|
||||||
|
policy = MagicMock()
|
||||||
|
policy.tools.webui_allow_remote_package_install = True
|
||||||
|
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: policy)
|
||||||
|
monkeypatch.setattr("nanobot.webui.ws_http.delete_webui_skill", delete)
|
||||||
|
channel = _ch(
|
||||||
|
bus,
|
||||||
|
session_manager=_seed_session(tmp_path),
|
||||||
|
workspace_path=tmp_path,
|
||||||
|
port=_free_port(),
|
||||||
|
)
|
||||||
|
token = channel.gateway.tokens.issue_api_token(300)
|
||||||
|
response = await channel.gateway.http.dispatch(
|
||||||
|
_REMOTE,
|
||||||
|
_FakeReq(
|
||||||
|
{"Authorization": f"Bearer {token}"},
|
||||||
|
path="/api/webui/skills/delete?name=custom-skill",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert "remote skill deletion is disabled" in response.body.decode()
|
||||||
|
delete.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webui_skill_install_honors_remote_install_opt_in(
|
||||||
|
bus: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
policy = MagicMock()
|
||||||
|
policy.tools.webui_allow_remote_package_install = True
|
||||||
|
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: policy)
|
||||||
|
|
||||||
|
async def install(
|
||||||
|
source: str,
|
||||||
|
skill_id: str,
|
||||||
|
workspace: Path,
|
||||||
|
*,
|
||||||
|
provider: str,
|
||||||
|
version: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
skill_dir = workspace / "skills" / skill_id
|
||||||
|
skill_dir.mkdir(parents=True)
|
||||||
|
(skill_dir / "SKILL.md").write_text(
|
||||||
|
"---\nname: react-testing\ndescription: Test React apps.\n---\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return {"installed": True, "already_installed": False, "name": skill_id}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.webui.ws_http.install_marketplace_skill",
|
||||||
|
AsyncMock(side_effect=install),
|
||||||
|
)
|
||||||
|
channel = _ch(
|
||||||
|
bus,
|
||||||
|
session_manager=_seed_session(tmp_path),
|
||||||
|
workspace_path=tmp_path,
|
||||||
|
port=_free_port(),
|
||||||
|
)
|
||||||
|
token = channel.gateway.tokens.issue_api_token(300)
|
||||||
|
response = await channel.gateway.http.dispatch(
|
||||||
|
_REMOTE,
|
||||||
|
_FakeReq(
|
||||||
|
{"Authorization": f"Bearer {token}"},
|
||||||
|
path=(
|
||||||
|
"/api/webui/skills/install"
|
||||||
|
"?source=acme%2Fagent-skills&skill=react-testing"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert json.loads(response.body.decode())["last_action"]["name"] == "react-testing"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_cli_apps_routes_require_token_and_return_payload(
|
async def test_cli_apps_routes_require_token_and_return_payload(
|
||||||
bus: MagicMock,
|
bus: MagicMock,
|
||||||
|
|||||||
@ -403,7 +403,7 @@ class ToolsConfig(Base):
|
|||||||
"webuiAllowRemotePackageInstall",
|
"webuiAllowRemotePackageInstall",
|
||||||
"webui_allow_remote_package_install",
|
"webui_allow_remote_package_install",
|
||||||
),
|
),
|
||||||
) # allow non-local WebUI clients to install optional Python packages
|
) # allow non-local WebUI clients to install optional packages and agent skills
|
||||||
mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
|
mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
|
||||||
ssrf_whitelist: list[str] = Field(default_factory=list) # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale)
|
ssrf_whitelist: list[str] = Field(default_factory=list) # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale)
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import shlex
|
import shlex
|
||||||
import shutil
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@ -113,19 +113,25 @@ def delete_webui_skill(
|
|||||||
target = skills_root / name
|
target = skills_root / name
|
||||||
if target.parent != skills_root:
|
if target.parent != skills_root:
|
||||||
raise SkillManagementError("invalid skill name")
|
raise SkillManagementError("invalid skill name")
|
||||||
if target.is_symlink():
|
if not target.is_symlink() and not target.is_dir():
|
||||||
target.unlink()
|
|
||||||
elif target.is_dir():
|
|
||||||
shutil.rmtree(target)
|
|
||||||
else:
|
|
||||||
raise SkillManagementError("skill directory was not found", status=404)
|
raise SkillManagementError("skill directory was not found", status=404)
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
next_disabled = set(config.agents.defaults.disabled_skills)
|
original_disabled = list(config.agents.defaults.disabled_skills)
|
||||||
|
next_disabled = set(original_disabled)
|
||||||
if name in next_disabled:
|
if name in next_disabled:
|
||||||
next_disabled.remove(name)
|
next_disabled.remove(name)
|
||||||
config.agents.defaults.disabled_skills = sorted(next_disabled)
|
with tempfile.TemporaryDirectory(prefix=".nanobot-delete-", dir=skills_root) as staging:
|
||||||
save_config(config)
|
staged_target = Path(staging) / name
|
||||||
|
target.replace(staged_target)
|
||||||
|
try:
|
||||||
|
if next_disabled != set(original_disabled):
|
||||||
|
config.agents.defaults.disabled_skills = sorted(next_disabled)
|
||||||
|
save_config(config)
|
||||||
|
except Exception:
|
||||||
|
config.agents.defaults.disabled_skills = original_disabled
|
||||||
|
staged_target.replace(target)
|
||||||
|
raise
|
||||||
disabled_skills.clear()
|
disabled_skills.clear()
|
||||||
disabled_skills.update(next_disabled)
|
disabled_skills.update(next_disabled)
|
||||||
return {"name": name, "enabled": False, "deleted": True}
|
return {"name": name, "enabled": False, "deleted": True}
|
||||||
|
|||||||
@ -530,7 +530,6 @@ async def _install_skillhub_skill(
|
|||||||
"name": skill_id,
|
"name": skill_id,
|
||||||
"provider": _PROVIDER_SKILLHUB,
|
"provider": _PROVIDER_SKILLHUB,
|
||||||
"version": version,
|
"version": version,
|
||||||
"verified": bool(signature.get("signed")),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -199,6 +199,7 @@ class GatewayHTTPHandler:
|
|||||||
self.skills_workspace_path = skills_workspace_path
|
self.skills_workspace_path = skills_workspace_path
|
||||||
self.disabled_skills = disabled_skills if disabled_skills is not None else set()
|
self.disabled_skills = disabled_skills if disabled_skills is not None else set()
|
||||||
self.skill_state_action = skill_state_action
|
self.skill_state_action = skill_state_action
|
||||||
|
self._skill_install_lock = asyncio.Lock()
|
||||||
self.cron_service = cron_service
|
self.cron_service = cron_service
|
||||||
self.local_trigger_store = local_trigger_store
|
self.local_trigger_store = local_trigger_store
|
||||||
self.cron_pending_job_ids = cron_pending_job_ids
|
self.cron_pending_job_ids = cron_pending_job_ids
|
||||||
@ -912,25 +913,28 @@ class GatewayHTTPHandler:
|
|||||||
return _http_error(401, "Unauthorized")
|
return _http_error(401, "Unauthorized")
|
||||||
if not self._allow_webui_package_install(connection, request):
|
if not self._allow_webui_package_install(connection, request):
|
||||||
return _http_error(403, "remote skill installation is disabled")
|
return _http_error(403, "remote skill installation is disabled")
|
||||||
|
if self._skill_install_lock.locked():
|
||||||
|
return _http_error(409, "another skill installation is already in progress")
|
||||||
|
|
||||||
query = _parse_query(request.path)
|
query = _parse_query(request.path)
|
||||||
provider = _query_first(query, "provider") or "skills_sh"
|
provider = _query_first(query, "provider") or "skills_sh"
|
||||||
source = _query_first(query, "source") or ""
|
source = _query_first(query, "source") or ""
|
||||||
skill_id = _query_first(query, "skill") or ""
|
skill_id = _query_first(query, "skill") or ""
|
||||||
version = _query_first(query, "version") or ""
|
version = _query_first(query, "version") or ""
|
||||||
try:
|
async with self._skill_install_lock:
|
||||||
action = await install_marketplace_skill(
|
try:
|
||||||
source,
|
action = await install_marketplace_skill(
|
||||||
skill_id,
|
source,
|
||||||
self.skills_workspace_path,
|
skill_id,
|
||||||
provider=provider,
|
self.skills_workspace_path,
|
||||||
version=version,
|
provider=provider,
|
||||||
)
|
version=version,
|
||||||
except SkillsMarketplaceError as exc:
|
)
|
||||||
return _http_error(exc.status, exc.message)
|
except SkillsMarketplaceError as exc:
|
||||||
except Exception:
|
return _http_error(exc.status, exc.message)
|
||||||
self._log.exception("skill installation failed")
|
except Exception:
|
||||||
return _http_error(500, "skill installation failed")
|
self._log.exception("skill installation failed")
|
||||||
|
return _http_error(500, "skill installation failed")
|
||||||
return _http_json_response({
|
return _http_json_response({
|
||||||
**webui_skills_payload(
|
**webui_skills_payload(
|
||||||
self.skills_workspace_path,
|
self.skills_workspace_path,
|
||||||
@ -983,7 +987,7 @@ class GatewayHTTPHandler:
|
|||||||
) -> Response:
|
) -> Response:
|
||||||
if not self.check_api_token(request):
|
if not self.check_api_token(request):
|
||||||
return _http_error(401, "Unauthorized")
|
return _http_error(401, "Unauthorized")
|
||||||
if not self._allow_webui_package_install(connection, request):
|
if not _is_local_browser_request(connection, request.headers):
|
||||||
return _http_error(403, "remote skill deletion is disabled")
|
return _http_error(403, "remote skill deletion is disabled")
|
||||||
name = _query_first(_parse_query(request.path), "name") or ""
|
name = _query_first(_parse_query(request.path), "name") or ""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -150,3 +150,30 @@ def test_delete_webui_skill_rejects_symlinked_skills_root(
|
|||||||
|
|
||||||
assert exc_info.value.status == 403
|
assert exc_info.value.status == 403
|
||||||
assert (outside / "custom-skill" / "SKILL.md").is_file()
|
assert (outside / "custom-skill" / "SKILL.md").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_webui_skill_restores_directory_when_config_save_fails(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
directory = _write_skill(tmp_path, "custom-skill")
|
||||||
|
config = _config("custom-skill")
|
||||||
|
monkeypatch.setattr("nanobot.webui.skills_api.load_config", lambda: config)
|
||||||
|
|
||||||
|
def fail_save(_config: object) -> None:
|
||||||
|
raise OSError("disk full")
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.webui.skills_api.save_config", fail_save)
|
||||||
|
disabled = {"custom-skill"}
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="disk full"):
|
||||||
|
delete_webui_skill(
|
||||||
|
tmp_path,
|
||||||
|
"custom-skill",
|
||||||
|
disabled_skills=disabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert directory.is_dir()
|
||||||
|
assert (directory / "SKILL.md").is_file()
|
||||||
|
assert config.agents.defaults.disabled_skills == ["custom-skill"]
|
||||||
|
assert disabled == {"custom-skill"}
|
||||||
|
|||||||
@ -477,7 +477,6 @@ async def test_install_skillhub_skill_checks_fingerprint_and_extracts_safely(
|
|||||||
"name": "ima-skills",
|
"name": "ima-skills",
|
||||||
"provider": "skillhub",
|
"provider": "skillhub",
|
||||||
"version": "1.1.8",
|
"version": "1.1.8",
|
||||||
"verified": True,
|
|
||||||
}
|
}
|
||||||
assert (tmp_path / "skills" / "ima-skills" / "SKILL.md").read_bytes() == skill_content
|
assert (tmp_path / "skills" / "ima-skills" / "SKILL.md").read_bytes() == skill_content
|
||||||
|
|
||||||
|
|||||||
@ -41,6 +41,7 @@ export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
|
|||||||
).length;
|
).length;
|
||||||
const [selectedSkill, setSelectedSkill] = useState<SkillSummary | null>(null);
|
const [selectedSkill, setSelectedSkill] = useState<SkillSummary | null>(null);
|
||||||
const [view, setView] = useState<"installed" | "discover">("installed");
|
const [view, setView] = useState<"installed" | "discover">("installed");
|
||||||
|
const [installingSkill, setInstallingSkill] = useState("");
|
||||||
const [installedQuery, setInstalledQuery] = useState("");
|
const [installedQuery, setInstalledQuery] = useState("");
|
||||||
const [installedFilter, setInstalledFilter] = useState<"all" | "enabled" | "disabled">(
|
const [installedFilter, setInstalledFilter] = useState<"all" | "enabled" | "disabled">(
|
||||||
"all",
|
"all",
|
||||||
@ -209,7 +210,11 @@ export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
) : (
|
) : (
|
||||||
<SkillsMarketplace installedSkills={skills} />
|
<SkillsMarketplace
|
||||||
|
installedSkills={skills}
|
||||||
|
installing={installingSkill}
|
||||||
|
onInstallingChange={setInstallingSkill}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SkillDetailSheet
|
<SkillDetailSheet
|
||||||
|
|||||||
@ -36,7 +36,15 @@ import type {
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useClient } from "@/providers/ClientProvider";
|
import { useClient } from "@/providers/ClientProvider";
|
||||||
|
|
||||||
export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillSummary[] }) {
|
export function SkillsMarketplace({
|
||||||
|
installedSkills,
|
||||||
|
installing,
|
||||||
|
onInstallingChange,
|
||||||
|
}: {
|
||||||
|
installedSkills: SkillSummary[];
|
||||||
|
installing: string;
|
||||||
|
onInstallingChange: (skillId: string) => void;
|
||||||
|
}) {
|
||||||
const { token } = useClient();
|
const { token } = useClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
@ -48,7 +56,6 @@ export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillS
|
|||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [provider, setProvider] = useState<MarketplaceProvider>("all");
|
const [provider, setProvider] = useState<MarketplaceProvider>("all");
|
||||||
const [selected, setSelected] = useState<MarketplaceSkillSummary | null>(null);
|
const [selected, setSelected] = useState<MarketplaceSkillSummary | null>(null);
|
||||||
const [installing, setInstalling] = useState("");
|
|
||||||
const installedNames = useMemo(
|
const installedNames = useMemo(
|
||||||
() => new Set(installedSkills.map((skill) => skill.name)),
|
() => new Set(installedSkills.map((skill) => skill.name)),
|
||||||
[installedSkills],
|
[installedSkills],
|
||||||
@ -149,7 +156,7 @@ export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillS
|
|||||||
|
|
||||||
const install = async (skill: MarketplaceSkillSummary) => {
|
const install = async (skill: MarketplaceSkillSummary) => {
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
setInstalling(skill.id);
|
onInstallingChange(skill.id);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const payload = await installMarketplaceSkill(
|
const payload = await installMarketplaceSkill(
|
||||||
@ -179,7 +186,7 @@ export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillS
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setInstalling("");
|
onInstallingChange("");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -396,6 +403,7 @@ function MarketplaceSkillGroups({
|
|||||||
grouped: boolean;
|
grouped: boolean;
|
||||||
onSelect: (skill: MarketplaceSkillSummary) => void;
|
onSelect: (skill: MarketplaceSkillSummary) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const providers: Array<Exclude<MarketplaceProvider, "all">> = [
|
const providers: Array<Exclude<MarketplaceProvider, "all">> = [
|
||||||
"skills_sh",
|
"skills_sh",
|
||||||
"skillhub",
|
"skillhub",
|
||||||
@ -425,7 +433,10 @@ function MarketplaceSkillGroups({
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||||
aria-label={`Open ${providerLabel(provider)}`}
|
aria-label={t("settings.skills.marketplaceOpenProvider", {
|
||||||
|
provider: providerLabel(provider),
|
||||||
|
defaultValue: "Open {{provider}}",
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-3.5 w-3.5" aria-hidden />
|
<ExternalLink className="h-3.5 w-3.5" aria-hidden />
|
||||||
</a>
|
</a>
|
||||||
@ -613,6 +624,9 @@ function providerUrl(provider: Exclude<MarketplaceProvider, "all">): string {
|
|||||||
|
|
||||||
function TrendSparkline({ values }: { values?: number[] }) {
|
function TrendSparkline({ values }: { values?: number[] }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const trendLabel = t("settings.skills.marketplaceTrendLabel", {
|
||||||
|
defaultValue: "8-week install trend",
|
||||||
|
});
|
||||||
|
|
||||||
if (values === undefined) {
|
if (values === undefined) {
|
||||||
return <span className="hidden h-[30px] w-24 shrink-0 sm:block" aria-hidden />;
|
return <span className="hidden h-[30px] w-24 shrink-0 sm:block" aria-hidden />;
|
||||||
@ -647,9 +661,9 @@ function TrendSparkline({ values }: { values?: number[] }) {
|
|||||||
viewBox={`0 0 ${width} ${height}`}
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
className="hidden h-[30px] w-24 shrink-0 overflow-visible text-foreground/40 sm:block"
|
className="hidden h-[30px] w-24 shrink-0 overflow-visible text-foreground/40 sm:block"
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="8-week install trend"
|
aria-label={trendLabel}
|
||||||
>
|
>
|
||||||
<title>8-week install trend</title>
|
<title>{trendLabel}</title>
|
||||||
<path d={area} fill="currentColor" opacity="0.06" />
|
<path d={area} fill="currentColor" opacity="0.06" />
|
||||||
<path
|
<path
|
||||||
d={line}
|
d={line}
|
||||||
|
|||||||
@ -837,6 +837,7 @@
|
|||||||
"marketplaceConfirmDescription": "This third-party skill comes from {{provider}} ({{source}}) and may include instructions or executable scripts.",
|
"marketplaceConfirmDescription": "This third-party skill comes from {{provider}} ({{source}}) and may include instructions or executable scripts.",
|
||||||
"marketplaceConfirmInstall": "Install skill",
|
"marketplaceConfirmInstall": "Install skill",
|
||||||
"marketplaceOpen": "Open {{name}} on {{provider}}",
|
"marketplaceOpen": "Open {{name}} on {{provider}}",
|
||||||
|
"marketplaceOpenProvider": "Open {{provider}}",
|
||||||
"marketplaceInstalls24h": "{{formattedCount}} installs / 24h",
|
"marketplaceInstalls24h": "{{formattedCount}} installs / 24h",
|
||||||
"marketplaceInstalls": "{{formattedCount}} installs",
|
"marketplaceInstalls": "{{formattedCount}} installs",
|
||||||
"marketplaceNpxRequired": "Node.js with npx is required",
|
"marketplaceNpxRequired": "Node.js with npx is required",
|
||||||
@ -844,6 +845,7 @@
|
|||||||
"marketplaceInstalled": "Installed",
|
"marketplaceInstalled": "Installed",
|
||||||
"marketplaceInstall": "Install",
|
"marketplaceInstall": "Install",
|
||||||
"marketplaceNoTrend": "No trend yet",
|
"marketplaceNoTrend": "No trend yet",
|
||||||
|
"marketplaceTrendLabel": "8-week install trend",
|
||||||
"featured": "Agent skills",
|
"featured": "Agent skills",
|
||||||
"empty": "No skills are available.",
|
"empty": "No skills are available.",
|
||||||
"sourceWorkspace": "Custom",
|
"sourceWorkspace": "Custom",
|
||||||
|
|||||||
@ -824,6 +824,7 @@
|
|||||||
"marketplaceConfirmDescription": "Este skill de terceros procede de {{provider}} ({{source}}) y puede incluir instrucciones o scripts ejecutables.",
|
"marketplaceConfirmDescription": "Este skill de terceros procede de {{provider}} ({{source}}) y puede incluir instrucciones o scripts ejecutables.",
|
||||||
"marketplaceConfirmInstall": "Instalar skill",
|
"marketplaceConfirmInstall": "Instalar skill",
|
||||||
"marketplaceOpen": "Abrir {{name}} en {{provider}}",
|
"marketplaceOpen": "Abrir {{name}} en {{provider}}",
|
||||||
|
"marketplaceOpenProvider": "Abrir {{provider}}",
|
||||||
"marketplaceInstalls24h": "{{formattedCount}} instalaciones / 24 h",
|
"marketplaceInstalls24h": "{{formattedCount}} instalaciones / 24 h",
|
||||||
"marketplaceInstalls": "{{formattedCount}} instalaciones",
|
"marketplaceInstalls": "{{formattedCount}} instalaciones",
|
||||||
"marketplaceNpxRequired": "Se requiere Node.js con npx",
|
"marketplaceNpxRequired": "Se requiere Node.js con npx",
|
||||||
@ -831,6 +832,7 @@
|
|||||||
"marketplaceInstalled": "Instalado",
|
"marketplaceInstalled": "Instalado",
|
||||||
"marketplaceInstall": "Instalar",
|
"marketplaceInstall": "Instalar",
|
||||||
"marketplaceNoTrend": "Sin tendencia todavía",
|
"marketplaceNoTrend": "Sin tendencia todavía",
|
||||||
|
"marketplaceTrendLabel": "Tendencia de instalaciones de 8 semanas",
|
||||||
"featured": "Habilidades del agente",
|
"featured": "Habilidades del agente",
|
||||||
"empty": "No hay habilidades disponibles.",
|
"empty": "No hay habilidades disponibles.",
|
||||||
"sourceWorkspace": "Personalizada",
|
"sourceWorkspace": "Personalizada",
|
||||||
|
|||||||
@ -823,6 +823,7 @@
|
|||||||
"marketplaceConfirmDescription": "Cette compétence tierce provient de {{provider}} ({{source}}) et peut contenir des instructions ou des scripts exécutables.",
|
"marketplaceConfirmDescription": "Cette compétence tierce provient de {{provider}} ({{source}}) et peut contenir des instructions ou des scripts exécutables.",
|
||||||
"marketplaceConfirmInstall": "Installer la compétence",
|
"marketplaceConfirmInstall": "Installer la compétence",
|
||||||
"marketplaceOpen": "Ouvrir {{name}} sur {{provider}}",
|
"marketplaceOpen": "Ouvrir {{name}} sur {{provider}}",
|
||||||
|
"marketplaceOpenProvider": "Ouvrir {{provider}}",
|
||||||
"marketplaceInstalls24h": "{{formattedCount}} installations / 24 h",
|
"marketplaceInstalls24h": "{{formattedCount}} installations / 24 h",
|
||||||
"marketplaceInstalls": "{{formattedCount}} installations",
|
"marketplaceInstalls": "{{formattedCount}} installations",
|
||||||
"marketplaceNpxRequired": "Node.js avec npx est requis",
|
"marketplaceNpxRequired": "Node.js avec npx est requis",
|
||||||
@ -830,6 +831,7 @@
|
|||||||
"marketplaceInstalled": "Installée",
|
"marketplaceInstalled": "Installée",
|
||||||
"marketplaceInstall": "Installer",
|
"marketplaceInstall": "Installer",
|
||||||
"marketplaceNoTrend": "Pas encore de tendance",
|
"marketplaceNoTrend": "Pas encore de tendance",
|
||||||
|
"marketplaceTrendLabel": "Tendance des installations sur 8 semaines",
|
||||||
"featured": "Compétences agent",
|
"featured": "Compétences agent",
|
||||||
"empty": "Aucune compétence disponible.",
|
"empty": "Aucune compétence disponible.",
|
||||||
"sourceWorkspace": "Personnalisée",
|
"sourceWorkspace": "Personnalisée",
|
||||||
|
|||||||
@ -823,6 +823,7 @@
|
|||||||
"marketplaceConfirmDescription": "Skill pihak ketiga ini berasal dari {{provider}} ({{source}}) dan mungkin berisi instruksi atau skrip yang dapat dijalankan.",
|
"marketplaceConfirmDescription": "Skill pihak ketiga ini berasal dari {{provider}} ({{source}}) dan mungkin berisi instruksi atau skrip yang dapat dijalankan.",
|
||||||
"marketplaceConfirmInstall": "Pasang skill",
|
"marketplaceConfirmInstall": "Pasang skill",
|
||||||
"marketplaceOpen": "Buka {{name}} di {{provider}}",
|
"marketplaceOpen": "Buka {{name}} di {{provider}}",
|
||||||
|
"marketplaceOpenProvider": "Buka {{provider}}",
|
||||||
"marketplaceInstalls24h": "{{formattedCount}} pemasangan / 24 jam",
|
"marketplaceInstalls24h": "{{formattedCount}} pemasangan / 24 jam",
|
||||||
"marketplaceInstalls": "{{formattedCount}} pemasangan",
|
"marketplaceInstalls": "{{formattedCount}} pemasangan",
|
||||||
"marketplaceNpxRequired": "Node.js dengan npx diperlukan",
|
"marketplaceNpxRequired": "Node.js dengan npx diperlukan",
|
||||||
@ -830,6 +831,7 @@
|
|||||||
"marketplaceInstalled": "Terpasang",
|
"marketplaceInstalled": "Terpasang",
|
||||||
"marketplaceInstall": "Pasang",
|
"marketplaceInstall": "Pasang",
|
||||||
"marketplaceNoTrend": "Belum ada tren",
|
"marketplaceNoTrend": "Belum ada tren",
|
||||||
|
"marketplaceTrendLabel": "Tren pemasangan 8 minggu",
|
||||||
"featured": "Skill agent",
|
"featured": "Skill agent",
|
||||||
"empty": "Tidak ada skill yang tersedia.",
|
"empty": "Tidak ada skill yang tersedia.",
|
||||||
"sourceWorkspace": "Kustom",
|
"sourceWorkspace": "Kustom",
|
||||||
|
|||||||
@ -823,6 +823,7 @@
|
|||||||
"marketplaceConfirmDescription": "このサードパーティ製スキルは {{provider}}({{source}})から提供され、指示や実行可能なスクリプトを含む場合があります。",
|
"marketplaceConfirmDescription": "このサードパーティ製スキルは {{provider}}({{source}})から提供され、指示や実行可能なスクリプトを含む場合があります。",
|
||||||
"marketplaceConfirmInstall": "スキルをインストール",
|
"marketplaceConfirmInstall": "スキルをインストール",
|
||||||
"marketplaceOpen": "{{provider}} で {{name}} を開く",
|
"marketplaceOpen": "{{provider}} で {{name}} を開く",
|
||||||
|
"marketplaceOpenProvider": "{{provider}} を開く",
|
||||||
"marketplaceInstalls24h": "24時間で {{formattedCount}} 回インストール",
|
"marketplaceInstalls24h": "24時間で {{formattedCount}} 回インストール",
|
||||||
"marketplaceInstalls": "{{formattedCount}} 回インストール",
|
"marketplaceInstalls": "{{formattedCount}} 回インストール",
|
||||||
"marketplaceNpxRequired": "npx を含む Node.js が必要です",
|
"marketplaceNpxRequired": "npx を含む Node.js が必要です",
|
||||||
@ -830,6 +831,7 @@
|
|||||||
"marketplaceInstalled": "インストール済み",
|
"marketplaceInstalled": "インストール済み",
|
||||||
"marketplaceInstall": "インストール",
|
"marketplaceInstall": "インストール",
|
||||||
"marketplaceNoTrend": "トレンドなし",
|
"marketplaceNoTrend": "トレンドなし",
|
||||||
|
"marketplaceTrendLabel": "8週間のインストール推移",
|
||||||
"featured": "エージェントスキル",
|
"featured": "エージェントスキル",
|
||||||
"empty": "利用可能なスキルはありません。",
|
"empty": "利用可能なスキルはありません。",
|
||||||
"sourceWorkspace": "カスタム",
|
"sourceWorkspace": "カスタム",
|
||||||
|
|||||||
@ -823,6 +823,7 @@
|
|||||||
"marketplaceConfirmDescription": "이 타사 스킬은 {{provider}}({{source}})에서 제공되며 지침이나 실행 가능한 스크립트를 포함할 수 있습니다.",
|
"marketplaceConfirmDescription": "이 타사 스킬은 {{provider}}({{source}})에서 제공되며 지침이나 실행 가능한 스크립트를 포함할 수 있습니다.",
|
||||||
"marketplaceConfirmInstall": "스킬 설치",
|
"marketplaceConfirmInstall": "스킬 설치",
|
||||||
"marketplaceOpen": "{{provider}}에서 {{name}} 열기",
|
"marketplaceOpen": "{{provider}}에서 {{name}} 열기",
|
||||||
|
"marketplaceOpenProvider": "{{provider}} 열기",
|
||||||
"marketplaceInstalls24h": "24시간 동안 {{formattedCount}}회 설치",
|
"marketplaceInstalls24h": "24시간 동안 {{formattedCount}}회 설치",
|
||||||
"marketplaceInstalls": "{{formattedCount}}회 설치",
|
"marketplaceInstalls": "{{formattedCount}}회 설치",
|
||||||
"marketplaceNpxRequired": "npx가 포함된 Node.js가 필요합니다",
|
"marketplaceNpxRequired": "npx가 포함된 Node.js가 필요합니다",
|
||||||
@ -830,6 +831,7 @@
|
|||||||
"marketplaceInstalled": "설치됨",
|
"marketplaceInstalled": "설치됨",
|
||||||
"marketplaceInstall": "설치",
|
"marketplaceInstall": "설치",
|
||||||
"marketplaceNoTrend": "추세 없음",
|
"marketplaceNoTrend": "추세 없음",
|
||||||
|
"marketplaceTrendLabel": "8주 설치 추이",
|
||||||
"featured": "에이전트 스킬",
|
"featured": "에이전트 스킬",
|
||||||
"empty": "사용 가능한 스킬이 없습니다.",
|
"empty": "사용 가능한 스킬이 없습니다.",
|
||||||
"sourceWorkspace": "사용자 지정",
|
"sourceWorkspace": "사용자 지정",
|
||||||
|
|||||||
@ -837,6 +837,7 @@
|
|||||||
"marketplaceConfirmDescription": "Esta skill de terceiros vem de {{provider}} ({{source}}) e pode incluir instruções ou scripts executáveis.",
|
"marketplaceConfirmDescription": "Esta skill de terceiros vem de {{provider}} ({{source}}) e pode incluir instruções ou scripts executáveis.",
|
||||||
"marketplaceConfirmInstall": "Instalar skill",
|
"marketplaceConfirmInstall": "Instalar skill",
|
||||||
"marketplaceOpen": "Abrir {{name}} no {{provider}}",
|
"marketplaceOpen": "Abrir {{name}} no {{provider}}",
|
||||||
|
"marketplaceOpenProvider": "Abrir {{provider}}",
|
||||||
"marketplaceInstalls24h": "{{formattedCount}} instalações / 24 h",
|
"marketplaceInstalls24h": "{{formattedCount}} instalações / 24 h",
|
||||||
"marketplaceInstalls": "{{formattedCount}} instalações",
|
"marketplaceInstalls": "{{formattedCount}} instalações",
|
||||||
"marketplaceNpxRequired": "Node.js com npx é necessário",
|
"marketplaceNpxRequired": "Node.js com npx é necessário",
|
||||||
@ -844,6 +845,7 @@
|
|||||||
"marketplaceInstalled": "Instalada",
|
"marketplaceInstalled": "Instalada",
|
||||||
"marketplaceInstall": "Instalar",
|
"marketplaceInstall": "Instalar",
|
||||||
"marketplaceNoTrend": "Ainda sem tendência",
|
"marketplaceNoTrend": "Ainda sem tendência",
|
||||||
|
"marketplaceTrendLabel": "Tendência de instalações em 8 semanas",
|
||||||
"featured": "Skills do agente",
|
"featured": "Skills do agente",
|
||||||
"empty": "Nenhuma skill disponível.",
|
"empty": "Nenhuma skill disponível.",
|
||||||
"sourceWorkspace": "Personalizada",
|
"sourceWorkspace": "Personalizada",
|
||||||
|
|||||||
@ -823,6 +823,7 @@
|
|||||||
"marketplaceConfirmDescription": "Kỹ năng bên thứ ba này đến từ {{provider}} ({{source}}) và có thể chứa hướng dẫn hoặc tập lệnh thực thi.",
|
"marketplaceConfirmDescription": "Kỹ năng bên thứ ba này đến từ {{provider}} ({{source}}) và có thể chứa hướng dẫn hoặc tập lệnh thực thi.",
|
||||||
"marketplaceConfirmInstall": "Cài đặt kỹ năng",
|
"marketplaceConfirmInstall": "Cài đặt kỹ năng",
|
||||||
"marketplaceOpen": "Mở {{name}} trên {{provider}}",
|
"marketplaceOpen": "Mở {{name}} trên {{provider}}",
|
||||||
|
"marketplaceOpenProvider": "Mở {{provider}}",
|
||||||
"marketplaceInstalls24h": "{{formattedCount}} lượt cài đặt / 24 giờ",
|
"marketplaceInstalls24h": "{{formattedCount}} lượt cài đặt / 24 giờ",
|
||||||
"marketplaceInstalls": "{{formattedCount}} lượt cài đặt",
|
"marketplaceInstalls": "{{formattedCount}} lượt cài đặt",
|
||||||
"marketplaceNpxRequired": "Cần Node.js có npx",
|
"marketplaceNpxRequired": "Cần Node.js có npx",
|
||||||
@ -830,6 +831,7 @@
|
|||||||
"marketplaceInstalled": "Đã cài đặt",
|
"marketplaceInstalled": "Đã cài đặt",
|
||||||
"marketplaceInstall": "Cài đặt",
|
"marketplaceInstall": "Cài đặt",
|
||||||
"marketplaceNoTrend": "Chưa có xu hướng",
|
"marketplaceNoTrend": "Chưa có xu hướng",
|
||||||
|
"marketplaceTrendLabel": "Xu hướng lượt cài đặt trong 8 tuần",
|
||||||
"featured": "Kỹ năng agent",
|
"featured": "Kỹ năng agent",
|
||||||
"empty": "Không có kỹ năng nào khả dụng.",
|
"empty": "Không có kỹ năng nào khả dụng.",
|
||||||
"sourceWorkspace": "Tùy chỉnh",
|
"sourceWorkspace": "Tùy chỉnh",
|
||||||
|
|||||||
@ -837,6 +837,7 @@
|
|||||||
"marketplaceConfirmDescription": "此第三方技能来自 {{provider}}({{source}}),其中可能包含操作指令或可执行脚本。",
|
"marketplaceConfirmDescription": "此第三方技能来自 {{provider}}({{source}}),其中可能包含操作指令或可执行脚本。",
|
||||||
"marketplaceConfirmInstall": "安装技能",
|
"marketplaceConfirmInstall": "安装技能",
|
||||||
"marketplaceOpen": "在 {{provider}} 中打开 {{name}}",
|
"marketplaceOpen": "在 {{provider}} 中打开 {{name}}",
|
||||||
|
"marketplaceOpenProvider": "打开 {{provider}}",
|
||||||
"marketplaceInstalls24h": "24 小时内安装 {{formattedCount}} 次",
|
"marketplaceInstalls24h": "24 小时内安装 {{formattedCount}} 次",
|
||||||
"marketplaceInstalls": "安装 {{formattedCount}} 次",
|
"marketplaceInstalls": "安装 {{formattedCount}} 次",
|
||||||
"marketplaceNpxRequired": "需要安装带有 npx 的 Node.js",
|
"marketplaceNpxRequired": "需要安装带有 npx 的 Node.js",
|
||||||
@ -844,6 +845,7 @@
|
|||||||
"marketplaceInstalled": "已安装",
|
"marketplaceInstalled": "已安装",
|
||||||
"marketplaceInstall": "安装",
|
"marketplaceInstall": "安装",
|
||||||
"marketplaceNoTrend": "暂无趋势",
|
"marketplaceNoTrend": "暂无趋势",
|
||||||
|
"marketplaceTrendLabel": "近 8 周安装趋势",
|
||||||
"featured": "Agent 技能",
|
"featured": "Agent 技能",
|
||||||
"empty": "暂无可用技能。",
|
"empty": "暂无可用技能。",
|
||||||
"sourceWorkspace": "自定义",
|
"sourceWorkspace": "自定义",
|
||||||
|
|||||||
@ -823,6 +823,7 @@
|
|||||||
"marketplaceConfirmDescription": "此第三方技能來自 {{provider}}({{source}}),其中可能包含操作指示或可執行腳本。",
|
"marketplaceConfirmDescription": "此第三方技能來自 {{provider}}({{source}}),其中可能包含操作指示或可執行腳本。",
|
||||||
"marketplaceConfirmInstall": "安裝技能",
|
"marketplaceConfirmInstall": "安裝技能",
|
||||||
"marketplaceOpen": "在 {{provider}} 開啟 {{name}}",
|
"marketplaceOpen": "在 {{provider}} 開啟 {{name}}",
|
||||||
|
"marketplaceOpenProvider": "開啟 {{provider}}",
|
||||||
"marketplaceInstalls24h": "24 小時內安裝 {{formattedCount}} 次",
|
"marketplaceInstalls24h": "24 小時內安裝 {{formattedCount}} 次",
|
||||||
"marketplaceInstalls": "安裝 {{formattedCount}} 次",
|
"marketplaceInstalls": "安裝 {{formattedCount}} 次",
|
||||||
"marketplaceNpxRequired": "需要安裝包含 npx 的 Node.js",
|
"marketplaceNpxRequired": "需要安裝包含 npx 的 Node.js",
|
||||||
@ -830,6 +831,7 @@
|
|||||||
"marketplaceInstalled": "已安裝",
|
"marketplaceInstalled": "已安裝",
|
||||||
"marketplaceInstall": "安裝",
|
"marketplaceInstall": "安裝",
|
||||||
"marketplaceNoTrend": "暫無趨勢",
|
"marketplaceNoTrend": "暫無趨勢",
|
||||||
|
"marketplaceTrendLabel": "近 8 週安裝趨勢",
|
||||||
"featured": "Agent 技能",
|
"featured": "Agent 技能",
|
||||||
"empty": "目前沒有可用的技能。",
|
"empty": "目前沒有可用的技能。",
|
||||||
"sourceWorkspace": "自訂",
|
"sourceWorkspace": "自訂",
|
||||||
|
|||||||
@ -37,7 +37,11 @@ function mockFetchRoutes(routes: Record<string, unknown>): void {
|
|||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
vi.fn(async (input: RequestInfo | URL) => {
|
vi.fn(async (input: RequestInfo | URL) => {
|
||||||
const body = routes[String(input)];
|
const route = routes[String(input)];
|
||||||
|
const body =
|
||||||
|
typeof route === "function"
|
||||||
|
? await (route as () => unknown | Promise<unknown>)()
|
||||||
|
: route;
|
||||||
return body === undefined
|
return body === undefined
|
||||||
? ({ ok: false, status: 404, json: async () => ({}) } as Response)
|
? ({ ok: false, status: 404, json: async () => ({}) } as Response)
|
||||||
: jsonResponse(body);
|
: jsonResponse(body);
|
||||||
@ -597,6 +601,26 @@ describe("App layout", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("discovers and installs a skill from skills.sh", async () => {
|
it("discovers and installs a skill from skills.sh", async () => {
|
||||||
|
let finishInstall!: (value: unknown) => void;
|
||||||
|
const pendingInstall = new Promise<unknown>((resolve) => {
|
||||||
|
finishInstall = resolve;
|
||||||
|
});
|
||||||
|
const installedPayload = {
|
||||||
|
skills: [
|
||||||
|
{
|
||||||
|
name: "react-testing",
|
||||||
|
description: "Test React apps.",
|
||||||
|
source: "workspace",
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{ name: "cron", description: "Schedule reminders.", source: "builtin", available: true },
|
||||||
|
],
|
||||||
|
last_action: {
|
||||||
|
installed: true,
|
||||||
|
already_installed: false,
|
||||||
|
name: "react-testing",
|
||||||
|
},
|
||||||
|
};
|
||||||
mockFetchRoutes({
|
mockFetchRoutes({
|
||||||
"/api/settings": baseSettingsPayload(),
|
"/api/settings": baseSettingsPayload(),
|
||||||
"/api/settings/cli-apps": { apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" },
|
"/api/settings/cli-apps": { apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" },
|
||||||
@ -683,22 +707,8 @@ describe("App layout", () => {
|
|||||||
"/api/webui/skills/trends?id=acme%2Fagent-skills%2Freact-testing": {
|
"/api/webui/skills/trends?id=acme%2Fagent-skills%2Freact-testing": {
|
||||||
trends: { "acme/agent-skills/react-testing": [] },
|
trends: { "acme/agent-skills/react-testing": [] },
|
||||||
},
|
},
|
||||||
"/api/webui/skills/install?provider=skills_sh&source=acme%2Fagent-skills&skill=react-testing": {
|
"/api/webui/skills/install?provider=skills_sh&source=acme%2Fagent-skills&skill=react-testing":
|
||||||
skills: [
|
() => pendingInstall,
|
||||||
{
|
|
||||||
name: "react-testing",
|
|
||||||
description: "Test React apps.",
|
|
||||||
source: "workspace",
|
|
||||||
available: true,
|
|
||||||
},
|
|
||||||
{ name: "cron", description: "Schedule reminders.", source: "builtin", available: true },
|
|
||||||
],
|
|
||||||
last_action: {
|
|
||||||
installed: true,
|
|
||||||
already_installed: false,
|
|
||||||
name: "react-testing",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
@ -751,10 +761,20 @@ describe("App layout", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: "Installed" }));
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: "Discover" }));
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole("button", { name: "Installed React Testing" }),
|
await screen.findByRole("button", { name: "Install find-skills" }),
|
||||||
).toBeDisabled();
|
).toBeDisabled();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
finishInstall(installedPayload);
|
||||||
|
await pendingInstall;
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("button", { name: "Install find-skills" })).toBeEnabled();
|
||||||
|
});
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "Installed" }));
|
fireEvent.click(screen.getByRole("tab", { name: "Installed" }));
|
||||||
expect(screen.getByText("react-testing")).toBeInTheDocument();
|
expect(screen.getByText("react-testing")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@ -1996,9 +2016,8 @@ describe("App layout", () => {
|
|||||||
expect(screen.getByTestId("provider-logo-openai")).toBeInTheDocument();
|
expect(screen.getByTestId("provider-logo-openai")).toBeInTheDocument();
|
||||||
expect(screen.queryByText(/Product names, logos, and brands/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/Product names, logos, and brands/)).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText("Not configured")).not.toBeInTheDocument();
|
expect(screen.queryByText("Not configured")).not.toBeInTheDocument();
|
||||||
const clickProviderRow = (label: string) => {
|
const clickProviderRow = async (label: string) => {
|
||||||
const providerLabel = screen
|
const providerLabel = (await screen.findAllByText(label))
|
||||||
.getAllByText(label)
|
|
||||||
.find((element) => element.className.includes("font-semibold"));
|
.find((element) => element.className.includes("font-semibold"));
|
||||||
expect(providerLabel).toBeTruthy();
|
expect(providerLabel).toBeTruthy();
|
||||||
fireEvent.click(providerLabel!);
|
fireEvent.click(providerLabel!);
|
||||||
@ -2009,21 +2028,21 @@ describe("App layout", () => {
|
|||||||
);
|
);
|
||||||
fireEvent.click(await screen.findByRole("menuitem", { name: label }));
|
fireEvent.click(await screen.findByRole("menuitem", { name: label }));
|
||||||
};
|
};
|
||||||
clickProviderRow("OpenAI");
|
await clickProviderRow("OpenAI");
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Edit" }));
|
fireEvent.click(screen.getByRole("button", { name: "Edit" }));
|
||||||
fireEvent.change(screen.getByPlaceholderText("Leave blank to keep the current key"), {
|
fireEvent.change(screen.getByPlaceholderText("Leave blank to keep the current key"), {
|
||||||
target: { value: "unsaved-openai-key" },
|
target: { value: "unsaved-openai-key" },
|
||||||
});
|
});
|
||||||
clickProviderRow("OpenAI");
|
await clickProviderRow("OpenAI");
|
||||||
await chooseProvider("OpenRouter");
|
await chooseProvider("OpenRouter");
|
||||||
clickProviderRow("OpenRouter");
|
await clickProviderRow("OpenRouter");
|
||||||
clickProviderRow("OpenAI");
|
await clickProviderRow("OpenAI");
|
||||||
expect(screen.getByText("open••••-key")).toBeInTheDocument();
|
expect(screen.getByText("open••••-key")).toBeInTheDocument();
|
||||||
expect(screen.queryByDisplayValue("unsaved-openai-key")).not.toBeInTheDocument();
|
expect(screen.queryByDisplayValue("unsaved-openai-key")).not.toBeInTheDocument();
|
||||||
clickProviderRow("OpenAI");
|
await clickProviderRow("OpenAI");
|
||||||
await chooseProvider("Ant Ling");
|
await chooseProvider("Ant Ling");
|
||||||
expect(screen.getByDisplayValue("https://api.ant-ling.com/v1")).toBeInTheDocument();
|
expect(screen.getByDisplayValue("https://api.ant-ling.com/v1")).toBeInTheDocument();
|
||||||
clickProviderRow("Ant Ling");
|
await clickProviderRow("Ant Ling");
|
||||||
await chooseProvider("Atomic Chat");
|
await chooseProvider("Atomic Chat");
|
||||||
expect(screen.getByDisplayValue("http://localhost:1337/v1")).toBeInTheDocument();
|
expect(screen.getByDisplayValue("http://localhost:1337/v1")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "Save provider" })).toBeEnabled();
|
expect(screen.getByRole("button", { name: "Save provider" })).toBeEnabled();
|
||||||
|
|||||||
@ -124,6 +124,7 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
|
|||||||
"settings.skills.marketplaceConfirmDescription",
|
"settings.skills.marketplaceConfirmDescription",
|
||||||
"settings.skills.marketplaceConfirmInstall",
|
"settings.skills.marketplaceConfirmInstall",
|
||||||
"settings.skills.marketplaceOpen",
|
"settings.skills.marketplaceOpen",
|
||||||
|
"settings.skills.marketplaceOpenProvider",
|
||||||
"settings.skills.marketplaceInstalls24h",
|
"settings.skills.marketplaceInstalls24h",
|
||||||
"settings.skills.marketplaceInstalls",
|
"settings.skills.marketplaceInstalls",
|
||||||
"settings.skills.marketplaceNpxRequired",
|
"settings.skills.marketplaceNpxRequired",
|
||||||
@ -131,6 +132,7 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
|
|||||||
"settings.skills.marketplaceInstalled",
|
"settings.skills.marketplaceInstalled",
|
||||||
"settings.skills.marketplaceInstall",
|
"settings.skills.marketplaceInstall",
|
||||||
"settings.skills.marketplaceNoTrend",
|
"settings.skills.marketplaceNoTrend",
|
||||||
|
"settings.skills.marketplaceTrendLabel",
|
||||||
"settings.nanobotFeatures.disable",
|
"settings.nanobotFeatures.disable",
|
||||||
"settings.nanobotFeatures.ready",
|
"settings.nanobotFeatures.ready",
|
||||||
"settings.nanobotFeatures.missingDependency",
|
"settings.nanobotFeatures.missingDependency",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user