Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions frontend-v2/src/features/settings/data/skill-deployments.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { afterEach, expect, it } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import type { ReactNode } from 'react'
import { http } from '@/lib/api-client'
import { fakeBackend } from '@/test/backend'
import type { SpacePlugin } from '@/api/generated.schemas'
import { marketplaceEntry, marketplaceSelection } from '../test-utils'
import { useSkillDeployments } from './skill-deployments'

const original = http.defaults.adapter
afterEach(() => {
http.defaults.adapter = original
})

const wrap = ({ children }: { children: ReactNode }) => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
return <QueryClientProvider client={client}>{children}</QueryClientProvider>
}

const skillSelection = (): SpacePlugin =>
marketplaceSelection(marketplaceEntry('skills', { kind: 'skill' }))

it('aggregates project receipts into a single delivery state', async () => {
const selection = skillSelection()
fakeBackend((config) =>
(config.url ?? '').includes('/plugins/results/')
? {
status: 200,
data: {
items: [
{ projectId: 'a', name: 'A', state: 'installed', installedVersion: '1.0.0', failure: '' },
{ projectId: 'b', name: 'B', state: 'applying', installedVersion: '', failure: '' },
],
},
}
: { status: 404 },
)
const { result } = renderHook(
() => useSkillDeployments('tenant', 'space', [selection]),
{ wrapper: wrap },
)
await waitFor(() => {
const summary = result.current.summaryOf(selection)
expect(summary?.aggregate).toBe('applying')
expect(summary?.states).toHaveLength(2)
})
})

it('marks a failed receipt and keeps the reason', async () => {
const selection = skillSelection()
fakeBackend((config) =>
(config.url ?? '').includes('/plugins/results/')
? {
status: 200,
data: {
items: [
{
projectId: 'a',
name: 'A',
state: 'failed',
installedVersion: '',
failure: '技能包下载失败。',
},
],
},
}
: { status: 404 },
)
const { result } = renderHook(
() => useSkillDeployments('tenant', 'space', [selection]),
{ wrapper: wrap },
)
await waitFor(() => {
expect(result.current.summaryOf(selection)?.aggregate).toBe('failed')
expect(result.current.summaryOf(selection)?.failure).toBe('技能包下载失败。')
})
})

it('reports none when a selected Skill has no receipts', async () => {
const selection = skillSelection()
fakeBackend((config) =>
(config.url ?? '').includes('/plugins/results/')
? { status: 200, data: { items: [] } }
: { status: 404 },
)
const { result } = renderHook(
() => useSkillDeployments('tenant', 'space', [selection]),
{ wrapper: wrap },
)
await waitFor(() => {
expect(result.current.summaryOf(selection)?.aggregate).toBe('none')
})
})
68 changes: 68 additions & 0 deletions frontend-v2/src/features/settings/data/skill-deployments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { useQuery } from '@tanstack/react-query'
import type { SpacePlugin } from '@/api/generated.schemas'
import { getSkillProgress, type SkillProgress } from '../market-api/generated'
import { pluginKey } from './marketplace'

export type DeploymentSummary = {
states: SkillProgress[]
aggregate: 'installed' | 'applying' | 'failed' | 'waiting' | 'removed' | 'none'
failure?: string
}

const aggregate = (items: SkillProgress[]): DeploymentSummary['aggregate'] => {
if (items.length === 0) return 'none'
if (items.some((item) => item.state === 'failed')) return 'failed'
if (items.every((item) => item.state === 'installed')) return 'installed'
if (items.every((item) => item.state === 'removed')) return 'removed'
if (items.some((item) => item.state === 'applying')) return 'applying'
return 'waiting'
}

