mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
fix(ui): 修复 Skills 管理与模型配置交互展示问题 (#4323)
* fix(ui): improve skills and provider interactions * fix(skills): keep repo manager available on skills.sh * test(skills): cover repo switch after refresh * fix(skills): keep refresh available for empty repo results --------- Co-authored-by: thisTom <19346741+thisTom@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const PROVIDER_CARD_TSX = path.resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"src",
|
||||
"components",
|
||||
"providers",
|
||||
"ProviderCard.tsx",
|
||||
);
|
||||
|
||||
describe("ProviderCard layout", () => {
|
||||
const source = fs.readFileSync(PROVIDER_CARD_TSX, "utf8");
|
||||
|
||||
it("lets website links use available card width before truncating", () => {
|
||||
expect(source).not.toContain("max-w-[280px]");
|
||||
expect(source).toContain("flex min-w-0 flex-1 items-center gap-2");
|
||||
expect(source).toContain("min-w-0 flex-1 space-y-1");
|
||||
expect(source).toContain(
|
||||
"inline-flex max-w-full items-center overflow-hidden text-left text-sm",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const SELECT_TSX = path.resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"src",
|
||||
"components",
|
||||
"ui",
|
||||
"select.tsx",
|
||||
);
|
||||
|
||||
const stripComments = (source: string) =>
|
||||
source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
|
||||
|
||||
describe("SelectItem indicator structure", () => {
|
||||
const source = stripComments(fs.readFileSync(SELECT_TSX, "utf8"));
|
||||
|
||||
it("renders an item indicator before item text", () => {
|
||||
const indicatorIndex = source.indexOf("SelectPrimitive.ItemIndicator");
|
||||
const itemTextIndex = source.indexOf("SelectPrimitive.ItemText");
|
||||
|
||||
expect(indicatorIndex).toBeGreaterThan(-1);
|
||||
expect(itemTextIndex).toBeGreaterThan(-1);
|
||||
expect(indicatorIndex).toBeLessThan(itemTextIndex);
|
||||
});
|
||||
|
||||
it("wraps the indicator in the first direct span", () => {
|
||||
const itemTextIndex = source.indexOf("SelectPrimitive.ItemText");
|
||||
const beforeItemText = source.slice(0, itemTextIndex);
|
||||
const lastSpanOpen = beforeItemText.lastIndexOf("<span");
|
||||
const lastIndicator = beforeItemText.lastIndexOf("ItemIndicator");
|
||||
|
||||
expect(lastSpanOpen).toBeGreaterThan(-1);
|
||||
expect(lastSpanOpen).toBeLessThan(lastIndicator);
|
||||
});
|
||||
});
|
||||
@@ -5,33 +5,49 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
import {
|
||||
SkillsPage,
|
||||
getSkillsPageHeaderActions,
|
||||
type SkillsPageHandle,
|
||||
} from "@/components/skills/SkillsPage";
|
||||
import type {
|
||||
DiscoverableSkill,
|
||||
SkillRepo,
|
||||
SkillsShDiscoverableSkill,
|
||||
SkillsShSearchResult,
|
||||
} from "@/lib/api/skills";
|
||||
|
||||
const installMutateAsyncMock = vi.fn();
|
||||
let discoverableSkillsMock: DiscoverableSkill[] = [];
|
||||
let skillReposMock: SkillRepo[] = [];
|
||||
const refetchDiscoverableMock = vi.fn();
|
||||
|
||||
// Stable cache so repeated renders see referentially-equal data.
|
||||
// SkillsPage has `useEffect([skillsShResult, ...])` that calls setState — a
|
||||
// fresh object every render would loop forever.
|
||||
const searchCache = new Map<
|
||||
string,
|
||||
{ data: SkillsShSearchResult | undefined; isLoading: boolean; isFetching: boolean }
|
||||
{
|
||||
data: SkillsShSearchResult | undefined;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
isPlaceholderData?: boolean;
|
||||
}
|
||||
>();
|
||||
|
||||
const setSearchResult = (
|
||||
query: string,
|
||||
offset: number,
|
||||
result: SkillsShSearchResult | undefined,
|
||||
state: Partial<{
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
isPlaceholderData: boolean;
|
||||
}> = {},
|
||||
) => {
|
||||
searchCache.set(`${query}:${offset}`, {
|
||||
data: result,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
...state,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -45,10 +61,10 @@ vi.mock("sonner", () => ({
|
||||
|
||||
vi.mock("@/hooks/useSkills", () => ({
|
||||
useDiscoverableSkills: () => ({
|
||||
data: [] as DiscoverableSkill[],
|
||||
data: discoverableSkillsMock,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
refetch: refetchDiscoverableMock,
|
||||
}),
|
||||
useInstalledSkills: () => ({
|
||||
data: [],
|
||||
@@ -58,7 +74,7 @@ vi.mock("@/hooks/useSkills", () => ({
|
||||
mutateAsync: installMutateAsyncMock,
|
||||
}),
|
||||
useSkillRepos: () => ({
|
||||
data: [],
|
||||
data: skillReposMock,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useAddSkillRepo: () => ({
|
||||
@@ -88,10 +104,35 @@ const makeSkillsShSkill = (
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeDiscoverableSkill = (
|
||||
overrides: Partial<DiscoverableSkill> = {},
|
||||
): DiscoverableSkill => ({
|
||||
key: "repo-skill:owner-a:repo-a",
|
||||
name: "Repo Skill",
|
||||
description: "Skill from a configured repository",
|
||||
directory: "repo-skill",
|
||||
readmeUrl: "https://example.com/repo-skill",
|
||||
repoOwner: "owner-a",
|
||||
repoName: "repo-a",
|
||||
repoBranch: "main",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeSkillRepo = (overrides: Partial<SkillRepo> = {}): SkillRepo => ({
|
||||
owner: "owner-a",
|
||||
name: "repo-a",
|
||||
branch: "main",
|
||||
enabled: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("SkillsPage - skills.sh install (regression)", () => {
|
||||
beforeEach(() => {
|
||||
installMutateAsyncMock.mockReset();
|
||||
installMutateAsyncMock.mockResolvedValue({});
|
||||
discoverableSkillsMock = [];
|
||||
skillReposMock = [];
|
||||
refetchDiscoverableMock.mockReset();
|
||||
searchCache.clear();
|
||||
});
|
||||
|
||||
@@ -156,4 +197,143 @@ describe("SkillsPage - skills.sh install (regression)", () => {
|
||||
expect(callArgs.skill.repoName).toBe("repo-b");
|
||||
expect(callArgs.skill.name).toBe("Agent Browser B");
|
||||
});
|
||||
|
||||
it("keeps skills.sh results when submitting the same query again", async () => {
|
||||
const figmaSkill = makeSkillsShSkill({
|
||||
key: "figma-use:figma:mcp-server-guide",
|
||||
name: "figma-use",
|
||||
directory: "figma-use",
|
||||
repoOwner: "figma",
|
||||
repoName: "mcp-server-guide",
|
||||
});
|
||||
|
||||
setSearchResult("figma", 0, {
|
||||
skills: [figmaSkill],
|
||||
totalCount: 1,
|
||||
query: "figma",
|
||||
});
|
||||
|
||||
render(<SkillsPage initialApp="claude" />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /skills\.sh/i }));
|
||||
const input = screen.getByPlaceholderText(
|
||||
"skills.skillssh.searchPlaceholder",
|
||||
);
|
||||
await user.type(input, "figma");
|
||||
|
||||
const searchButton = screen.getByRole("button", {
|
||||
name: "skills.search",
|
||||
});
|
||||
await user.click(searchButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("figma-use")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(searchButton);
|
||||
|
||||
expect(screen.getByText("figma-use")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the skills.sh loading state while a new query is fetching", async () => {
|
||||
const figmaSkill = makeSkillsShSkill({
|
||||
key: "figma-use:figma:mcp-server-guide",
|
||||
name: "figma-use",
|
||||
directory: "figma-use",
|
||||
repoOwner: "figma",
|
||||
repoName: "mcp-server-guide",
|
||||
});
|
||||
|
||||
setSearchResult("figma", 0, {
|
||||
skills: [figmaSkill],
|
||||
totalCount: 1,
|
||||
query: "figma",
|
||||
});
|
||||
setSearchResult("react", 0, undefined, { isFetching: true });
|
||||
|
||||
render(<SkillsPage initialApp="claude" />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /skills\.sh/i }));
|
||||
const input = screen.getByPlaceholderText(
|
||||
"skills.skillssh.searchPlaceholder",
|
||||
);
|
||||
await user.type(input, "figma");
|
||||
|
||||
const searchButton = screen.getByRole("button", {
|
||||
name: "skills.search",
|
||||
});
|
||||
await user.click(searchButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("figma-use")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.clear(input);
|
||||
await user.type(input, "react");
|
||||
await user.click(searchButton);
|
||||
|
||||
expect(screen.getByText("skills.skillssh.loading")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reports the effective skills.sh source to parent chrome", async () => {
|
||||
const onSourceChange = vi.fn();
|
||||
|
||||
render(<SkillsPage initialApp="claude" onSourceChange={onSourceChange} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSourceChange).toHaveBeenCalledWith("skillssh");
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the repository source when configured repositories return no discoverable skills", async () => {
|
||||
skillReposMock = [makeSkillRepo()];
|
||||
const onSourceChange = vi.fn();
|
||||
|
||||
render(<SkillsPage initialApp="claude" onSourceChange={onSourceChange} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSourceChange).toHaveBeenCalledWith("repos");
|
||||
});
|
||||
expect(
|
||||
screen.getByPlaceholderText("skills.searchPlaceholder"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("can switch back to repository results after discoverable skills refresh", async () => {
|
||||
const onSourceChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(
|
||||
<SkillsPage initialApp="claude" onSourceChange={onSourceChange} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSourceChange).toHaveBeenCalledWith("skillssh");
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /skills\.sh/i }));
|
||||
|
||||
discoverableSkillsMock = [makeDiscoverableSkill()];
|
||||
skillReposMock = [makeSkillRepo()];
|
||||
rerender(
|
||||
<SkillsPage initialApp="claude" onSourceChange={onSourceChange} />,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "skills.searchSource.repos" }),
|
||||
);
|
||||
|
||||
expect(screen.getByText("Repo Skill")).toBeInTheDocument();
|
||||
expect(onSourceChange).toHaveBeenCalledWith("repos");
|
||||
});
|
||||
|
||||
it("exposes repository-only header actions for the parent chrome", () => {
|
||||
expect(
|
||||
getSkillsPageHeaderActions("repos").map((action) => action.key),
|
||||
).toEqual(["refresh-repos", "manage-repos"]);
|
||||
expect(
|
||||
getSkillsPageHeaderActions("skillssh").map((action) => action.key),
|
||||
).toEqual(["manage-repos"]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user