/**
* Reads each selected Skill's per-project delivery receipts. A selection alone does not prove the
* package reached any project, so the Skills page and the workflow Agent picker can show what has
* actually been delivered, what is still in flight, and what failed.
*/
export function useSkillDeployments(
tenant: string,
space: string,
selections: readonly SpacePlugin[],
) {
const selected = selections.filter(
(selection) => selection.kind === 'skill' && selection.desiredState === 'installed',
)
const query = useQuery({
queryKey: ['skill-deployments', tenant, space, selected.map(pluginKey).join('|')],
enabled: tenant !== '' && space !== '' && selected.length > 0,
refetchInterval: 3000,
queryFn: async () => {
const rows = await Promise.all(
selected.map(async (selection) => {
const { items } = await getSkillProgress(
tenant,
space,
selection.sourceNamespace,
selection.identifier,
)
return [pluginKey(selection), items] as const
}),
)
return new Map<string, SkillProgress[]>(rows)
},
})
const byKey = query.data ?? new Map<string, SkillProgress[]>()
return {
byKey,
/** The aggregate delivery state for one selection; `undefined` while the receipt is unknown. */
summaryOf: (selection: SpacePlugin): DeploymentSummary | undefined => {
const items = byKey.get(pluginKey(selection))
if (items === undefined) return undefined
const failed = items.find((item) => item.state === 'failed')
return {
states: items,
aggregate: aggregate(items),
failure: failed?.failure,
}
},
}
}
130 changes: 125 additions & 5 deletions frontend-v2/src/features/settings/skills/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,9 @@ it('installs and removes the source plugin from the skill row and keeps the docu
const row = await screen.findByRole('article', { name: 'writing-guide' })
expect(within(row).getByText('来源插件未安装')).toBeInTheDocument()
await user.click(within(row).getByRole('button', { name: '安装技能' }))
await waitFor(() => expect(within(row).getByText('可用')).toBeInTheDocument())
await waitFor(() =>
expect(within(row).getByText('等待下发')).toBeInTheDocument(),
)
expect(
backend.state.selections.find(
(selection) => selection.identifier === 'writing-kit',
Expand All @@ -146,8 +148,8 @@ it('installs and removes the source plugin from the skill row and keeps the docu
).toHaveAttribute('href', '/settings/plugins?plugin=demo%2Fwriting-kit')
})

it('treats a selected market skill as available without a project node receipt', async () => {
installMarketplaceBackend({
it('treats a selected market skill as waiting for delivery without a project node receipt', async () => {
const backend = installMarketplaceBackend({
catalog: [writing],
selections: [
marketplaceSelection(writing, {
Expand All @@ -158,7 +160,14 @@ it('treats a selected market skill as available without a project node receipt',
})
renderSettingsPage(<SkillsPage />)
const row = await screen.findByRole('article', { name: 'writing-guide' })
expect(within(row).getByText('可用')).toBeInTheDocument()
await waitFor(() => {
expect(backend.seen).toContain(
'GET /api/v1/tenants/00000000-0000-4000-8000-0000000000aa/spaces/00000000-0000-4000-8000-0000000000bb/plugins/results/demo/writing-kit',
)
})
await waitFor(() =>
expect(within(row).getByText('等待下发')).toBeInTheDocument(),
)
expect(within(row).queryByText('等待节点')).not.toBeInTheDocument()
expect(within(row).getByRole('button', { name: '移除技能' })).toBeVisible()
})
Expand All @@ -171,9 +180,120 @@ it('shows a member the market state without market write controls', async () =>
})
renderSettingsPage(<SkillsPage />)
const row = await screen.findByRole('article', { name: 'writing-guide' })
expect(within(row).getByText('可用')).toBeInTheDocument()
await waitFor(() =>
expect(within(row).getByText('等待下发')).toBeInTheDocument(),
)
expect(
within(row).queryByRole('button', { name: '移除技能' }),
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '新建技能' })).toBeVisible()
})

it('shows per-project delivery results on the skill row and detail panel', async () => {
installMarketplaceBackend({
catalog: [writing],
selections: [marketplaceSelection(writing)],
progress: [
{
namespace: 'demo',
identifier: 'writing-kit',
results: [
{
projectId: 'project-a',
name: '项目 A',
state: 'installed',
installedVersion: '1.0.0',
failure: '',
},
{
projectId: 'project-b',
name: '项目 B',
state: 'applying',
installedVersion: '',
failure: '',
},
],
},
],
})
const user = userEvent.setup()
renderSettingsPage(<SkillsPage />)
const row = await screen.findByRole('article', { name: 'writing-guide' })
await waitFor(() =>
expect(within(row).getByText('下发中')).toBeInTheDocument(),
)
await user.click(
within(row).getByRole('button', { name: '查看技能 writing-guide' }),
)
const dialog = await screen.findByRole('dialog')
expect(within(dialog).getByText('项目下发状态')).toBeInTheDocument()
expect(within(dialog).getByText('项目 A')).toBeInTheDocument()
expect(within(dialog).getByText('项目 B')).toBeInTheDocument()
const blockOf = (name: string) =>
within(dialog).getByText(name, { exact: false }).closest('div')
expect(blockOf('项目 A')).toHaveTextContent(/已下发/)
expect(blockOf('项目 B')).toHaveTextContent(/下发中/)
})

it('marks a failed delivery and shows the failure reason', async () => {
installMarketplaceBackend({
catalog: [writing],
selections: [marketplaceSelection(writing)],
progress: [
{
namespace: 'demo',
identifier: 'writing-kit',
results: [
{
projectId: 'project-a',
name: '项目 A',
state: 'failed',
installedVersion: '',
failure: '技能包下载失败或完整性校验未通过,请重新应用。',
},
],
},
],
})
const user = userEvent.setup()
renderSettingsPage(<SkillsPage />)
const row = await screen.findByRole('article', { name: 'writing-guide' })
await waitFor(() =>
expect(within(row).getByText('安装失败')).toBeInTheDocument(),
)
await user.click(
within(row).getByRole('button', { name: '查看技能 writing-guide' }),
)
const dialog = await screen.findByRole('dialog')
await waitFor(() =>
expect(
within(dialog).getAllByText(/技能包下载失败或完整性校验未通过/).length,
).toBeGreaterThan(0),
)
})

it('prompts a reinstall for a legacy selection without a frozen package', async () => {
installMarketplaceBackend({
catalog: [writing],
selections: [
marketplaceSelection(writing, {
installError: '旧版本没有保留安装包信息,请重新安装。',
}),
],
})
const user = userEvent.setup()
renderSettingsPage(<SkillsPage />)
const row = await screen.findByRole('article', { name: 'writing-guide' })
await waitFor(() =>
expect(within(row).getByText('安装失败')).toBeInTheDocument(),
)
await user.click(
within(row).getByRole('button', { name: '查看技能 writing-guide' }),
)
const dialog = await screen.findByRole('dialog')
await waitFor(() =>
expect(
within(dialog).getByText(/旧版本没有保留安装包信息,请重新安装/),
).toBeInTheDocument(),
)
})
Loading
Loading