feat(web): display plugin-owned locale metadata
This commit is contained in:
1 parent
0b7cb4102b
commit
0d611ea151
59 files changed
+1067
-200
No files matched your search
@@ -25,5 +25,5 @@
|
||||
- text: "1"
|
||||
- list:
|
||||
- listitem:
|
||||
- button "查看 live-client": live-client
|
||||
- switch "启用 live-client"
|
||||
- button "查看 @fixture/live-client": "@fixture/live-client"
|
||||
- switch "启用 @fixture/live-client"
|
||||
@@ -1,5 +1,7 @@
|
||||
- button "返回 live-client": live-client
|
||||
- heading "fixture-live-client" [level=3]
|
||||
- button "返回 @fixture/live-client": "@fixture/live-client"
|
||||
- heading "@fixture/live-client" [level=3]
|
||||
- paragraph:
|
||||
- code: fixture-live-client
|
||||
- paragraph:
|
||||
- code: "@fixture/live-client"
|
||||
- paragraph: 示例配置项
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
- button "Back to plugins": Plugins
|
||||
- button "Uninstall @fixture/bundle": Uninstall
|
||||
- switch "Enable @fixture/bundle"
|
||||
- heading "@fixture/bundle" [level=3]
|
||||
- text: v0.0.1
|
||||
- paragraph:
|
||||
- code: "@fixture/bundle"
|
||||
- paragraph: Registry description for the fixture bundle.
|
||||
- heading "Components" [level=4]
|
||||
- text: 3 total · 3 off
|
||||
- list:
|
||||
- listitem:
|
||||
- text: "@fixture/bundle Registry description for the fixture bundle."
|
||||
- code: fixture-row
|
||||
- text: "Off"
|
||||
- listitem:
|
||||
- text: File Search Search package introduction.
|
||||
- code: fixture-search
|
||||
- code: "@fixture/bundle/search"
|
||||
- text: "Off"
|
||||
- listitem:
|
||||
- text: "@fixture/bundle/review"
|
||||
- code: fixture-review
|
||||
- text: "Off"
|
||||
@@ -0,0 +1,25 @@
|
||||
- button "返回插件列表": 插件列表
|
||||
- button "卸载 @fixture/bundle": 卸载
|
||||
- switch "启用 @fixture/bundle"
|
||||
- heading "@fixture/bundle" [level=3]
|
||||
- text: v0.0.1
|
||||
- paragraph:
|
||||
- code: "@fixture/bundle"
|
||||
- paragraph: Registry description for the fixture bundle.
|
||||
- heading "包含的组件" [level=4]
|
||||
- text: 共 3 个 · 3 已停用
|
||||
- list:
|
||||
- listitem:
|
||||
- text: "@fixture/bundle Registry description for the fixture bundle."
|
||||
- code: fixture-row
|
||||
- text: 已关闭
|
||||
- listitem:
|
||||
- text: 文件搜索 搜索工作区中的文件。
|
||||
- code: fixture-search
|
||||
- code: "@fixture/bundle/search"
|
||||
- text: 已关闭
|
||||
- listitem:
|
||||
- text: 代码审查 审查工作区中的改动。
|
||||
- code: fixture-review
|
||||
- code: "@fixture/bundle/review"
|
||||
- text: 已关闭
|
||||
@@ -25,6 +25,6 @@
|
||||
- text: "1"
|
||||
- list:
|
||||
- listitem:
|
||||
- button "查看 bundle": bundle
|
||||
- text: "Web e2e fixture: a bundle whose one row is an inert plugin."
|
||||
- switch "启用 bundle" [checked]
|
||||
- button "查看 @fixture/bundle": "@fixture/bundle"
|
||||
- text: Registry description for the fixture bundle.
|
||||
- switch "启用 @fixture/bundle" [checked]
|
||||
@@ -25,6 +25,6 @@
|
||||
- text: "1"
|
||||
- list:
|
||||
- listitem:
|
||||
- button "查看 bundle": bundle
|
||||
- text: "Web e2e fixture: a bundle whose one row is an inert plugin."
|
||||
- switch "启用 bundle"
|
||||
- button "查看 @fixture/bundle": "@fixture/bundle"
|
||||
- text: Registry description for the fixture bundle.
|
||||
- switch "启用 @fixture/bundle"
|
||||
@@ -1,10 +1,9 @@
|
||||
- button "返回插件列表": 插件列表
|
||||
- switch "启用 missing-bundle" [checked]
|
||||
- heading "missing-bundle" [level=3]
|
||||
- switch "启用 @fixture/missing-bundle" [checked]
|
||||
- heading "@fixture/missing-bundle" [level=3]
|
||||
- text: 异常
|
||||
- paragraph:
|
||||
- code: "@fixture/missing-bundle"
|
||||
- paragraph: 暂无描述。
|
||||
- status: "原因: dsh: cannot resolve profile bundle \"@fixture/missing-bundle\" from the dsh installation or {{home}}/profiles/scaffold; run 'dsh plugin --profile scaffold install' if its dependency is not installed"
|
||||
- heading "包含的组件" [level=4]
|
||||
- paragraph: 这个插件包不包含任何组件。
|
||||
@@ -2,7 +2,7 @@
|
||||
- listitem:
|
||||
- button "tool-subagent-control, tool-subagent-control, 已启用":
|
||||
- strong: tool-subagent-control
|
||||
- text: 已启用
|
||||
- text: 已启用 Globally named send_message, interrupt_agent, and list_agents tools over ctx.subagents continuations
|
||||
- code: tool-subagent-control
|
||||
- listitem:
|
||||
- button "tool-subagent-control/list-agents, tool-subagent-list-agents, 已启用":
|
||||
@@ -12,20 +12,20 @@
|
||||
- listitem:
|
||||
- button "tool-subagent, tool-subagent, 已启用":
|
||||
- strong: tool-subagent
|
||||
- text: 已启用
|
||||
- text: 已启用 Model-facing subagent delegation tool over the ctx.subagents seam
|
||||
- code: tool-subagent
|
||||
- listitem:
|
||||
- button "tool-subagent, tool-subagent-fork, 已启用":
|
||||
- strong: tool-subagent
|
||||
- text: 已启用
|
||||
- text: 已启用 Model-facing subagent delegation tool over the ctx.subagents seam
|
||||
- code: tool-subagent-fork
|
||||
- listitem:
|
||||
- button "tool-subagent, tool-subagent-codex, 已停用":
|
||||
- strong: tool-subagent
|
||||
- text: 已停用
|
||||
- text: 已停用 Model-facing subagent delegation tool over the ctx.subagents seam
|
||||
- code: tool-subagent-codex
|
||||
- listitem:
|
||||
- button "tool-subagent, tool-subagent-claude-code, 已停用":
|
||||
- strong: tool-subagent
|
||||
- text: 已停用
|
||||
- text: 已停用 Model-facing subagent delegation tool over the ctx.subagents seam
|
||||
- code: tool-subagent-claude-code
|
||||
@@ -1,5 +1,5 @@
|
||||
- listitem:
|
||||
- button "tool-subagent, tool-subagent, 已启用":
|
||||
- strong: tool-subagent
|
||||
- text: 已启用
|
||||
- text: 已启用 Model-facing subagent delegation tool over the ctx.subagents seam
|
||||
- code: tool-subagent
|
||||
@@ -1,4 +1,8 @@
|
||||
# Web e2e fixture bundle: one inert row whose mount state exposes live layer changes.
|
||||
# Web e2e fixture bundle with independently localized plugin exports.
|
||||
- insert:
|
||||
- id: fixture-row
|
||||
name: '@fixture/bundle'
|
||||
- id: fixture-search
|
||||
name: '@fixture/bundle/search'
|
||||
- id: fixture-review
|
||||
name: '@fixture/bundle/review'
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"meta": {}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "代码审查",
|
||||
"description": "审查工作区中的改动。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "File Search"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "文件搜索",
|
||||
"description": "搜索工作区中的文件。"
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -2,9 +2,18 @@
|
||||
"name": "@fixture/bundle",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"description": "Web e2e fixture: a bundle whose one row is an inert plugin.",
|
||||
"description": "Registry description for the fixture bundle.",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"exports": {
|
||||
".": "./index.js",
|
||||
"./package.json": "./package.json",
|
||||
"./search": "./search.js",
|
||||
"./search/package.json": "./search.package.json",
|
||||
"./review": "./review.js",
|
||||
"./search/locale/*.json": "./locale/search/*.json",
|
||||
"./review/locale/*.json": "./locale/review/*.json"
|
||||
},
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Inert review plugin for exported metadata browser coverage. */
|
||||
export function apply() {}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Inert search plugin for exported metadata browser coverage. */
|
||||
export function apply() {}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "Search plugin",
|
||||
"description": "Search package introduction."
|
||||
}
|
||||
@@ -33,6 +33,7 @@ describe('web e2e: plugin configuration pages', () => {
|
||||
// The live-client fixture is a bundle with a browser half; switched on
|
||||
// below, that half registers its row's configuration into the page.
|
||||
scaffold = await launchWebScaffold({
|
||||
extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
|
||||
profile: { packages: [{ dir: join(FIXTURE_PLUGINS, 'fixture-live-client') }] },
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
@@ -286,21 +287,21 @@ describe('web e2e: plugin configuration pages', () => {
|
||||
const panel = await openPlugins()
|
||||
|
||||
// Off, the bundle's browser half is not loaded and the row has no configuration to open.
|
||||
await panel.getByRole('button', { name: '查看 live-client', exact: true }).click()
|
||||
await panel.getByRole('button', { name: '查看 @fixture/live-client', exact: true }).click()
|
||||
const row = panel.locator('[data-plugin-row]', { hasText: 'fixture-live-client' })
|
||||
await row.waitFor({ timeout: 10_000 })
|
||||
expect(await panel.getByRole('button', { name: '配置 fixture-live-client' }).count()).toBe(0)
|
||||
expect(await panel.getByRole('button', { name: '配置 @fixture/live-client' }).count()).toBe(0)
|
||||
|
||||
// Switched on, the Host recomposes and the browser half mounts without a
|
||||
// reload; its registration puts the configure control on the row.
|
||||
await panel.getByRole('switch', { name: '启用 live-client' }).click()
|
||||
const configure = panel.getByRole('button', { name: '配置 fixture-live-client' })
|
||||
await panel.getByRole('switch', { name: '启用 @fixture/live-client' }).click()
|
||||
const configure = panel.getByRole('button', { name: '配置 @fixture/live-client' })
|
||||
await configure.waitFor({ timeout: 30_000 })
|
||||
await configure.click()
|
||||
|
||||
const rowPage = panel.locator('[data-plugin-row-detail="@fixture/live-client#fixture-live-client"]')
|
||||
await rowPage.waitFor({ timeout: 10_000 })
|
||||
expect(await rowPage.getByRole('heading', { level: 3 }).textContent()).toBe('fixture-live-client')
|
||||
expect(await rowPage.getByRole('heading', { level: 3 }).textContent()).toBe('@fixture/live-client')
|
||||
expect(await rowPage.getByText('示例配置项', { exact: true }).count()).toBe(1)
|
||||
const form = rowPage.getByRole('form', { name: '动态插件配置' })
|
||||
await form.getByLabel('问候语').fill('你好')
|
||||
@@ -309,7 +310,7 @@ describe('web e2e: plugin configuration pages', () => {
|
||||
|
||||
const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(ROW_EXPECTED, snapshot, MODE)
|
||||
await rowPage.getByRole('button', { name: '返回 live-client' }).click()
|
||||
await rowPage.getByRole('button', { name: '返回 @fixture/live-client' }).click()
|
||||
await panel.locator('[data-plugin-detail="@fixture/live-client"]').waitFor({ timeout: 10_000 })
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 90_000)
|
||||
|
||||
@@ -19,6 +19,8 @@ import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './s
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/plugin-manager', import.meta.url))
|
||||
const MANAGER_EXPECTED = join(SNAPSHOT_DIR, 'manager.expected.md')
|
||||
const LIVE_EXPECTED = join(SNAPSHOT_DIR, 'live-enabled.expected.md')
|
||||
const EXPORTS_EXPECTED = join(SNAPSHOT_DIR, 'exports.expected.md')
|
||||
const EXPORTS_EN_EXPECTED = join(SNAPSHOT_DIR, 'exports-en.expected.md')
|
||||
const FIXTURE_PLUGINS = fileURLToPath(new URL('./fixtures/plugins', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
@@ -30,6 +32,7 @@ describe('web e2e: plugin manager', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({
|
||||
extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
|
||||
profile: { packages: [{ dir: join(FIXTURE_PLUGINS, 'fixture-bundle') }], bundles: ['@fixture/missing-bundle'] },
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
@@ -52,11 +55,32 @@ describe('web e2e: plugin manager', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** Change the UI language through Settings and close the dialog. */
|
||||
async function setLanguage(language: 'en' | 'zh'): Promise<void> {
|
||||
if (await page.locator('html').getAttribute('lang') === language) return
|
||||
const settings = language === 'en' ? '设置' : 'Settings'
|
||||
const source = language === 'en' ? '中文' : 'English'
|
||||
const target = language === 'en' ? 'English' : '中文'
|
||||
if (await page.getByRole('dialog', { name: settings }).count() === 0) {
|
||||
await page.getByRole('button', { name: settings, exact: true }).click()
|
||||
}
|
||||
await page.getByRole('dialog', { name: settings }).getByRole('button', { name: source }).click()
|
||||
await page.getByRole('menuitem', { name: target }).click()
|
||||
const dialog = page.getByRole('dialog', { name: language === 'en' ? 'Settings' : '设置' })
|
||||
await dialog.waitFor()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect.poll(() => dialog.count()).toBe(0)
|
||||
}
|
||||
|
||||
/** Select the sidebar's Plugins entry and wait for the management page in the main column. */
|
||||
async function openPluginsPanel() {
|
||||
await closeSettings()
|
||||
await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
|
||||
const panel = page.locator('[data-plugin-panel]')
|
||||
await panel.waitFor({ timeout: 10_000 })
|
||||
while (await panel.getByRole('button', { name: /^返回/ }).count() > 0) {
|
||||
await panel.getByRole('button', { name: /^返回/ }).first().click()
|
||||
}
|
||||
await panel.getByRole('heading', { name: '插件', exact: true }).waitFor({ timeout: 10_000 })
|
||||
return panel
|
||||
}
|
||||
@@ -69,8 +93,8 @@ describe('web e2e: plugin manager', () => {
|
||||
it('starts with an unavailable selected bundle and lets the user clear its error', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-missing-bundle'))
|
||||
const panel = await openPluginsPanel()
|
||||
await panel.getByRole('button', { name: '查看 missing-bundle', exact: true }).click()
|
||||
const toggle = panel.getByRole('switch', { name: '启用 missing-bundle', exact: true })
|
||||
await panel.getByRole('button', { name: '查看 @fixture/missing-bundle', exact: true }).click()
|
||||
const toggle = panel.getByRole('switch', { name: '启用 @fixture/missing-bundle', exact: true })
|
||||
expect(await toggle.getAttribute('aria-checked')).toBe('true')
|
||||
expect(await toggle.isDisabled()).toBe(false)
|
||||
await panel.getByText(/cannot resolve profile bundle/).waitFor({ timeout: 10_000 })
|
||||
@@ -93,8 +117,8 @@ describe('web e2e: plugin manager', () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-list'))
|
||||
const panel = await openPluginsPanel()
|
||||
|
||||
await panel.getByText('bundle', { exact: true }).waitFor({ timeout: 20_000 })
|
||||
const toggle = panel.getByRole('switch', { name: '启用 bundle' })
|
||||
await panel.getByRole('button', { name: '查看 @fixture/bundle', exact: true }).waitFor({ timeout: 20_000 })
|
||||
const toggle = panel.getByRole('switch', { name: '启用 @fixture/bundle' })
|
||||
expect(await toggle.getAttribute('aria-checked')).toBe('false')
|
||||
// The profile's own group holds its one bundle; the installation's optional bundles open the Official
|
||||
// group, followed by the official plugins that registered their configuration, and its other bundles
|
||||
@@ -104,18 +128,47 @@ describe('web e2e: plugin manager', () => {
|
||||
expect(await panel.locator('[data-plugin-group="official"] [data-plugin-item]').count()).toBe(4)
|
||||
expect(await panel.getByText('Beta', { exact: true }).count()).toBe(1)
|
||||
// A bundle that is off still shows the rows its patch declares, without switches.
|
||||
await panel.getByRole('button', { name: '查看 bundle' }).click()
|
||||
await panel.getByRole('button', { name: '查看 @fixture/bundle' }).click()
|
||||
await panel.locator('[data-plugin-row]', { hasText: 'fixture-row' }).waitFor({ timeout: 10_000 })
|
||||
expect(await panel.getByRole('switch', { name: '启用组件 fixture-row' }).count()).toBe(0)
|
||||
await panel.getByRole('button', { name: '卸载 bundle' }).waitFor({ timeout: 5_000 })
|
||||
expect(await panel.getByRole('switch', { name: '启用组件 @fixture/bundle' }).count()).toBe(0)
|
||||
await panel.getByRole('button', { name: '卸载 @fixture/bundle' }).waitFor({ timeout: 5_000 })
|
||||
await panel.getByRole('button', { name: '返回插件列表' }).click()
|
||||
await expect.poll(() => panel.getByRole('button', { name: '卸载 bundle' }).count(), { timeout: 5_000 }).toBe(0)
|
||||
await expect.poll(() => panel.getByRole('button', { name: '卸载 @fixture/bundle' }).count(), { timeout: 5_000 }).toBe(0)
|
||||
|
||||
const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(MANAGER_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('localizes independent exports and falls back per field without activating the plugins', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-exports'))
|
||||
const panel = await openPluginsPanel()
|
||||
await panel.getByRole('button', { name: '查看 @fixture/bundle' }).click()
|
||||
const search = panel.locator('[data-plugin-row]', { hasText: '@fixture/bundle/search' })
|
||||
const review = panel.locator('[data-plugin-row]', { hasText: '@fixture/bundle/review' })
|
||||
await search.getByText('文件搜索', { exact: true }).waitFor()
|
||||
await review.getByText('代码审查', { exact: true }).waitFor()
|
||||
expect(await search.getByText('搜索工作区中的文件。', { exact: true }).count()).toBe(1)
|
||||
expect(await review.getByText('审查工作区中的改动。', { exact: true }).count()).toBe(1)
|
||||
await panel.getByText('Registry description for the fixture bundle.', { exact: true }).first().waitFor()
|
||||
expect([...scaffold.ctx.loader.entries()].some(entry => entry.options.name.startsWith('@fixture/bundle'))).toBe(false)
|
||||
await compareOrRefreshGolden(EXPORTS_EXPECTED, await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd), MODE)
|
||||
try {
|
||||
await setLanguage('en')
|
||||
await search.getByText('File Search', { exact: true }).waitFor()
|
||||
expect(await review.getByText('@fixture/bundle/review', { exact: true }).count()).toBeGreaterThan(0)
|
||||
expect(await search.getByText('Search package introduction.', { exact: true }).count()).toBe(1)
|
||||
expect(await review.getByText('审查工作区中的改动。', { exact: true }).count()).toBe(0)
|
||||
await compareOrRefreshGolden(EXPORTS_EN_EXPECTED, await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd), MODE)
|
||||
expect(await panel.locator('[data-plugin-name]').textContent()).toBe('@fixture/bundle')
|
||||
} finally {
|
||||
await setLanguage('zh')
|
||||
await closeSettings()
|
||||
}
|
||||
await panel.getByRole('button', { name: '返回插件列表' }).click()
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
})
|
||||
|
||||
it('updates built-in names and descriptions when the UI language changes', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-locale'))
|
||||
const panel = await openPluginsPanel()
|
||||
@@ -123,15 +176,16 @@ describe('web e2e: plugin manager', () => {
|
||||
const packageName = panel.locator('[data-plugin-name]')
|
||||
expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-agent-team-profile')
|
||||
expect(await panel.getByText('启用团队协作、团队工具、成员列表和共享任务看板。').count()).toBe(1)
|
||||
const child = panel.locator('[data-plugin-row]', { hasText: 'tool-agent-team' })
|
||||
await child.getByText('团队工具', { exact: true }).waitFor()
|
||||
expect(await child.getByText('为智能体提供成员协调、消息通信和共享任务管理工具。', { exact: true }).count()).toBe(1)
|
||||
try {
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '中文' }).click()
|
||||
await page.getByRole('menuitem', { name: 'English' }).click()
|
||||
await page.getByRole('dialog', { name: 'Settings' }).waitFor()
|
||||
await page.keyboard.press('Escape')
|
||||
await setLanguage('en')
|
||||
await panel.getByRole('heading', { name: 'Agent Teams', exact: true }).waitFor()
|
||||
expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-agent-team-profile')
|
||||
expect(await panel.getByText('Enable team collaboration, team tools, the member roster, and the shared task board.').count()).toBe(1)
|
||||
await child.getByText('Team Tools', { exact: true }).waitFor()
|
||||
expect(await child.getByText('Give agents tools to coordinate members, exchange messages, and manage shared tasks.', { exact: true }).count()).toBe(1)
|
||||
await panel.getByRole('button', { name: 'Back to plugins' }).click()
|
||||
await panel.getByRole('button', { name: 'View Agent Teams', exact: true }).waitFor()
|
||||
expect(await panel.getByRole('switch', { name: 'Enable Agent Teams', exact: true }).count()).toBe(1)
|
||||
@@ -140,14 +194,7 @@ describe('web e2e: plugin manager', () => {
|
||||
await panel.getByRole('button', { name: `View ${title}`, exact: true }).waitFor()
|
||||
}
|
||||
} finally {
|
||||
if (await page.locator('html').getAttribute('lang') === 'en') {
|
||||
if (await page.getByRole('dialog', { name: 'Settings' }).count() === 0) {
|
||||
await page.getByRole('button', { name: 'Settings', exact: true }).click()
|
||||
}
|
||||
await page.getByRole('dialog', { name: 'Settings' }).getByRole('button', { name: 'English' }).click()
|
||||
await page.getByRole('menuitem', { name: '中文' }).click()
|
||||
await page.getByRole('dialog', { name: '设置' }).waitFor()
|
||||
}
|
||||
await setLanguage('zh')
|
||||
await closeSettings()
|
||||
}
|
||||
await panel.getByRole('button', { name: '查看 智能体团队', exact: true }).waitFor()
|
||||
@@ -244,7 +291,7 @@ describe('web e2e: plugin manager', () => {
|
||||
it('enables a bundle into the profile manifest, mounts its rows live, and switches one of them', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-enable'))
|
||||
const panel = await openPluginsPanel()
|
||||
const toggle = panel.getByRole('switch', { name: '启用 bundle' })
|
||||
const toggle = panel.getByRole('switch', { name: '启用 @fixture/bundle' })
|
||||
await toggle.waitFor({ timeout: 20_000 })
|
||||
const mounted = () => [...scaffold.ctx.loader.entries()].find(entry => entry.options.id === 'fixture-row')
|
||||
expect(mounted()?.fiber?.state).toBeUndefined()
|
||||
@@ -262,8 +309,8 @@ describe('web e2e: plugin manager', () => {
|
||||
const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(LIVE_EXPECTED, snapshot, MODE)
|
||||
// The pack's page lists its rows as the Host runs them, each with a switch that writes the profile patch.
|
||||
await panel.getByRole('button', { name: '查看 bundle' }).click()
|
||||
const rowSwitch = panel.getByRole('switch', { name: '启用组件 fixture-row' })
|
||||
await panel.getByRole('button', { name: '查看 @fixture/bundle' }).click()
|
||||
const rowSwitch = panel.getByRole('switch', { name: '启用组件 @fixture/bundle' })
|
||||
await rowSwitch.waitFor({ timeout: 10_000 })
|
||||
expect(await rowSwitch.getAttribute('aria-checked')).toBe('true')
|
||||
await rowSwitch.click()
|
||||
@@ -283,7 +330,9 @@ describe('web e2e: plugin manager', () => {
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md'])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||
'manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md', 'exports.expected.md', 'exports-en.expected.md',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -291,6 +340,7 @@ describe('web e2e: plugin manager', () => {
|
||||
describe('web e2e: startup-applied plugin management', () => {
|
||||
it('saves a bundle selection that waits for the next start and keeps its rows read-only', async () => {
|
||||
const scaffold = await launchWebScaffold({
|
||||
extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
|
||||
profile: { hmr: false, packages: [{ dir: join(FIXTURE_PLUGINS, 'fixture-bundle') }] },
|
||||
})
|
||||
let browser: Browser | undefined
|
||||
@@ -302,7 +352,7 @@ describe('web e2e: startup-applied plugin management', () => {
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
|
||||
const panel = page.locator('[data-plugin-panel]')
|
||||
const toggle = panel.getByRole('switch', { name: '启用 bundle' })
|
||||
const toggle = panel.getByRole('switch', { name: '启用 @fixture/bundle' })
|
||||
await toggle.waitFor({ timeout: 20_000 })
|
||||
const mounted = () => [...scaffold.ctx.loader.entries()].find(entry => entry.options.id === 'fixture-row')
|
||||
const bundles = async () => {
|
||||
@@ -317,9 +367,9 @@ describe('web e2e: startup-applied plugin management', () => {
|
||||
await page.getByText('更改将在下次启动生效', { exact: true }).waitFor({ timeout: 10_000 })
|
||||
expect(mounted()?.fiber?.state).toBeUndefined()
|
||||
// The pack's page lists its rows from their declarations, with no live entry to switch.
|
||||
await panel.getByRole('button', { name: '查看 bundle' }).click()
|
||||
await panel.getByRole('button', { name: '查看 @fixture/bundle' }).click()
|
||||
await panel.locator('[data-plugin-row]', { hasText: 'fixture-row' }).waitFor({ timeout: 10_000 })
|
||||
expect(await panel.getByRole('switch', { name: '启用组件 fixture-row' }).isDisabled()).toBe(true)
|
||||
expect(await panel.getByRole('switch', { name: '启用组件 @fixture/bundle' }).isDisabled()).toBe(true)
|
||||
await panel.getByRole('button', { name: '返回插件列表' }).click()
|
||||
|
||||
await toggle.click()
|
||||
|
||||
@@ -37,7 +37,10 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({ developerTools: false })
|
||||
scaffold = await launchWebScaffold({
|
||||
developerTools: false,
|
||||
extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
// Chinese browser: the shared page asserts the localized settings surface
|
||||
// the client derives from it (the English default has its own spec below).
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-package-manifest": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"react": "^18.2.0",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* its own settings surface.
|
||||
*/
|
||||
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
||||
import type { LocalizedText } from '@deepseek-ai/dsh-package-manifest'
|
||||
import {
|
||||
type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -200,6 +201,20 @@ export class LocaleRuntime {
|
||||
return this.snapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve package text through the active language's declared fallback chain.
|
||||
* Plain strings stay verbatim; maps do not consult registered dictionaries.
|
||||
* @param text - package text whose locale keys are lowercase and include English.
|
||||
* @returns the first available translation, including an empty string.
|
||||
*/
|
||||
resolveText(text: LocalizedText): string {
|
||||
if (typeof text === 'string') return text
|
||||
return this.fallbackChain(this.snapshot.active).reduceRight(
|
||||
(resolved, locale) => text[localeKey(locale)] ?? resolved,
|
||||
text.en,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* LocaleFace getSnapshot: the current snapshot (carries `revision`; stable
|
||||
* reference between changes, uSES-safe).
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it, onTestFinished } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { LocaleRuntime } from '../src/client/index.ts'
|
||||
|
||||
function make() {
|
||||
const ctx = new Context()
|
||||
onTestFinished(async () => { await ctx.fiber.dispose() })
|
||||
return { ctx, locale: new LocaleRuntime(ctx) }
|
||||
}
|
||||
|
||||
describe('package text resolution', () => {
|
||||
it('keeps strings verbatim without common lookup or interpolation', () => {
|
||||
const { ctx, locale } = make()
|
||||
ctx.effect(() => locale.register('common', 'zh', { retry: '重试' }), 'test: common dictionary')
|
||||
locale.setLocale('zh')
|
||||
|
||||
expect(locale.resolveText('retry')).toBe('retry')
|
||||
expect(locale.resolveText('Hello, {name}')).toBe('Hello, {name}')
|
||||
expect(locale.resolveText('')).toBe('')
|
||||
expect(locale.resolveText({ en: 'retry' })).toBe('retry')
|
||||
})
|
||||
|
||||
it('resolves each field in the current locale without registering map languages', () => {
|
||||
const { locale } = make()
|
||||
const title = { en: 'Tools', zh: '工具', ja: 'ツール' }
|
||||
locale.setLocale('zh')
|
||||
const snapshot = locale.getSnapshot()
|
||||
|
||||
expect(locale.resolveText(title)).toBe('工具')
|
||||
expect(locale.resolveText({ en: 'English description' })).toBe('English description')
|
||||
expect(locale.resolveText({ en: 'Fallback', zh: '' })).toBe('')
|
||||
expect(locale.getSnapshot()).toBe(snapshot)
|
||||
expect(snapshot.locales.map(item => item.id)).toEqual(['zh', 'en'])
|
||||
|
||||
locale.setLocale('en')
|
||||
expect(locale.resolveText(title)).toBe('Tools')
|
||||
})
|
||||
|
||||
it('walks registered regional fallbacks using lowercase map keys', () => {
|
||||
const { ctx, locale } = make()
|
||||
ctx.effect(() => locale.addLanguage({ id: 'fr', label: 'Français', fallback: 'en' }), 'test: French')
|
||||
ctx.effect(() => locale.addLanguage({ id: 'fr-CA', label: 'Français (Canada)', fallback: 'fr' }), 'test: Canadian French')
|
||||
locale.setLocale('FR-ca')
|
||||
|
||||
expect(locale.resolveText({ en: 'Tools', fr: 'Outils', 'fr-ca': 'Outils Québec' })).toBe('Outils Québec')
|
||||
expect(locale.resolveText({ en: 'Tools', fr: 'Outils' })).toBe('Outils')
|
||||
expect(locale.resolveText({ en: 'Tools', zh: '工具' })).toBe('Tools')
|
||||
})
|
||||
|
||||
it('uses English across an unloaded fallback and follows its replacement', () => {
|
||||
const { ctx, locale } = make()
|
||||
const removeFrench = locale.addLanguage({ id: 'fr', label: 'Français', fallback: 'en' })
|
||||
ctx.effect(() => removeFrench, 'test: French')
|
||||
ctx.effect(() => locale.addLanguage({ id: 'fr-CA', label: 'Français (Canada)', fallback: 'fr' }), 'test: Canadian French')
|
||||
locale.setLocale('fr-CA')
|
||||
const text = { en: 'Tools', fr: 'Outils' }
|
||||
|
||||
expect(locale.resolveText(text)).toBe('Outils')
|
||||
removeFrench()
|
||||
expect(locale.resolveText(text)).toBe('Tools')
|
||||
ctx.effect(() => locale.addLanguage({ id: 'fr', label: 'Français', fallback: 'en' }), 'test: replacement French')
|
||||
expect(locale.resolveText(text)).toBe('Outils')
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,9 @@
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../util/package-manifest"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-plugin-manager": "workspace:^",
|
||||
"@deepseek-ai/dsh-package-manifest": "workspace:^",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/react": "~18.3.1",
|
||||
"@types/react-dom": "~18.3.0",
|
||||
|
||||
@@ -26,10 +26,10 @@ import { rowConfigKey, type OfficialItem } from './config-ledger.ts'
|
||||
import type { PluginManagerLocaleKey } from './locales.ts'
|
||||
import {
|
||||
isInstallPending, offeredRegistries, rowKey,
|
||||
type ConfirmState, type InstallInputError, type InstallState, type InstallSubject, type PackageRow, type PackageView,
|
||||
type InstallInputError, type InstallState, type InstallSubject, type PackageRow, type PackageView,
|
||||
type PluginManagerFace, type RegistryChoice,
|
||||
} from './manager-store.ts'
|
||||
import { managementText, noticeText, packageText, registryText, type Translate } from './presentation.ts'
|
||||
import { managementText, noticeText, packageText, registryText, rowText, type Translate } from './presentation.ts'
|
||||
import type {} from './slot-contract.ts'
|
||||
import css from './PluginManagerPage.module.css'
|
||||
|
||||
@@ -42,6 +42,7 @@ export type PluginManagerPageProps =
|
||||
|
||||
/** The page's slot renderer, narrowed to the configuration slots. */
|
||||
type RenderConfig = PluginManagerPageProps['renderSlot']
|
||||
type ResolveText = PluginManagerFace['resolveText']
|
||||
|
||||
/** What the page shows: the cards, a bundle's page, an official plugin's page, or a row's configuration page. */
|
||||
type View =
|
||||
@@ -147,8 +148,9 @@ function packageArtwork(name: string): ReactNode {
|
||||
}
|
||||
|
||||
/** A row's switch: locked, saying why, when the Host refuses to address the row through the profile patch. */
|
||||
function RowSwitch({ row, t, busy, onChange }: {
|
||||
function RowSwitch({ row, title, t, busy, onChange }: {
|
||||
readonly row: PackageRow
|
||||
readonly title: string
|
||||
readonly t: Translate
|
||||
readonly busy: boolean
|
||||
readonly onChange: (enabled: boolean) => void
|
||||
@@ -157,7 +159,7 @@ function RowSwitch({ row, t, busy, onChange }: {
|
||||
return (
|
||||
<Switch
|
||||
checked={row.enabled}
|
||||
label={t('partToggle', { name: row.rowId })}
|
||||
label={t('partToggle', { name: title })}
|
||||
disabled={busy || locked}
|
||||
{...row.readOnlyReason === undefined ? {} : { title: managementText({ code: row.readOnlyReason }, t) }}
|
||||
onChange={onChange}
|
||||
@@ -177,21 +179,29 @@ function rowDotState(row: PackageRow): StateDotState {
|
||||
return PHASE_STATES[row.phase]
|
||||
}
|
||||
|
||||
/** A Host metadata diagnostic does not change the package's management permissions. */
|
||||
function MetadataError({ error, t }: { readonly error: string | undefined; readonly t: Translate }): ReactNode {
|
||||
return error === undefined ? null : <p className={css.reason} role="status" data-package-meta-error>{t('metadataError', { error })}</p>
|
||||
}
|
||||
|
||||
/**
|
||||
* A pack's rows as a list in the order the pack declares them: a state dot,
|
||||
* the row id, one line saying its state, a configure control for a row that
|
||||
* registered a page, and, when the pack is on, a switch. A pack like base
|
||||
* carries close to a hundred rows, so a long list gets a filter.
|
||||
*/
|
||||
function RowsSection({ rows, t, toggle, configure }: {
|
||||
function RowsSection({ rows, t, resolveText, toggle, configure }: {
|
||||
readonly rows: readonly PackageRow[]
|
||||
readonly t: Translate
|
||||
readonly resolveText: ResolveText
|
||||
readonly toggle?: RowToggles | undefined
|
||||
readonly configure?: RowConfigure | undefined
|
||||
}): ReactNode {
|
||||
const [filter, setFilter] = useState('')
|
||||
const query = filter.trim().toLowerCase()
|
||||
const shown = query === '' ? rows : rows.filter(row => row.rowId.toLowerCase().includes(query))
|
||||
const localized = rows.map(row => ({ row, ...rowText(row, resolveText) }))
|
||||
const shown = query === '' ? localized : localized.filter(({ row, title, description }) =>
|
||||
[title, description, row.rowId, row.moduleName].some(value => value?.toLowerCase().includes(query)))
|
||||
return (
|
||||
<section className={css.detailSection} data-plugin-rows>
|
||||
<div className={css.sectionHead}>
|
||||
@@ -216,7 +226,7 @@ function RowsSection({ rows, t, toggle, configure }: {
|
||||
? null
|
||||
: (
|
||||
<ul className={css.rows}>
|
||||
{shown.map(row => (
|
||||
{shown.map(({ row, title, description }) => (
|
||||
<li
|
||||
key={row.rowId}
|
||||
className={css.row}
|
||||
@@ -228,13 +238,15 @@ function RowsSection({ rows, t, toggle, configure }: {
|
||||
<div className={css.rowMain}>
|
||||
{configure?.has(row) === true
|
||||
? (
|
||||
<button type="button" className={css.rowOpen} aria-label={t('configureRow', { name: row.rowId })} onClick={() => { configure.open(row) }}>
|
||||
<span className={css.rowId}>{row.rowId}</span>
|
||||
<button type="button" className={css.rowOpen} aria-label={t('configureRow', { name: title })} onClick={() => { configure.open(row) }}>
|
||||
<span className={css.rowId}>{title}</span>
|
||||
<IconChevronRightOutlineRegular className={css.rowOpenIcon} aria-hidden="true" />
|
||||
</button>
|
||||
)
|
||||
: <span className={css.rowId}>{row.rowId}</span>}
|
||||
<span className={css.rowModule}>{row.moduleName}</span>
|
||||
: <span className={css.rowId}>{title}</span>}
|
||||
{description === undefined ? null : <span className={css.rowModule}>{description}</span>}
|
||||
{title === row.rowId ? null : <code className={css.rowModule}>{row.rowId}</code>}
|
||||
{title === row.moduleName ? null : <code className={css.rowModule}>{row.moduleName}</code>}
|
||||
</div>
|
||||
<span className={css.rowState}>
|
||||
<StateDot state={rowDotState(row)} />
|
||||
@@ -242,8 +254,12 @@ function RowsSection({ rows, t, toggle, configure }: {
|
||||
</span>
|
||||
{toggle === undefined
|
||||
? null
|
||||
: <RowSwitch row={row} t={t} busy={toggle.busy(row)} onChange={(enabled) => { toggle.onSetEnabled(row, enabled) }} />}
|
||||
: <RowSwitch
|
||||
row={row} title={title} t={t} busy={toggle.busy(row)}
|
||||
onChange={(enabled) => { toggle.onSetEnabled(row, enabled) }}
|
||||
/>}
|
||||
</div>
|
||||
<MetadataError error={row.meta?.error} t={t} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -290,15 +306,16 @@ function CardHead({ title, t, onOpen, icon, tags, description, end }: {
|
||||
readonly description: ReactNode
|
||||
readonly end?: ReactNode
|
||||
}): ReactNode {
|
||||
const descriptionId = useId()
|
||||
return (
|
||||
<div className={css.cardHead}>
|
||||
<span className={css.cardIcon} aria-hidden="true">{icon}</span>
|
||||
<div className={css.cardMain}>
|
||||
<div className={css.titleRow}>
|
||||
<button type="button" className={`${css.cardTitle} ${css.cardOpen}`} aria-label={t('openDetail', { name: title })} onClick={onOpen}>{title}</button>
|
||||
<button type="button" className={`${css.cardTitle} ${css.cardOpen}`} aria-label={t('openDetail', { name: title })} aria-describedby={description === undefined ? undefined : descriptionId} onClick={onOpen}>{title}</button>
|
||||
{tags}
|
||||
</div>
|
||||
{description === undefined ? null : <span className={css.cardDesc}>{description}</span>}
|
||||
{description === undefined ? null : <span className={css.cardDesc} id={descriptionId}>{description}</span>}
|
||||
</div>
|
||||
{end === undefined ? null : <div className={css.cardEnd}>{end}</div>}
|
||||
</div>
|
||||
@@ -328,15 +345,16 @@ function DetailTop({ crumbLabel, crumbText, onBack, icon, actions }: {
|
||||
}
|
||||
|
||||
/** One package as a card that opens its page: its name, its one-liner, its tags, and its bundle switch. */
|
||||
function PackageCard({ pkg, t, busy, highlighted, onOpen, onSetEnabled }: {
|
||||
function PackageCard({ pkg, t, resolveText, busy, highlighted, onOpen, onSetEnabled }: {
|
||||
readonly pkg: PackageView
|
||||
readonly t: Translate
|
||||
readonly resolveText: ResolveText
|
||||
readonly busy: boolean
|
||||
readonly highlighted: boolean
|
||||
readonly onOpen: () => void
|
||||
readonly onSetEnabled: (enabled: boolean) => void
|
||||
}): ReactNode {
|
||||
const { title, description, beta } = packageText(pkg, t)
|
||||
const { title, description, beta } = packageText(pkg, resolveText)
|
||||
const status = packageStatus(pkg)
|
||||
return (
|
||||
<li
|
||||
@@ -359,6 +377,7 @@ function PackageCard({ pkg, t, busy, highlighted, onOpen, onSetEnabled }: {
|
||||
description={description}
|
||||
end={<EnableSwitch pkg={pkg} title={title} t={t} busy={busy} onSetEnabled={onSetEnabled} />}
|
||||
/>
|
||||
<MetadataError error={pkg.meta?.error} t={t} />
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -404,28 +423,32 @@ function ItemDetail({ item, t, onBack, renderSlot }: {
|
||||
}
|
||||
|
||||
/**
|
||||
* A row's configuration page: the crumb back to its bundle's page, the row id
|
||||
* over the module it names and the entry's one-liner, and the form the entry renders.
|
||||
* A row's configuration page keeps its technical identity beside local package
|
||||
* text and the form supplied by its configuration entry.
|
||||
*/
|
||||
function RowDetail({ pkg, row, t, onBack, renderSlot }: {
|
||||
function RowDetail({ pkg, row, t, resolveText, onBack, renderSlot }: {
|
||||
readonly pkg: PackageView
|
||||
readonly row: PackageRow
|
||||
readonly t: Translate
|
||||
readonly resolveText: ResolveText
|
||||
readonly onBack: () => void
|
||||
readonly renderSlot: RenderConfig
|
||||
}): ReactNode {
|
||||
const { title } = packageText(pkg, t)
|
||||
const { title } = packageText(pkg, resolveText)
|
||||
const { title: rowTitle, description } = rowText(row, resolveText)
|
||||
const key = rowConfigKey(pkg.name, row.rowId)
|
||||
return (
|
||||
<div className={css.detail} data-plugin-row-detail={key}>
|
||||
<DetailTop crumbLabel={t('backToPackage', { name: title })} crumbText={title} onBack={onBack} icon={<PluginArtworkSubagent size={CARD_ARTWORK_SIZE} />} />
|
||||
<div className={css.detailMain}>
|
||||
<div className={css.titleRow}>
|
||||
<h3 className={css.detailTitle}>{row.rowId}</h3>
|
||||
<h3 className={css.detailTitle}>{rowTitle}</h3>
|
||||
</div>
|
||||
{rowTitle === row.rowId ? null : <p className={css.detailName}><code>{row.rowId}</code></p>}
|
||||
<p className={css.detailName}><code>{row.moduleName}</code></p>
|
||||
<p className={css.detailDesc}>{renderSlot('plugins.row.config', { view: 'summary' }, { entryKey: key })}</p>
|
||||
<p className={css.detailDesc}>{description ?? renderSlot('plugins.row.config', { view: 'summary' }, { entryKey: key })}</p>
|
||||
</div>
|
||||
<MetadataError error={row.meta?.error} t={t} />
|
||||
<div className={css.detailSections} data-plugin-config>
|
||||
{renderSlot('plugins.row.config', { view: 'page' }, { entryKey: key })}
|
||||
</div>
|
||||
@@ -442,11 +465,12 @@ function RowDetail({ pkg, row, t, onBack, renderSlot }: {
|
||||
* itself; and its rows with their switches and configure controls.
|
||||
*/
|
||||
function PackageDetail({
|
||||
pkg, t, busy, rowBusy, configured, configure, renderSlot,
|
||||
pkg, t, resolveText, busy, rowBusy, configured, configure, renderSlot,
|
||||
onBack, onSetEnabled, onUninstall, onSetRowEnabled,
|
||||
}: {
|
||||
readonly pkg: PackageView
|
||||
readonly t: Translate
|
||||
readonly resolveText: ResolveText
|
||||
readonly busy: boolean
|
||||
/** Whether a row has a write in flight. */
|
||||
readonly rowBusy: (row: PackageRow) => boolean
|
||||
@@ -459,7 +483,7 @@ function PackageDetail({
|
||||
readonly onUninstall: () => void
|
||||
readonly onSetRowEnabled: (row: PackageRow, enabled: boolean) => void
|
||||
}): ReactNode {
|
||||
const { title, description, beta } = packageText(pkg, t)
|
||||
const { title, description, beta } = packageText(pkg, resolveText)
|
||||
const status = packageStatus(pkg)
|
||||
return (
|
||||
<div className={css.detail} data-plugin-detail={pkg.name}>
|
||||
@@ -497,8 +521,9 @@ function PackageDetail({
|
||||
{status === 'problem' ? <Tag className={css.statusTag} tone="danger">{t('statusProblem')}</Tag> : null}
|
||||
</div>
|
||||
<p className={css.detailName}><code data-plugin-name>{pkg.name}</code></p>
|
||||
<p className={css.detailDesc}>{description ?? t('noDescription')}</p>
|
||||
{description === undefined ? null : <p className={css.detailDesc}>{description}</p>}
|
||||
</div>
|
||||
<MetadataError error={pkg.meta?.error} t={t} />
|
||||
{pkg.error === undefined ? null : <p className={css.reason} role="status">{t('reasonLabel')}: {managementText(pkg.error, t)}</p>}
|
||||
{pkg.readOnlyReason === undefined ? null : <p className={css.reason} role="status">{managementText({ code: pkg.readOnlyReason }, t)}</p>}
|
||||
<div className={css.detailSections}>
|
||||
@@ -512,6 +537,7 @@ function PackageDetail({
|
||||
<RowsSection
|
||||
rows={pkg.rows}
|
||||
t={t}
|
||||
resolveText={resolveText}
|
||||
toggle={pkg.enabled ? { busy: row => busy || rowBusy(row), onSetEnabled: onSetRowEnabled } : undefined}
|
||||
configure={configure}
|
||||
/>
|
||||
@@ -1012,13 +1038,12 @@ function InstallDialog({
|
||||
}
|
||||
|
||||
/** The confirmation an uninstall waits on. */
|
||||
function ConfirmDialog({ confirm, t, onConfirm, onCancel }: {
|
||||
readonly confirm: ConfirmState
|
||||
function ConfirmDialog({ name, t, onConfirm, onCancel }: {
|
||||
readonly name: string
|
||||
readonly t: Translate
|
||||
readonly onConfirm: () => void
|
||||
readonly onCancel: () => void
|
||||
}): ReactNode {
|
||||
const { title: name } = packageText({ name: confirm.packageName }, t)
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
@@ -1040,7 +1065,7 @@ function ConfirmDialog({ confirm, t, onConfirm, onCancel }: {
|
||||
|
||||
/** Render the plugin manager: the official plugins and installed bundles, their pages, the install dialog, and the confirmation. */
|
||||
export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
||||
const { t, ensure, renderSlot } = props
|
||||
const { t, ensure, renderSlot, resolveText } = props
|
||||
const state = props.usePluginManager(snapshot => snapshot)
|
||||
const ledger = props.useConfigLedger(snapshot => snapshot)
|
||||
// What is open; a package that leaves the list (uninstalled) drops back to the cards.
|
||||
@@ -1082,6 +1107,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
||||
key={pkg.name}
|
||||
pkg={pkg}
|
||||
t={t}
|
||||
resolveText={resolveText}
|
||||
busy={state.busy.includes(pkg.name)}
|
||||
highlighted={state.highlight === pkg.name}
|
||||
onOpen={() => { setView({ kind: 'package', name: pkg.name }) }}
|
||||
@@ -1165,6 +1191,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
||||
pkg={openPkg}
|
||||
row={openRow}
|
||||
t={t}
|
||||
resolveText={resolveText}
|
||||
renderSlot={renderSlot}
|
||||
onBack={() => { setView({ kind: 'package', name: openPkg.name }) }}
|
||||
/>
|
||||
@@ -1175,6 +1202,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
||||
<PackageDetail
|
||||
pkg={openPkg}
|
||||
t={t}
|
||||
resolveText={resolveText}
|
||||
busy={state.busy.includes(openPkg.name)}
|
||||
rowBusy={row => row.entryId !== undefined && state.busy.includes(rowKey(row.entryId))}
|
||||
configured={ledger.bundles.has(openPkg.name)}
|
||||
@@ -1231,7 +1259,10 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
||||
? null
|
||||
: (
|
||||
<ConfirmDialog
|
||||
confirm={state.confirm}
|
||||
name={packageText(
|
||||
state.packages.find(pkg => pkg.name === state.confirm?.packageName) ?? { name: state.confirm.packageName },
|
||||
resolveText,
|
||||
).title}
|
||||
t={t}
|
||||
onConfirm={props.confirm}
|
||||
onCancel={props.cancelConfirm}
|
||||
|
||||
@@ -84,7 +84,7 @@ export function apply(ctx: ClientContext): void {
|
||||
name: 'main',
|
||||
key: PANEL_ID,
|
||||
locale: NS,
|
||||
inject: () => controller.inject(configLedger),
|
||||
inject: () => controller.inject(configLedger, text => ctx.locale.resolveText(text)),
|
||||
children: {
|
||||
'plugins.item': { kind: 'list', scope: 'root' },
|
||||
'plugins.bundle.config': { kind: 'keyed', scope: 'root' },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Plugin management copy and display names of shipped global rows. */
|
||||
/** Plugin management interface copy. */
|
||||
|
||||
/** Simplified Chinese dictionary and key source of truth. */
|
||||
export const zh = {
|
||||
@@ -16,15 +16,11 @@ export const zh = {
|
||||
overriddenNotice: '{name} 已保存,但被更高优先级的配置覆盖,当前未生效',
|
||||
bundlesTitle: '已安装',
|
||||
officialTitle: '官方',
|
||||
builtinAgentTeamTitle: '智能体团队',
|
||||
builtinAgentTeamDescription: '启用团队协作、团队工具、成员列表和共享任务看板。',
|
||||
builtinAutoReviewTitle: '自动授权审查',
|
||||
builtinAutoReviewDescription: '提供自动审查权限模式,由模型在每次工具调用前判断是否授权。',
|
||||
statusProblem: '异常',
|
||||
statusBeta: 'Beta',
|
||||
reasonLabel: '原因',
|
||||
metadataError: '包元信息错误:{error}',
|
||||
versionTag: 'v{version}',
|
||||
noDescription: '暂无描述。',
|
||||
partsLabel: '包含的组件',
|
||||
partsEmpty: '这个插件包不包含任何组件。',
|
||||
partsCountTotal: '共 {count} 个',
|
||||
@@ -204,15 +200,11 @@ export const en = {
|
||||
overriddenNotice: '{name} was saved, but a higher-priority configuration overrides it, so it is not in effect',
|
||||
bundlesTitle: 'Installed',
|
||||
officialTitle: 'Official',
|
||||
builtinAgentTeamTitle: 'Agent Teams',
|
||||
builtinAgentTeamDescription: 'Enable team collaboration, team tools, the member roster, and the shared task board.',
|
||||
builtinAutoReviewTitle: 'Auto Authorization Review',
|
||||
builtinAutoReviewDescription: 'Add an Auto review permission mode that uses the model to assess authorization before each tool call.',
|
||||
statusProblem: 'Problem',
|
||||
statusBeta: 'Beta',
|
||||
reasonLabel: 'Reason',
|
||||
metadataError: 'Package metadata error: {error}',
|
||||
versionTag: 'v{version}',
|
||||
noDescription: 'No description.',
|
||||
partsLabel: 'Components',
|
||||
partsEmpty: 'This plugin pack contains no components.',
|
||||
partsCountTotal: '{count} total',
|
||||
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
import { REGISTRY_URL } from '@deepseek-ai/dsh-plugin-manager/registry'
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { LocalizedText, PackageMeta } from '@deepseek-ai/dsh-package-manifest'
|
||||
import type { ConfigLedger } from './config-ledger.ts'
|
||||
import { shortName } from './presentation.ts'
|
||||
|
||||
@@ -59,6 +60,8 @@ export interface PackageRow {
|
||||
readonly rowId: string
|
||||
/** The module the row names. */
|
||||
readonly moduleName: string
|
||||
/** Local package display text and metadata diagnostics supplied by the Host. */
|
||||
readonly meta?: PackageMeta
|
||||
/** Whether the entry runs; false for a row without a live entry. */
|
||||
readonly enabled: boolean
|
||||
/** The entry's fiber phase, null without a live fiber. */
|
||||
@@ -72,6 +75,8 @@ export interface PackageView {
|
||||
readonly name: string
|
||||
readonly version?: string
|
||||
readonly description?: string
|
||||
/** Local package display text and metadata diagnostics supplied by the Host. */
|
||||
readonly meta?: PackageMeta
|
||||
/** Whether the profile's own dependencies hold the package; false for a bundle the installation supplies. */
|
||||
readonly installed: boolean
|
||||
/** Whether the installation ships the bundle for the person to switch on: official, off until selected, never removable. */
|
||||
@@ -220,6 +225,8 @@ export interface PluginManagerState {
|
||||
|
||||
/** The registration-side face the tab's slot entry injects. */
|
||||
export interface PluginManagerFace {
|
||||
/** Resolve local package text in the current Client locale at render time. */
|
||||
resolveText: (text: LocalizedText) => string
|
||||
hooks: {
|
||||
/** Tab snapshot bound by the renderer as usePluginManager. */
|
||||
pluginManager: SnapshotStore<PluginManagerState>
|
||||
@@ -325,6 +332,7 @@ export function packageView(bundle: BundleInfo, plugins: readonly PluginInfo[]):
|
||||
moduleName: row.moduleName,
|
||||
enabled: live?.enabled ?? false,
|
||||
phase: live?.fiberPhase ?? null,
|
||||
...row.meta === undefined ? {} : { meta: row.meta },
|
||||
...row.entryId === undefined ? {} : { entryId: row.entryId },
|
||||
...live?.readOnlyReason === undefined ? {} : { readOnlyReason: live.readOnlyReason },
|
||||
}
|
||||
@@ -337,6 +345,7 @@ export function packageView(bundle: BundleInfo, plugins: readonly PluginInfo[]):
|
||||
rows,
|
||||
...bundle.version === undefined ? {} : { version: bundle.version },
|
||||
...bundle.description === undefined ? {} : { description: bundle.description },
|
||||
...bundle.meta === undefined ? {} : { meta: bundle.meta },
|
||||
...bundle.readOnlyReason === undefined ? {} : { readOnlyReason: bundle.readOnlyReason },
|
||||
...bundle.error === undefined ? {} : { error: bundle.error },
|
||||
}
|
||||
@@ -431,10 +440,12 @@ export class PluginManagerController {
|
||||
/**
|
||||
* Build the face the tab's slot registration injects.
|
||||
* @param configLedger - the projection of the plugins carrying configuration, bound beside the tab's own state.
|
||||
* @param resolveText - render-time package text resolution supplied by the locale service.
|
||||
* @returns the tab's snapshot sources and its actions.
|
||||
*/
|
||||
inject(configLedger: HostObservable<ConfigLedger>): PluginManagerFace {
|
||||
inject(configLedger: HostObservable<ConfigLedger>, resolveText: PluginManagerFace['resolveText']): PluginManagerFace {
|
||||
return {
|
||||
resolveText,
|
||||
hooks: { pluginManager: this.store, configLedger },
|
||||
ensure: () => { if (this.getSnapshot().status === 'idle') void this.load() },
|
||||
refresh: () => { void this.load() },
|
||||
|
||||
@@ -3,19 +3,15 @@
|
||||
import type { ManagementError, Registry } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { PluginManagerLocaleKey } from './locales.ts'
|
||||
import type { FailedAction, ManagerNotice, PackageView } from './manager-store.ts'
|
||||
import type { FailedAction, ManagerNotice, PackageRow, PackageView, PluginManagerFace } from './manager-store.ts'
|
||||
|
||||
/** The translate seat of the manager's dictionary. */
|
||||
export type Translate = PropsLocale<'pluginManager'>['t']
|
||||
|
||||
/** The official packages with copy of their own, and whether each is a beta feature the page tags as such. */
|
||||
const BUILTIN_COPY = new Map<string, { title: PluginManagerLocaleKey; description: PluginManagerLocaleKey; beta: boolean }>([
|
||||
['@deepseek-ai/dsh-experimental-agent-team-profile', {
|
||||
title: 'builtinAgentTeamTitle', description: 'builtinAgentTeamDescription', beta: true,
|
||||
}],
|
||||
['@deepseek-ai/dsh-experimental-auto-review', {
|
||||
title: 'builtinAutoReviewTitle', description: 'builtinAutoReviewDescription', beta: true,
|
||||
}],
|
||||
/** Official packages whose release status the page identifies as beta. */
|
||||
const BETA_PACKAGES = new Set([
|
||||
'@deepseek-ai/dsh-experimental-agent-team-profile',
|
||||
'@deepseek-ai/dsh-experimental-auto-review',
|
||||
])
|
||||
|
||||
/** The registries with a name of their own, by host. */
|
||||
@@ -98,18 +94,34 @@ export function shortName(name: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Localize known official packages by exact npm name at render time.
|
||||
* @param pkg - original package identity and optional metadata description.
|
||||
* @param t - the manager's current translate function.
|
||||
* @returns localized copy and whether the package is a beta feature, or the package's short name and original description.
|
||||
* Resolve installed package metadata without changing its technical identity.
|
||||
* @param pkg - package identity and local metadata.
|
||||
* @param resolveText - current-locale package text resolver.
|
||||
* @returns localized copy with a technical-name fallback and the independent beta status.
|
||||
*/
|
||||
export function packageText(
|
||||
pkg: Pick<PackageView, 'name' | 'description'>, t: Translate,
|
||||
pkg: Pick<PackageView, 'name' | 'meta'>, resolveText: PluginManagerFace['resolveText'],
|
||||
): { title: string; description: string | undefined; beta: boolean } {
|
||||
const keys = BUILTIN_COPY.get(pkg.name)
|
||||
return keys === undefined
|
||||
? { title: shortName(pkg.name), description: pkg.description, beta: false }
|
||||
: { title: t(keys.title), description: t(keys.description), beta: keys.beta }
|
||||
return {
|
||||
title: pkg.meta?.title === undefined ? pkg.name : resolveText(pkg.meta.title),
|
||||
description: pkg.meta?.description === undefined ? undefined : resolveText(pkg.meta.description) || undefined,
|
||||
beta: BETA_PACKAGES.has(pkg.name),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a bundle row's plugin metadata, using its full module specifier as the final title fallback.
|
||||
* @param row - row identity and local metadata.
|
||||
* @param resolveText - current-locale package text resolver.
|
||||
* @returns the row's display title and optional description.
|
||||
*/
|
||||
export function rowText(
|
||||
row: Pick<PackageRow, 'moduleName' | 'meta'>, resolveText: PluginManagerFace['resolveText'],
|
||||
): { title: string; description: string | undefined } {
|
||||
return {
|
||||
title: row.meta?.title === undefined ? row.moduleName : resolveText(row.meta.title),
|
||||
description: row.meta?.description === undefined ? undefined : resolveText(row.meta.description) || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
* that row. A registrant merges this contract with `import type` and registers
|
||||
* through `ctx.slots`; it never imports this package at runtime.
|
||||
*
|
||||
* Every entry is rendered in two views the owner asks for: `summary` for the
|
||||
* one-liner the page places under the title, `page` for the form with its own
|
||||
* save control. The page draws the title, the icon, and the crumb itself.
|
||||
* Entries accept `page` for the form with its own save control and `summary`
|
||||
* for an official card's one-liner or a row's missing-description fallback.
|
||||
* Bundle configuration renders only `page`. The page draws the title, icon,
|
||||
* and crumb itself.
|
||||
*/
|
||||
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -40,7 +41,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* The configuration of one row a bundle declares, keyed by
|
||||
* `<package name>#<row id>` with the row id as the bundle's patch declares
|
||||
* it: the row on the bundle's page gains a configure control that opens
|
||||
* the entry's page, headed by the row id and the entry's summary.
|
||||
* the entry's page, headed by the plugin's display title and description.
|
||||
* An absent description falls back to the entry's `view: 'summary'`.
|
||||
*/
|
||||
'plugins.row.config': { kind: 'keyed'; scope: 'root'; owner: PluginConfigViewProps }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
@@ -17,6 +17,7 @@ afterEach(cleanup)
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
onTestFinished(async () => { await ctx.fiber.dispose() })
|
||||
await ctx.plugin(SlotRegistry).await()
|
||||
const locale = new LocaleRuntime(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
@@ -83,6 +84,11 @@ describe('ui-plugin-manager browser plugin', () => {
|
||||
expect(b.slots.spec('plugins.bundle.config')).toMatchObject({ kind: 'keyed', scope: 'root' })
|
||||
expect(b.slots.spec('plugins.row.config')).toMatchObject({ kind: 'keyed', scope: 'root' })
|
||||
const face = (entry.inject as unknown as () => PluginManagerFace)()
|
||||
const text = { en: 'Local tools', zh: '本地工具' }
|
||||
expect(face.resolveText(text)).toBe('本地工具')
|
||||
b.locale.setLocale('en')
|
||||
expect(face.resolveText(text)).toBe('Local tools')
|
||||
b.locale.setLocale('zh')
|
||||
expect(face.hooks.configLedger.getSnapshot()).toEqual({ items: [], bundles: new Set(), rows: new Set() })
|
||||
// A Host change before the first render is not a reason to read.
|
||||
b.remote.emit('plugin-manager/changed', [{ reason: 'install' }])
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { PluginEntryId, PluginInstallRequestId } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
@@ -69,6 +71,11 @@ type SlotBodies = Record<string, (view: 'summary' | 'page') => ReactNode>
|
||||
const NO_CONFIG: ConfigLedger = { items: [], bundles: new Set(), rows: new Set() }
|
||||
|
||||
function renderTab(state: Partial<PluginManagerState> = {}, config: Partial<ConfigLedger> = {}, bodies: SlotBodies = {}) {
|
||||
const ctx = new Context()
|
||||
onTestFinished(async () => { await ctx.fiber.dispose() })
|
||||
const locale = new LocaleRuntime(ctx)
|
||||
locale.setLocale('en')
|
||||
const resolveText: PluginManagerPageProps['resolveText'] = text => locale.resolveText(text)
|
||||
const store = createSnapshotStore<PluginManagerState>({ ...READY, ...state })
|
||||
const ledger = createSnapshotStore<ConfigLedger>({ ...NO_CONFIG, ...config })
|
||||
const actions = {
|
||||
@@ -96,6 +103,7 @@ function renderTab(state: Partial<PluginManagerState> = {}, config: Partial<Conf
|
||||
}
|
||||
const props = {
|
||||
t,
|
||||
resolveText,
|
||||
...actions,
|
||||
usePluginManager: bindSnapshotSelector(store),
|
||||
useConfigLedger: bindSnapshotSelector(ledger),
|
||||
@@ -107,7 +115,10 @@ function renderTab(state: Partial<PluginManagerState> = {}, config: Partial<Conf
|
||||
store,
|
||||
actions,
|
||||
set: (next: Partial<PluginManagerState>) => { act(() => { store.set({ ...store.getSnapshot(), ...next }) }) },
|
||||
setLanguage: (dict: typeof en) => { rerender(<PluginManagerPage {...props} t={translate(dict)} />) },
|
||||
setLanguage: (dict: typeof en) => {
|
||||
locale.setLocale(dict === zh ? 'zh' : 'en')
|
||||
rerender(<PluginManagerPage {...props} t={translate(dict)} />)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +144,7 @@ describe('PluginManagerPage', () => {
|
||||
|
||||
it('keeps the read failure and its retry visible while a detail page is open', () => {
|
||||
const { actions, set } = renderTab({ packages: [pkg()] })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'better-sidebar') }))
|
||||
fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') }))
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
set({ status: 'error' })
|
||||
expect(screen.getByRole('alert').querySelector('[data-state="error"]')).not.toBeNull()
|
||||
@@ -146,7 +157,7 @@ describe('PluginManagerPage', () => {
|
||||
it('lists the installed bundles as cards, the installation\'s offered ones as official, and tags a problem the Host reports', () => {
|
||||
const { actions } = renderTab({
|
||||
packages: [
|
||||
pkg({ description: 'A sidebar.' }),
|
||||
pkg({ meta: { description: { en: 'A sidebar.' } } }),
|
||||
pkg({ name: 'dsh-broken', enabled: false, error: { code: 'not-bundle' } }),
|
||||
pkg({ name: '@deepseek-ai/dsh-web-app', installed: false }),
|
||||
pkg({ name: 'dsh-protected', readOnlyReason: 'management-required' }),
|
||||
@@ -168,15 +179,14 @@ describe('PluginManagerPage', () => {
|
||||
expect(screen.getByRole('heading', { name: en.officialTitle })).toBeTruthy()
|
||||
expect([...document.querySelectorAll('[data-plugin-count]')].map(count => count.textContent)).toEqual(['1', '5'])
|
||||
expect(screen.getAllByText(en.statusBeta)).toHaveLength(1)
|
||||
// A scoped name reads without its scope and harness prefix.
|
||||
expect(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'tool') })).toHaveProperty('disabled', false)
|
||||
expect(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', '@acme/dsh-tool') })).toHaveProperty('disabled', false)
|
||||
expect(screen.getByText('A sidebar.')).toBeTruthy()
|
||||
expect(screen.getAllByText(en.statusProblem)).toHaveLength(2)
|
||||
// The switch acts on the bundle; a bundle the Host cannot read stays off, a protected one stays as it is.
|
||||
fireEvent.click(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'better-sidebar') }))
|
||||
fireEvent.click(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'dsh-better-sidebar') }))
|
||||
expect(actions.setEnabled).toHaveBeenCalledWith('dsh-better-sidebar', false)
|
||||
expect(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'broken') })).toHaveProperty('disabled', true)
|
||||
const locked = screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'protected') })
|
||||
expect(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'dsh-broken') })).toHaveProperty('disabled', true)
|
||||
const locked = screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'dsh-protected') })
|
||||
expect(locked).toHaveProperty('disabled', true)
|
||||
expect(locked.getAttribute('title')).toBe(en.reasonManagementRequired)
|
||||
})
|
||||
@@ -215,52 +225,258 @@ describe('PluginManagerPage', () => {
|
||||
})
|
||||
|
||||
it('opens an official bundle\'s page with its beta tag and no uninstall, and switches it on', () => {
|
||||
const title = 'Agent Teams'
|
||||
const { actions } = renderTab({
|
||||
packages: [pkg({ name: '@deepseek-ai/dsh-experimental-agent-team-profile', installed: false, optional: true, enabled: false })],
|
||||
packages: [pkg({ name: '@deepseek-ai/dsh-experimental-agent-team-profile', meta: { title }, installed: false, optional: true, enabled: false })],
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', en.builtinAgentTeamTitle) }))
|
||||
fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', title) }))
|
||||
const detail = document.querySelector('[data-plugin-detail]') as HTMLElement
|
||||
expect(within(detail).getByText(en.statusBeta)).toBeTruthy()
|
||||
expect(within(detail).queryByRole('button', { name: en.uninstallLabel.replace('{name}', en.builtinAgentTeamTitle) })).toBeNull()
|
||||
fireEvent.click(within(detail).getByRole('switch', { name: en.enableToggle.replace('{name}', en.builtinAgentTeamTitle) }))
|
||||
expect(within(detail).queryByRole('button', { name: en.uninstallLabel.replace('{name}', title) })).toBeNull()
|
||||
fireEvent.click(within(detail).getByRole('switch', { name: en.enableToggle.replace('{name}', title) }))
|
||||
expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith('@deepseek-ai/dsh-experimental-agent-team-profile', true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['agent-team-profile', 'builtinAgentTeamTitle', 'builtinAgentTeamDescription'],
|
||||
['auto-review', 'builtinAutoReviewTitle', 'builtinAutoReviewDescription'],
|
||||
] as const)('localizes %s across cards, details, switches, and uninstall confirmation', (suffix, titleKey, descriptionKey) => {
|
||||
const name = `@deepseek-ai/dsh-experimental-${suffix}`
|
||||
const { actions, set, setLanguage } = renderTab({ packages: [pkg({ name, description: 'Original metadata.' })] })
|
||||
'@deepseek-ai/dsh-experimental-agent-team-profile',
|
||||
'@deepseek-ai/dsh-experimental-auto-review',
|
||||
'@acme/dsh-local-tools',
|
||||
])('localizes Host metadata for %s across cards, details, switches, and uninstall confirmation', (name) => {
|
||||
const meta = {
|
||||
title: { en: 'Installed tools', zh: '已安装工具' },
|
||||
description: { en: 'Tools on this host.', zh: '本机工具。' },
|
||||
}
|
||||
const title = (dict: typeof en): string => dict === zh ? meta.title.zh : meta.title.en
|
||||
const description = (dict: typeof en): string => dict === zh ? meta.description.zh : meta.description.en
|
||||
const { actions, set, setLanguage } = renderTab({ packages: [pkg({ name, meta, description: 'Original metadata.' })] })
|
||||
const assertCard = (dict: typeof en) => {
|
||||
expect(screen.getByRole('button', { name: dict.openDetail.replace('{name}', dict[titleKey]) }).textContent).toBe(dict[titleKey])
|
||||
expect(screen.getByText(dict[descriptionKey])).toBeTruthy()
|
||||
expect(screen.getByRole('switch', { name: dict.enableToggle.replace('{name}', dict[titleKey]) })).toBeTruthy()
|
||||
const card = screen.getByRole('button', { name: dict.openDetail.replace('{name}', title(dict)) })
|
||||
expect(card.textContent).toBe(title(dict))
|
||||
expect(screen.getByText(description(dict))).toBeTruthy()
|
||||
expect(document.getElementById(card.getAttribute('aria-describedby')!)?.textContent).toBe(description(dict))
|
||||
expect(screen.getByRole('switch', { name: dict.enableToggle.replace('{name}', title(dict)) })).toBeTruthy()
|
||||
expect(screen.queryByText('Original metadata.')).toBeNull()
|
||||
}
|
||||
assertCard(en)
|
||||
setLanguage(zh)
|
||||
assertCard(zh)
|
||||
fireEvent.click(screen.getByRole('switch', { name: zh.enableToggle.replace('{name}', zh[titleKey]) }))
|
||||
fireEvent.click(screen.getByRole('switch', { name: zh.enableToggle.replace('{name}', title(zh)) }))
|
||||
expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith(name, false)
|
||||
fireEvent.click(screen.getByRole('button', { name: zh.openDetail.replace('{name}', zh[titleKey]) }))
|
||||
fireEvent.click(screen.getByRole('button', { name: zh.openDetail.replace('{name}', title(zh)) }))
|
||||
for (const dict of [zh, en]) {
|
||||
setLanguage(dict)
|
||||
expect(screen.getByRole('heading', { level: 3 }).textContent).toBe(dict[titleKey])
|
||||
expect(screen.getByText(dict[descriptionKey])).toBeTruthy()
|
||||
expect(screen.getByRole('heading', { level: 3 }).textContent).toBe(title(dict))
|
||||
expect(screen.getByText(description(dict))).toBeTruthy()
|
||||
expect(document.querySelector('[data-plugin-name]')?.textContent).toBe(name)
|
||||
expect(screen.getByRole('switch', { name: dict.enableToggle.replace('{name}', dict[titleKey]) })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: dict.uninstallLabel.replace('{name}', dict[titleKey]) })).toBeTruthy()
|
||||
expect(screen.getByRole('switch', { name: dict.enableToggle.replace('{name}', title(dict)) })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: dict.uninstallLabel.replace('{name}', title(dict)) })).toBeTruthy()
|
||||
}
|
||||
fireEvent.click(screen.getByRole('button', { name: en.uninstallLabel.replace('{name}', en[titleKey]) }))
|
||||
fireEvent.click(screen.getByRole('button', { name: en.uninstallLabel.replace('{name}', title(en)) }))
|
||||
expect(actions.uninstall).toHaveBeenCalledExactlyOnceWith(name)
|
||||
set({ confirm: { action: 'uninstall', packageName: name } })
|
||||
for (const dict of [en, zh]) {
|
||||
setLanguage(dict)
|
||||
expect(screen.getByRole('dialog', { name: dict.confirmUninstallTitle.replace('{name}', dict[titleKey]) })).toBeTruthy()
|
||||
expect(screen.getByRole('dialog', { name: dict.confirmUninstallTitle.replace('{name}', title(dict)) })).toBeTruthy()
|
||||
}
|
||||
})
|
||||
|
||||
it('shows metadata diagnostics without blocking management or displaying legacy descriptions', () => {
|
||||
const error = 'locale/zh.json: invalid title'
|
||||
const { actions, set, setLanguage } = renderTab({
|
||||
packages: [pkg({ enabled: false, description: 'Legacy description.', meta: { error } })],
|
||||
})
|
||||
expect(screen.getByText(en.metadataError.replace('{error}', error))).toBeTruthy()
|
||||
expect(screen.queryByText('Legacy description.')).toBeNull()
|
||||
const enable = screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'dsh-better-sidebar') })
|
||||
expect(enable).toHaveProperty('disabled', false)
|
||||
fireEvent.click(enable)
|
||||
expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith('dsh-better-sidebar', true)
|
||||
fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') }))
|
||||
expect(screen.getByText(en.metadataError.replace('{error}', error))).toBeTruthy()
|
||||
const uninstall = screen.getByRole('button', { name: en.uninstallLabel.replace('{name}', 'dsh-better-sidebar') })
|
||||
expect(uninstall).toHaveProperty('disabled', false)
|
||||
fireEvent.click(uninstall)
|
||||
expect(actions.uninstall).toHaveBeenCalledExactlyOnceWith('dsh-better-sidebar')
|
||||
|
||||
setLanguage(zh)
|
||||
expect(screen.getByText(zh.metadataError.replace('{error}', error))).toBeTruthy()
|
||||
set({ packages: [pkg({ meta: { title: 'Literal title', description: { en: 'English fallback.' } } })] })
|
||||
expect(screen.getByRole('heading', { level: 3 }).textContent).toBe('Literal title')
|
||||
expect(screen.getByText('English fallback.')).toBeTruthy()
|
||||
expect(document.querySelector('[data-package-meta-error]')).toBeNull()
|
||||
})
|
||||
|
||||
it.each<{
|
||||
field: string
|
||||
meta: NonNullable<PackageView['meta']>
|
||||
englishTitle: string
|
||||
chineseTitle: string
|
||||
englishDescription?: string
|
||||
chineseDescription: string
|
||||
}>([
|
||||
{
|
||||
field: 'title',
|
||||
meta: { title: { en: '@acme/dsh-sidebar', zh: '侧栏套件' }, description: 'Package description.' },
|
||||
englishTitle: '@acme/dsh-sidebar', chineseTitle: '侧栏套件',
|
||||
englishDescription: 'Package description.', chineseDescription: 'Package description.',
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
meta: { title: { en: 'English title' }, description: { en: '', zh: '中文套件说明。' } },
|
||||
englishTitle: 'English title', chineseTitle: 'English title', chineseDescription: '中文套件说明。',
|
||||
},
|
||||
])('resolves bundle $field independently and hides empty descriptions on cards and details', ({ meta, englishTitle, chineseTitle, englishDescription, chineseDescription }) => {
|
||||
const { setLanguage } = renderTab({ packages: [pkg({ name: '@acme/dsh-sidebar', meta })] })
|
||||
const languages = [
|
||||
{ dict: en, title: englishTitle, description: englishDescription },
|
||||
{ dict: zh, title: chineseTitle, description: chineseDescription },
|
||||
{ dict: en, title: englishTitle, description: englishDescription },
|
||||
]
|
||||
for (const { dict, title, description } of languages) {
|
||||
setLanguage(dict)
|
||||
const card = screen.getByRole('button', { name: dict.openDetail.replace('{name}', title) })
|
||||
expect(card.textContent).toBe(title)
|
||||
if (description === undefined) {
|
||||
expect(card.getAttribute('aria-describedby')).toBeNull()
|
||||
expect(screen.queryByText(chineseDescription)).toBeNull()
|
||||
} else {
|
||||
expect(screen.getByText(description)).toBeTruthy()
|
||||
expect(document.getElementById(card.getAttribute('aria-describedby')!)?.textContent).toBe(description)
|
||||
}
|
||||
}
|
||||
fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', englishTitle) }))
|
||||
for (const { dict, title, description } of languages) {
|
||||
setLanguage(dict)
|
||||
expect(screen.getByRole('heading', { level: 3 }).textContent).toBe(title)
|
||||
if (description === undefined) {
|
||||
expect(screen.queryByText(chineseDescription)).toBeNull()
|
||||
expect(screen.queryByText(dict === zh ? '暂无描述。' : 'No description.')).toBeNull()
|
||||
} else {
|
||||
expect(screen.getByText(description)).toBeTruthy()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves row fields independently from bundle metadata and preserves subpath specifiers', () => {
|
||||
const rows = [
|
||||
row({ moduleName: '@acme/dsh-sidebar/navigation', meta: { description: { en: 'Navigation description.' } } }),
|
||||
row({
|
||||
rowId: 'theme', entryId: 'include:theme' as PluginEntryId, moduleName: '@acme/dsh-theme/client',
|
||||
meta: { title: { en: '@acme/dsh-theme', zh: '主题插件' }, description: { en: '', zh: '中文主题说明。' } },
|
||||
}),
|
||||
]
|
||||
const { actions, setLanguage } = renderTab(
|
||||
{ packages: [pkg({ meta: { title: 'Bundle title', description: 'Bundle description.' }, rows })] },
|
||||
{ rows: new Set(['dsh-better-sidebar#theme']) },
|
||||
{ 'plugins.row.config:dsh-better-sidebar#theme': view => view === 'page' ? <form aria-label="theme settings" /> : null },
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'Bundle title') }))
|
||||
expect(screen.getByRole('switch', { name: en.partToggle.replace('{name}', '@acme/dsh-sidebar/navigation') })).toBeTruthy()
|
||||
expect(screen.getByRole('switch', { name: en.partToggle.replace('{name}', '@acme/dsh-theme') })).toBeTruthy()
|
||||
expect(screen.queryByText('中文主题说明。')).toBeNull()
|
||||
setLanguage(zh)
|
||||
const navigation = document.querySelector('[data-plugin-row="include:sidebar"]') as HTMLElement
|
||||
expect(within(navigation).getByText('Navigation description.')).toBeTruthy()
|
||||
expect(within(navigation).queryByText('Bundle title')).toBeNull()
|
||||
expect(within(navigation).queryByText('Bundle description.')).toBeNull()
|
||||
fireEvent.click(within(navigation).getByRole('switch', { name: zh.partToggle.replace('{name}', '@acme/dsh-sidebar/navigation') }))
|
||||
expect(actions.setRowEnabled).toHaveBeenCalledExactlyOnceWith('include:sidebar', false)
|
||||
expect(screen.getByText('中文主题说明。')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: zh.configureRow.replace('{name}', '主题插件') }))
|
||||
expect(document.querySelector('[data-plugin-row-detail]')?.getAttribute('data-plugin-row-detail')).toBe('dsh-better-sidebar#theme')
|
||||
expect(screen.getByRole('heading', { level: 3 }).textContent).toBe('主题插件')
|
||||
expect(screen.getByText('中文主题说明。')).toBeTruthy()
|
||||
setLanguage(en)
|
||||
expect(screen.getByRole('heading', { level: 3 }).textContent).toBe('@acme/dsh-theme')
|
||||
expect(screen.getByText('@acme/dsh-theme/client')).toBeTruthy()
|
||||
expect(screen.getByText('theme')).toBeTruthy()
|
||||
expect(screen.queryByText('中文主题说明。')).toBeNull()
|
||||
expect(screen.getByRole('form', { name: 'theme settings' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows a row id only once when it is the localized title in lists and configuration pages', () => {
|
||||
const moduleName = '@acme/dsh-sidebar/navigation'
|
||||
const { setLanguage } = renderTab(
|
||||
{ packages: [pkg({ rows: [row({ moduleName, meta: { title: { en: 'Sidebar component', zh: 'sidebar' } } })] })] },
|
||||
{ rows: new Set(['dsh-better-sidebar#sidebar']) },
|
||||
{ 'plugins.row.config:dsh-better-sidebar#sidebar': view => view === 'page' ? <form aria-label="sidebar settings" /> : null },
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') }))
|
||||
const listed = document.querySelector('[data-plugin-row="include:sidebar"]') as HTMLElement
|
||||
expect(within(listed).getByText('Sidebar component')).toBeTruthy()
|
||||
expect(within(listed).getByText('sidebar', { selector: 'code' })).toBeTruthy()
|
||||
setLanguage(zh)
|
||||
expect(within(listed).getAllByText('sidebar')).toHaveLength(1)
|
||||
expect(within(listed).queryByText('sidebar', { selector: 'code' })).toBeNull()
|
||||
expect(within(listed).getByText(moduleName, { selector: 'code' })).toBeTruthy()
|
||||
|
||||
fireEvent.click(within(listed).getByRole('button', { name: zh.configureRow.replace('{name}', 'sidebar') }))
|
||||
const detail = document.querySelector('[data-plugin-row-detail="dsh-better-sidebar#sidebar"]') as HTMLElement
|
||||
expect(within(detail).getByRole('heading', { level: 3 }).textContent).toBe('sidebar')
|
||||
expect(within(detail).getAllByText('sidebar')).toHaveLength(1)
|
||||
expect(within(detail).queryByText('sidebar', { selector: 'code' })).toBeNull()
|
||||
expect(within(detail).getByText(moduleName, { selector: 'code' })).toBeTruthy()
|
||||
expect(within(detail).getByRole('form', { name: 'sidebar settings' })).toBeTruthy()
|
||||
setLanguage(en)
|
||||
expect(within(detail).getByRole('heading', { level: 3 }).textContent).toBe('Sidebar component')
|
||||
expect(within(detail).getByText('sidebar', { selector: 'code' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('translates row text, searches current copy and technical identities, and keeps configuration keys unchanged', () => {
|
||||
const error = 'locale/zh.json: invalid description'
|
||||
const localized = row({
|
||||
moduleName: '@acme/dsh-sidebar-widget',
|
||||
meta: {
|
||||
title: { en: 'Sidebar component', zh: '导航组件' },
|
||||
description: { en: 'Sidebar navigation', zh: '侧边导航' },
|
||||
error,
|
||||
},
|
||||
})
|
||||
const rows = [localized, ...Array.from({ length: 10 }, (_, index) => row({
|
||||
rowId: `extra-${String(index)}`, entryId: `include:extra-${String(index)}` as PluginEntryId, moduleName: '@acme/other',
|
||||
}))]
|
||||
const { actions, setLanguage } = renderTab(
|
||||
{ packages: [pkg({ meta: { title: { en: 'Personal tools', zh: '个人工具' } }, rows })] },
|
||||
{ rows: new Set(['dsh-better-sidebar#sidebar']) },
|
||||
{ 'plugins.row.config:dsh-better-sidebar#sidebar': view => view === 'summary' ? 'Config summary' : <form aria-label="row settings" /> },
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'Personal tools') }))
|
||||
setLanguage(zh)
|
||||
const search = screen.getByRole('searchbox', { name: zh.partsFilter })
|
||||
for (const query of ['导航组件', '侧边导航', 'sidebar', '@acme/dsh-sidebar-widget']) {
|
||||
fireEvent.change(search, { target: { value: query } })
|
||||
expect(document.querySelectorAll('[data-plugin-row]')).toHaveLength(1)
|
||||
expect(screen.getByRole('button', { name: zh.configureRow.replace('{name}', '导航组件') })).toBeTruthy()
|
||||
}
|
||||
expect(screen.getByText('sidebar')).toBeTruthy()
|
||||
expect(screen.getByText('@acme/dsh-sidebar-widget')).toBeTruthy()
|
||||
expect(screen.getByText('侧边导航')).toBeTruthy()
|
||||
expect(screen.getByText(zh.metadataError.replace('{error}', error))).toBeTruthy()
|
||||
const toggle = screen.getByRole('switch', { name: zh.partToggle.replace('{name}', '导航组件') })
|
||||
expect(toggle).toHaveProperty('disabled', false)
|
||||
fireEvent.click(toggle)
|
||||
expect(actions.setRowEnabled).toHaveBeenCalledExactlyOnceWith('include:sidebar', false)
|
||||
|
||||
fireEvent.change(search, { target: { value: 'Sidebar component' } })
|
||||
expect(screen.getByText(zh.partsFilterEmpty)).toBeTruthy()
|
||||
setLanguage(en)
|
||||
expect(screen.getByRole('button', { name: en.configureRow.replace('{name}', 'Sidebar component') })).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: en.configureRow.replace('{name}', 'Sidebar component') }))
|
||||
expect(document.querySelector('[data-plugin-row-detail]')?.getAttribute('data-plugin-row-detail')).toBe('dsh-better-sidebar#sidebar')
|
||||
expect(screen.getByRole('form', { name: 'row settings' })).toBeTruthy()
|
||||
expect(screen.getByRole('heading', { level: 3 }).textContent).toBe('Sidebar component')
|
||||
expect(screen.getByText('Sidebar navigation')).toBeTruthy()
|
||||
expect(screen.queryByText('Config summary')).toBeNull()
|
||||
setLanguage(zh)
|
||||
expect(screen.getByRole('heading', { level: 3 }).textContent).toBe('导航组件')
|
||||
expect(screen.getByText('侧边导航')).toBeTruthy()
|
||||
expect(screen.getByText('sidebar')).toBeTruthy()
|
||||
expect(screen.getByText('@acme/dsh-sidebar-widget')).toBeTruthy()
|
||||
expect(screen.getByText(zh.metadataError.replace('{error}', error))).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: zh.backToPackage.replace('{name}', '个人工具') }))
|
||||
expect(screen.getByRole('heading', { level: 3 }).textContent).toBe('个人工具')
|
||||
})
|
||||
|
||||
describe('configuration pages', () => {
|
||||
const bodies: SlotBodies = {
|
||||
'plugins.item:bash': view => view === 'summary' ? 'Limits every command.' : <form aria-label="bash form" />,
|
||||
@@ -306,35 +522,36 @@ describe('PluginManagerPage', () => {
|
||||
|
||||
it('renders a bundle\'s own configuration on its page, and no configure control on a row without one', () => {
|
||||
renderTab({ packages: [pkg({ rows: [row()] })] }, { bundles: new Set(['dsh-better-sidebar']) }, bodies)
|
||||
fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'better-sidebar') }))
|
||||
fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') }))
|
||||
const detail = document.querySelector('[data-plugin-detail]') as HTMLElement
|
||||
expect(within(detail).getByRole('form', { name: 'sidebar form' })).toBeTruthy()
|
||||
expect(within(detail).queryByRole('button', { name: en.configureRow.replace('{name}', 'sidebar') })).toBeNull()
|
||||
expect(within(detail).queryByRole('button', { name: en.configureRow.replace('{name}', 'dsh-better-sidebar') })).toBeNull()
|
||||
})
|
||||
|
||||
it('opens a row\'s configuration page from its configure control and leads back to the bundle', () => {
|
||||
const theme = row({ rowId: 'theme', moduleName: 'dsh-better-sidebar/theme', entryId: 'include:theme' as PluginEntryId })
|
||||
renderTab({ packages: [pkg({ rows: [row(), theme] })] }, { rows: new Set(['dsh-better-sidebar#sidebar']) }, bodies)
|
||||
fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'better-sidebar') }))
|
||||
expect(screen.queryByRole('button', { name: en.configureRow.replace('{name}', 'theme') })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: en.configureRow.replace('{name}', 'sidebar') }))
|
||||
fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') }))
|
||||
expect(screen.queryByRole('button', { name: en.configureRow.replace('{name}', 'dsh-better-sidebar/theme') })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: en.configureRow.replace('{name}', 'dsh-better-sidebar') }))
|
||||
const page = document.querySelector('[data-plugin-row-detail="dsh-better-sidebar#sidebar"]') as HTMLElement
|
||||
expect(within(page).getByRole('heading', { level: 3 }).textContent).toBe('sidebar')
|
||||
expect(within(page).getByText('dsh-better-sidebar')).toBeTruthy()
|
||||
expect(within(page).getByRole('heading', { level: 3 }).textContent).toBe('dsh-better-sidebar')
|
||||
expect(within(page).getByText('dsh-better-sidebar', { selector: 'code' })).toBeTruthy()
|
||||
expect(within(page).getByText('sidebar', { selector: 'code' })).toBeTruthy()
|
||||
expect(within(page).getByText('The sidebar row.')).toBeTruthy()
|
||||
expect(within(page).getByRole('form', { name: 'row form' })).toBeTruthy()
|
||||
fireEvent.click(within(page).getByRole('button', { name: en.backToPackage.replace('{name}', 'better-sidebar') }))
|
||||
fireEvent.click(within(page).getByRole('button', { name: en.backToPackage.replace('{name}', 'dsh-better-sidebar') }))
|
||||
expect(document.querySelector('[data-plugin-row-detail]')).toBeNull()
|
||||
expect(document.querySelector('[data-plugin-detail="dsh-better-sidebar"]')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves metadata for another scope with the same short name', () => {
|
||||
it('preserves the full package name and description for a third-party scope', () => {
|
||||
const name = '@acme/dsh-experimental-agent-team-profile'
|
||||
const { setLanguage } = renderTab({ packages: [pkg({ name, description: 'Third-party description.' })] })
|
||||
const { setLanguage } = renderTab({ packages: [pkg({ name, meta: { description: { en: 'Third-party description.' } } })] })
|
||||
setLanguage(zh)
|
||||
fireEvent.click(screen.getByRole('button', { name: zh.openDetail.replace('{name}', 'experimental-agent-team-profile') }))
|
||||
expect(screen.getByRole('heading', { level: 3 }).textContent).toBe('experimental-agent-team-profile')
|
||||
fireEvent.click(screen.getByRole('button', { name: zh.openDetail.replace('{name}', name) }))
|
||||
expect(screen.getByRole('heading', { level: 3 }).textContent).toBe(name)
|
||||
expect(screen.getByText('Third-party description.')).toBeTruthy()
|
||||
expect(document.querySelector('[data-plugin-name]')?.textContent).toBe(name)
|
||||
})
|
||||
@@ -357,16 +574,15 @@ describe('PluginManagerPage', () => {
|
||||
it('opens a bundle\'s page with its facts and rows, and uninstalls from it', () => {
|
||||
const { actions, set } = renderTab({
|
||||
packages: [pkg({
|
||||
description: 'A sidebar.',
|
||||
meta: { description: { en: 'A sidebar.' } },
|
||||
rows: [row(), row({ rowId: 'theme', moduleName: 'dsh-better-sidebar/theme', entryId: 'include:theme' as PluginEntryId, enabled: false, phase: null })],
|
||||
})],
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'better-sidebar') }))
|
||||
fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') }))
|
||||
const detail = document.querySelector('[data-plugin-detail="dsh-better-sidebar"]') as HTMLElement
|
||||
expect(within(detail).getByRole('heading', { level: 3 }).textContent).toBe('better-sidebar')
|
||||
expect(within(detail).getByRole('heading', { level: 3 }).textContent).toBe('dsh-better-sidebar')
|
||||
// The version sits beside the name as a tag; the crumb only leads back.
|
||||
expect(within(detail).getByText('v0.16.0')).toBeTruthy()
|
||||
// The full package name stays visible under the short name.
|
||||
expect(document.querySelector('[data-plugin-name]')?.textContent).toBe('dsh-better-sidebar')
|
||||
expect(within(detail).getByRole('button', { name: en.backToList }).textContent).toBe(en.crumbRoot)
|
||||
expect(within(detail).getByText('A sidebar.')).toBeTruthy()
|
||||
@@ -375,18 +591,18 @@ describe('PluginManagerPage', () => {
|
||||
expect(rows.map(item => item.getAttribute('data-plugin-row'))).toEqual(['include:sidebar', 'include:theme'])
|
||||
expect(rows[1]?.getAttribute('data-state')).toBe('off')
|
||||
expect(within(detail).getByText(en.partsCountTotal.replace('{count}', '2'), { exact: false })).toBeTruthy()
|
||||
expect(within(detail).getByText('dsh-better-sidebar/theme')).toBeTruthy()
|
||||
expect(within(detail).getByRole('switch', { name: en.partToggle.replace('{name}', 'dsh-better-sidebar/theme') })).toBeTruthy()
|
||||
expect(within(detail).getByText(en.rowPhaseActive)).toBeTruthy()
|
||||
expect(within(detail).getByText(en.partOff)).toBeTruthy()
|
||||
fireEvent.click(within(detail).getByRole('button', { name: en.uninstallLabel.replace('{name}', 'better-sidebar') }))
|
||||
fireEvent.click(within(detail).getByRole('button', { name: en.uninstallLabel.replace('{name}', 'dsh-better-sidebar') }))
|
||||
expect(actions.uninstall).toHaveBeenCalledWith('dsh-better-sidebar')
|
||||
fireEvent.click(within(detail).getByRole('switch', { name: en.enableToggle.replace('{name}', 'better-sidebar') }))
|
||||
fireEvent.click(within(detail).getByRole('switch', { name: en.enableToggle.replace('{name}', 'dsh-better-sidebar') }))
|
||||
expect(actions.setEnabled).toHaveBeenCalledWith('dsh-better-sidebar', false)
|
||||
// A problem and a protection the Host reports read on the page in the dictionary's words; the page leaves with the crumb.
|
||||
set({ packages: [pkg({ error: { code: 'operation-error', diagnostic: 'unreadable' }, readOnlyReason: 'management-required' })] })
|
||||
expect(within(detail).getByText(`${en.reasonLabel}: unreadable`)).toBeTruthy()
|
||||
expect(within(detail).getByText(en.reasonManagementRequired)).toBeTruthy()
|
||||
expect(within(detail).getByRole('button', { name: en.uninstallLabel.replace('{name}', 'better-sidebar') })).toHaveProperty('disabled', true)
|
||||
expect(within(detail).getByRole('button', { name: en.uninstallLabel.replace('{name}', 'dsh-better-sidebar') })).toHaveProperty('disabled', true)
|
||||
expect(within(detail).getByText(en.partsEmpty)).toBeTruthy()
|
||||
set({ packages: [pkg({ error: { code: 'not-bundle' } })] })
|
||||
expect(within(detail).getByText(`${en.reasonLabel}: ${en.reasonNotBundle}`)).toBeTruthy()
|
||||
@@ -394,11 +610,11 @@ describe('PluginManagerPage', () => {
|
||||
expect(within(detail).getByText(`${en.reasonLabel}: ${en.reasonOperationError}`)).toBeTruthy()
|
||||
fireEvent.click(within(detail).getByRole('button', { name: en.backToList }))
|
||||
expect(document.querySelector('[data-plugin-detail]')).toBeNull()
|
||||
// A bundle without a description or a version says so; one that leaves the list drops back to the cards.
|
||||
// A bundle that leaves the list drops back to the cards.
|
||||
const { version: _version, ...unversioned } = pkg()
|
||||
set({ packages: [unversioned] })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'better-sidebar') }))
|
||||
expect(screen.getByText(en.noDescription)).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') }))
|
||||
expect(screen.queryByText('No description.')).toBeNull()
|
||||
expect(screen.queryByText(en.versionTag.replace('{version}', '0.16.0'))).toBeNull()
|
||||
set({ packages: [] })
|
||||
expect(document.querySelector('[data-plugin-detail]')).toBeNull()
|
||||
@@ -420,19 +636,21 @@ describe('PluginManagerPage', () => {
|
||||
return { ...unmounted, enabled: false, phase: null }
|
||||
})
|
||||
const { actions, set } = renderTab({ packages: [pkg({ rows })], busy: [rowKey('include:row-5')] })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'better-sidebar') }))
|
||||
fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') }))
|
||||
const detail = document.querySelector('[data-plugin-detail]') as HTMLElement
|
||||
const toggle = (id: string): HTMLElement => within(detail.querySelector<HTMLElement>(`[data-plugin-row="${id}"]`)!)
|
||||
.getByRole('switch', { name: en.partToggle.replace('{name}', 'dsh-better-sidebar') })
|
||||
expect(within(detail).getByText(`${en.partsCountTotal.replace('{count}', '12')} · ${en.partsCountRunning.replace('{count}', '8')} · ${en.partsCountOff.replace('{count}', '1')} · ${en.partsCountFailed.replace('{count}', '1')}`)).toBeTruthy()
|
||||
fireEvent.click(within(detail).getByRole('switch', { name: en.partToggle.replace('{name}', 'row-0') }))
|
||||
fireEvent.click(toggle('include:row-0'))
|
||||
expect(actions.setRowEnabled).toHaveBeenCalledWith('include:row-0', false)
|
||||
// A protected row, a row without a live entry, and a row with a write in flight cannot be switched.
|
||||
const locked = within(detail).getByRole('switch', { name: en.partToggle.replace('{name}', 'row-1') })
|
||||
const locked = toggle('include:row-1')
|
||||
expect(locked).toHaveProperty('disabled', true)
|
||||
expect(locked.getAttribute('title')).toBe(en.reasonUnaddressable)
|
||||
expect(within(detail).getByRole('switch', { name: en.partToggle.replace('{name}', 'row-2') })).toHaveProperty('disabled', true)
|
||||
fireEvent.click(within(detail).getByRole('switch', { name: en.partToggle.replace('{name}', 'row-2') }))
|
||||
expect(toggle('row-2')).toHaveProperty('disabled', true)
|
||||
fireEvent.click(toggle('row-2'))
|
||||
expect(actions.setRowEnabled).toHaveBeenCalledTimes(1)
|
||||
expect(within(detail).getByRole('switch', { name: en.partToggle.replace('{name}', 'row-5') })).toHaveProperty('disabled', true)
|
||||
expect(toggle('include:row-5')).toHaveProperty('disabled', true)
|
||||
expect(within(detail).getByText(en.rowPhaseFailed)).toBeTruthy()
|
||||
expect(within(detail).getByText(en.rowPhaseLoading)).toBeTruthy()
|
||||
expect(within(detail).getByText(en.rowPhaseUnloading)).toBeTruthy()
|
||||
@@ -448,7 +666,7 @@ describe('PluginManagerPage', () => {
|
||||
fireEvent.change(filter, { target: { value: '' } })
|
||||
// A bundle that is off shows its rows without switches.
|
||||
set({ packages: [pkg({ enabled: false, rows: rows.slice(0, 2).map(item => ({ ...item, enabled: false, phase: null })) })] })
|
||||
expect(within(detail).queryByRole('switch', { name: en.partToggle.replace('{name}', 'row-0') })).toBeNull()
|
||||
expect(within(detail).queryByRole('switch', { name: en.partToggle.replace('{name}', 'dsh-better-sidebar') })).toBeNull()
|
||||
expect(within(detail).getAllByText(en.partOff)).toHaveLength(2)
|
||||
// A row without a fiber, on a bundle that is on, reads idle.
|
||||
set({ packages: [pkg({ rows: [row({ phase: null })] })] })
|
||||
@@ -841,12 +1059,14 @@ describe('PluginManagerPage', () => {
|
||||
packages: [pkg(), pkg({ name: 'dsh-other' })],
|
||||
confirm: { action: 'uninstall', packageName: 'dsh-better-sidebar' },
|
||||
})
|
||||
expect(screen.getByRole('dialog', { name: en.confirmUninstallTitle.replace('{name}', 'better-sidebar') })).toBeTruthy()
|
||||
expect(screen.getByRole('dialog', { name: en.confirmUninstallTitle.replace('{name}', 'dsh-better-sidebar') })).toBeTruthy()
|
||||
expect(screen.getByText(en.confirmUninstallDescription)).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: en.cancel }))
|
||||
expect(actions.cancelConfirm).toHaveBeenCalledTimes(1)
|
||||
set({ confirm: { action: 'uninstall', packageName: 'dsh-other' } })
|
||||
expect(screen.getByRole('dialog', { name: en.confirmUninstallTitle.replace('{name}', 'other') })).toBeTruthy()
|
||||
expect(screen.getByRole('dialog', { name: en.confirmUninstallTitle.replace('{name}', 'dsh-other') })).toBeTruthy()
|
||||
set({ packages: [] })
|
||||
expect(screen.getByRole('dialog', { name: en.confirmUninstallTitle.replace('{name}', 'dsh-other') })).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: en.confirmUninstall }))
|
||||
expect(actions.confirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* outcomes become toasts, and how the install run folds its output.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import type { BundleInfo, ChangeResult, ManagementError, PluginEntryId, PluginInfo, PluginInstallRequestId } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -87,7 +87,8 @@ function bench(overrides: Partial<Record<string, ReturnType<typeof vi.fn>>> = {}
|
||||
}
|
||||
const ctx = { remote: { pluginManager: plugins, pluginInventory: inventory } } as never
|
||||
const controller = new PluginManagerController(ctx)
|
||||
const face = controller.inject(NO_CONFIG)
|
||||
onTestFinished(() => { controller.dispose() })
|
||||
const face = controller.inject(NO_CONFIG, text => typeof text === 'string' ? text : text.en)
|
||||
const state = () => controller.getSnapshot()
|
||||
/** The request id of the run the dialog just handed to the Host. */
|
||||
const started = async (): Promise<PluginInstallRequestId> => {
|
||||
@@ -172,6 +173,25 @@ describe('PluginManagerController', () => {
|
||||
install.resolve(ok({ ...failed(), application: 'cancelled' }))
|
||||
})
|
||||
|
||||
it('keeps local package and row metadata in the loaded view without changing technical identities', async () => {
|
||||
const meta = { title: { en: 'Sidebar', zh: '侧栏' }, description: 'Local package', error: 'locale/zh.json: invalid title' }
|
||||
const rowMeta = { title: { en: 'Theme', zh: '主题' }, description: { en: 'Display options', zh: '显示选项' } }
|
||||
const bundle: BundleInfo = {
|
||||
...BUNDLE,
|
||||
meta,
|
||||
rows: BUNDLE.rows.map(row => ({ ...row, meta: rowMeta })),
|
||||
}
|
||||
const { controller, state } = bench({ listBundles: vi.fn().mockResolvedValue(ok([bundle])) })
|
||||
|
||||
await controller.load()
|
||||
|
||||
expect(state().packages[0]).toMatchObject({ name: BUNDLE.name, meta })
|
||||
expect(state().packages[0]!.meta).toBe(meta)
|
||||
expect(state().packages[0]!.rows[0]).toMatchObject({ rowId: 'sidebar', moduleName: BUNDLE.name, entryId: ROW_ENTRY, meta: rowMeta })
|
||||
expect(state().packages[0]!.rows[0]!.meta).toBe(rowMeta)
|
||||
expect(state().packages[0]!.error).toBeUndefined()
|
||||
})
|
||||
|
||||
it('starts idle, reads the inventory then the bundles and entries on first use, and folds concurrent loads', async () => {
|
||||
const gate = deferred<ReturnType<typeof ok<BundleInfo[]>>>()
|
||||
const { plugins, inventory, face, state, controller } = bench({
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../util/package-manifest"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-package-manifest": "workspace:^",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/react": "~18.3.1",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
|
||||
+40
-12
@@ -2,6 +2,7 @@ import { useEffect, useId, useMemo, useState, type ReactNode } from 'react'
|
||||
import type { ClientEntryState } from '@deepseek-ai/dsh-client-modules/client'
|
||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import type { PluginInventorySnapshot } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { LocalizedText } from '@deepseek-ai/dsh-package-manifest'
|
||||
import {
|
||||
IconChevronDownOutlineRegular,
|
||||
IconSearchOutlineRegular,
|
||||
@@ -20,6 +21,8 @@ type AgentPresetRow = AgentPresetGroup['rows'][number]
|
||||
|
||||
/** Registration-side Remote face used by the section. */
|
||||
export interface PluginInventorySettingsTabInjected {
|
||||
/** Resolve local package text in the current Client locale at render time. */
|
||||
resolveText: (text: LocalizedText) => string
|
||||
/** Page-local module synchronization, independent from the Host inventory. */
|
||||
hooks: { clientSync: ObservableSnapshot<ClientEntryState> }
|
||||
/** Retry the latest client graph without changing the Host composition. */
|
||||
@@ -60,7 +63,7 @@ function phaseLabel(phase: PluginFiberPhase, t: Translate): string {
|
||||
return phase === null ? t('unobserved') : t(PHASE_KEYS[phase])
|
||||
}
|
||||
|
||||
/** Compact a module specifier without guessing whether its Loader id was generated. */
|
||||
/** Compact technical names for Settings without changing their module identity. */
|
||||
function moduleShortName(moduleName: string): string {
|
||||
const unscoped = moduleName.startsWith('@') ? moduleName.slice(moduleName.indexOf('/') + 1) : moduleName
|
||||
return unscoped
|
||||
@@ -74,11 +77,21 @@ function entrySubtitle(entryId: string): string {
|
||||
return entryId.replace(/^include:/, '')
|
||||
}
|
||||
|
||||
/** Whether one row's module name or entry id matches the catalog query. */
|
||||
function matches(moduleName: string, entryId: string | null, normalizedQuery: string): boolean {
|
||||
/** Preserve translated titles and shorten literal package or module name fallbacks in Settings. */
|
||||
function pluginText(row: PluginInventoryEntry | AgentPresetRow, resolveText: PluginInventorySettingsTabInjected['resolveText']) {
|
||||
const title = row.meta?.title
|
||||
return {
|
||||
title: typeof title === 'object' ? resolveText(title) : moduleShortName(title ?? row.moduleName),
|
||||
description: row.meta?.description === undefined ? undefined : resolveText(row.meta.description) || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/** Match translated text alongside the row's technical module and entry identities. */
|
||||
function matches(row: PluginInventoryEntry | AgentPresetRow, normalizedQuery: string, resolveText: PluginInventorySettingsTabInjected['resolveText']): boolean {
|
||||
if (normalizedQuery.length === 0) return true
|
||||
return [moduleName, ...entryId === null ? [] : [entryId]]
|
||||
.some(value => value.toLocaleLowerCase().includes(normalizedQuery))
|
||||
const { title, description } = pluginText(row, resolveText)
|
||||
return [row.moduleName, row.entryId, title, description]
|
||||
.some(value => value?.toLocaleLowerCase().includes(normalizedQuery))
|
||||
}
|
||||
|
||||
/** The roster row shown when the preset switcher has no explicit choice. */
|
||||
@@ -95,9 +108,14 @@ function presetLabel(preset: AgentPresetGroup, t: Translate, presetName: (preset
|
||||
}
|
||||
|
||||
/** One expandable plugin card; the caller owns the trailing status content. */
|
||||
function PluginCard({ rowKey, moduleName, entryId, trailing, ariaLabel, failed, expanded, onToggle, children }: {
|
||||
function PluginCard({
|
||||
rowKey, moduleName, title, description, metadataError, entryId, trailing, ariaLabel, failed, expanded, onToggle, children,
|
||||
}: {
|
||||
readonly rowKey: string
|
||||
readonly moduleName: string
|
||||
readonly title: string
|
||||
readonly description: string | undefined
|
||||
readonly metadataError: string | undefined
|
||||
readonly entryId: string | null
|
||||
readonly trailing: ReactNode
|
||||
readonly ariaLabel: string
|
||||
@@ -108,6 +126,7 @@ function PluginCard({ rowKey, moduleName, entryId, trailing, ariaLabel, failed,
|
||||
}): ReactNode {
|
||||
const open = expanded === rowKey
|
||||
const detailId = `plugin-details-${encodeURIComponent(rowKey)}`
|
||||
const descriptionId = useId()
|
||||
return (
|
||||
<li
|
||||
className={css.card}
|
||||
@@ -122,17 +141,20 @@ function PluginCard({ rowKey, moduleName, entryId, trailing, ariaLabel, failed,
|
||||
aria-expanded={open}
|
||||
aria-controls={detailId}
|
||||
aria-label={ariaLabel}
|
||||
aria-describedby={description === undefined ? undefined : descriptionId}
|
||||
onClick={() => { onToggle(rowKey) }}
|
||||
>
|
||||
<span className={css.cardMainRow}>
|
||||
<strong className={css.cardTitle} title={moduleName}>{moduleShortName(moduleName)}</strong>
|
||||
<strong className={css.cardTitle} title={moduleName}>{title}</strong>
|
||||
<span className={css.cardTrailing}>
|
||||
{trailing}
|
||||
<IconChevronDownOutlineRegular className={css.chevron} size={12} aria-hidden="true" />
|
||||
</span>
|
||||
</span>
|
||||
{description === undefined ? null : <span className={css.hint} id={descriptionId}>{description}</span>}
|
||||
{entryId === null ? null : <code className={css.cardIdentity} title={entryId}>{entrySubtitle(entryId)}</code>}
|
||||
</button>
|
||||
{metadataError === undefined ? null : <p className={css.brokenNote} role="status" data-package-meta-error>{metadataError}</p>}
|
||||
{open ? <div className={css.cardDetails} id={detailId}>{children}</div> : null}
|
||||
</li>
|
||||
)
|
||||
@@ -212,7 +234,7 @@ function StateTag({ kind, label }: { readonly kind: EnablementKind; readonly lab
|
||||
|
||||
/** Render the read-only plugin inventory: agent presets first, then the global plane. */
|
||||
export function PluginInventorySettingsTab(
|
||||
{ list, presetName, t, useClientSync, retryClient }: PluginInventorySettingsTabProps,
|
||||
{ list, presetName, resolveText, t, useClientSync, retryClient }: PluginInventorySettingsTabProps,
|
||||
): ReactNode {
|
||||
const clientSync = useClientSync(snapshot => snapshot)
|
||||
const sectionId = useId()
|
||||
@@ -262,8 +284,8 @@ export function PluginInventorySettingsTab(
|
||||
else regularEntries.push(entry)
|
||||
}
|
||||
|
||||
const entryMatch = (entry: PluginInventoryEntry): boolean => matches(entry.moduleName, entry.entryId, normalizedQuery)
|
||||
const rowMatch = (row: AgentPresetRow): boolean => matches(row.moduleName, row.entryId, normalizedQuery)
|
||||
const entryMatch = (entry: PluginInventoryEntry): boolean => matches(entry, normalizedQuery, resolveText)
|
||||
const rowMatch = (row: AgentPresetRow): boolean => matches(row, normalizedQuery, resolveText)
|
||||
const filteredFailed = failedEntries.filter(entryMatch)
|
||||
const filteredRegular = regularEntries.filter(entryMatch)
|
||||
const globalCount = filteredFailed.length + filteredRegular.length
|
||||
@@ -291,7 +313,7 @@ export function PluginInventorySettingsTab(
|
||||
/** Trailing status and detail facts for one row of the selected preset. */
|
||||
const presetRowCard = (preset: AgentPresetGroup, row: AgentPresetRow, index: number): ReactNode => {
|
||||
const key = `preset:${preset.id}:${String(index)}`
|
||||
const title = moduleShortName(row.moduleName)
|
||||
const { title, description } = pluginText(row, resolveText)
|
||||
const failed = row.fiberPhase === 'failed'
|
||||
const stateText = failed
|
||||
? t('failedTag')
|
||||
@@ -302,6 +324,9 @@ export function PluginInventorySettingsTab(
|
||||
key={key}
|
||||
rowKey={key}
|
||||
moduleName={row.moduleName}
|
||||
title={title}
|
||||
description={description}
|
||||
metadataError={row.meta?.error === undefined ? undefined : t('metadataError', { error: row.meta.error })}
|
||||
entryId={row.entryId}
|
||||
failed={failed}
|
||||
expanded={expanded}
|
||||
@@ -337,7 +362,7 @@ export function PluginInventorySettingsTab(
|
||||
providers?: readonly [AgentPresetGroup, ...AgentPresetGroup[]],
|
||||
): ReactNode => {
|
||||
const key = `global:${entry.entryId}`
|
||||
const title = moduleShortName(entry.moduleName)
|
||||
const { title, description } = pluginText(entry, resolveText)
|
||||
const failed = entry.fiberPhase === 'failed'
|
||||
const stateText = failed
|
||||
? t('failedTag')
|
||||
@@ -348,6 +373,9 @@ export function PluginInventorySettingsTab(
|
||||
key={key}
|
||||
rowKey={key}
|
||||
moduleName={entry.moduleName}
|
||||
title={title}
|
||||
description={description}
|
||||
metadataError={entry.meta?.error === undefined ? undefined : t('metadataError', { error: entry.meta.error })}
|
||||
entryId={entry.entryId}
|
||||
failed={failed}
|
||||
expanded={expanded}
|
||||
|
||||
@@ -48,6 +48,7 @@ export function apply(ctx: ClientContext): void {
|
||||
presetDisplayText(preset, agentPresetCopy).name
|
||||
const injected = (): PluginInventorySettingsTabInjected => ({
|
||||
list, presetName,
|
||||
resolveText: text => ctx.locale.resolveText(text),
|
||||
hooks: { clientSync: ctx.modules.entries.state },
|
||||
retryClient: () => { void ctx.modules.entries.retry().catch((error: unknown) => { ctx.logger.error(error) }) },
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ export const zh = {
|
||||
clientSyncFailed: '本页面的插件未能完成同步;服务端的启用状态保持不变。',
|
||||
clientSyncRetry: '重试本页面同步',
|
||||
error: '暂时无法读取插件。',
|
||||
metadataError: '包元信息错误:{error}',
|
||||
retry: '重试',
|
||||
search: '搜索插件',
|
||||
empty: '暂无插件。',
|
||||
@@ -54,6 +55,7 @@ export const en = {
|
||||
clientSyncFailed: 'Some plugins could not sync on this page. Host enablement is unchanged.',
|
||||
clientSyncRetry: 'Retry this page',
|
||||
error: 'Plugins are temporarily unavailable.',
|
||||
metadataError: 'Package metadata error: {error}',
|
||||
retry: 'Retry',
|
||||
search: 'Search plugins',
|
||||
empty: 'No plugins are available.',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { ClientModuleLoader } from '@deepseek-ai/dsh-client-modules/client'
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
@@ -23,6 +23,7 @@ type ListResult =
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
onTestFinished(async () => { await ctx.fiber.dispose() })
|
||||
const retryClient = vi.fn(async () => {})
|
||||
ctx.provide('modules', { entries: { state: createSnapshotStore({ syncing: false, failures: [] }), retry: retryClient } } as unknown as ClientModuleLoader)
|
||||
await ctx.plugin(SlotRegistry).await()
|
||||
@@ -69,6 +70,11 @@ describe('ui-settings-plugin-inventory browser plugin', () => {
|
||||
expect(b.list).not.toHaveBeenCalled()
|
||||
|
||||
const injected = (entry.inject as unknown as () => PluginInventorySettingsTabInjected)()
|
||||
const text = { en: 'Local tools', zh: '本地工具' }
|
||||
expect(injected.resolveText(text)).toBe('本地工具')
|
||||
b.locale.setLocale('en')
|
||||
expect(injected.resolveText(text)).toBe('Local tools')
|
||||
b.locale.setLocale('zh')
|
||||
injected.retryClient()
|
||||
const retryError = vi.spyOn(b.ctx.logger, 'error').mockImplementation(() => {})
|
||||
b.retryClient.mockRejectedValueOnce(new Error('retry unavailable'))
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
// @vitest-environment jsdom
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { ClientEntryState } from '@deepseek-ai/dsh-client-modules/client'
|
||||
import type { PluginEntryId } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { PluginInventorySettingsTab } from '../src/client/PluginInventorySettingsTab.tsx'
|
||||
import type {
|
||||
PluginInventorySettingsTabInjected,
|
||||
PluginInventorySettingsTabProps,
|
||||
} from '../src/client/PluginInventorySettingsTab.tsx'
|
||||
import { en, type PluginInventoryLocaleKey } from '../src/client/locales.ts'
|
||||
import { en, zh, type PluginInventoryLocaleKey } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -23,16 +26,32 @@ const t = ((key: PluginInventoryLocaleKey, params?: Record<string, string>): str
|
||||
function props(
|
||||
list: PluginInventorySettingsTabInjected['list'],
|
||||
presetName: PluginInventorySettingsTabInjected['presetName'] = preset => preset.name ?? preset.id,
|
||||
resolveText: PluginInventorySettingsTabInjected['resolveText'] = text => typeof text === 'string' ? text : text.en,
|
||||
): PluginInventorySettingsTabProps {
|
||||
return {
|
||||
t,
|
||||
list,
|
||||
presetName,
|
||||
resolveText,
|
||||
useClientSync: bindSnapshotSelector(createSnapshotStore<ClientEntryState>({ syncing: false, failures: [] })),
|
||||
retryClient: vi.fn(),
|
||||
} as PluginInventorySettingsTabProps
|
||||
}
|
||||
|
||||
function localizedProps(list: PluginInventorySettingsTabInjected['list']) {
|
||||
const ctx = new Context()
|
||||
onTestFinished(async () => { await ctx.fiber.dispose() })
|
||||
const locale = new LocaleRuntime(ctx)
|
||||
ctx.effect(() => locale.register('settings.pluginInventory', 'en', en), 'test: English inventory dictionary')
|
||||
ctx.effect(() => locale.register('settings.pluginInventory', 'zh', zh), 'test: Chinese inventory dictionary')
|
||||
locale.setLocale('en')
|
||||
const pageProps: PluginInventorySettingsTabProps = {
|
||||
...props(list, preset => preset.name ?? preset.id, text => locale.resolveText(text)),
|
||||
t: locale.bind('settings.pluginInventory'),
|
||||
}
|
||||
return { locale, pageProps }
|
||||
}
|
||||
|
||||
/** A deployment with a roster: one failed global row, two preset-provided rows. */
|
||||
const SNAPSHOT = {
|
||||
entries: [
|
||||
@@ -90,6 +109,208 @@ const globalToggle = (): HTMLElement =>
|
||||
const presetToggle = (): HTMLElement => screen.getByRole('button', { name: en.presetTitle })
|
||||
|
||||
describe('PluginInventorySettingsTab', () => {
|
||||
it.each(['global', 'preset'])('shortens package and module name fallbacks in the %s inventory', async (scope) => {
|
||||
const names = [
|
||||
['@deepseek-ai/dsh-tool-subagent', 'tool-subagent'],
|
||||
['@deepseek-ai/dsh-host-web', 'web'],
|
||||
['@deepseek-ai/dsh-client-tabs', 'tabs'],
|
||||
['@deepseek-ai/cordis-plugin-hmr', 'hmr'],
|
||||
['cordis:timer', 'timer'],
|
||||
['@acme/dsh-sidebar/navigation', 'sidebar/navigation'],
|
||||
['plain-plugin', 'plain-plugin'],
|
||||
] as const
|
||||
const rows = names.flatMap(([moduleName], index) => [false, true].map(fromManifest => ({
|
||||
entryId: `include:short-${String(index)}-${String(fromManifest)}` as PluginEntryId,
|
||||
moduleName,
|
||||
enabled: true,
|
||||
fiberPhase: null,
|
||||
...fromManifest ? { meta: { title: moduleName, description: 'Package description.' } } : {},
|
||||
})))
|
||||
const snapshot: Snapshot = scope === 'global'
|
||||
? { entries: rows }
|
||||
: { entries: [], agentPresets: [{ id: 'custom', trust: 'user', isDefault: true, rows }] }
|
||||
const view = await renderReady(snapshot)
|
||||
fireEvent.click(scope === 'global' ? globalToggle() : presetToggle())
|
||||
for (const [index, [moduleName, title]] of names.entries()) {
|
||||
for (const fromManifest of [false, true]) {
|
||||
const entryId = `include:short-${String(index)}-${String(fromManifest)}`
|
||||
const card = screen.getByRole('button', { name: `${title}, ${entryId}, Enabled` })
|
||||
if (fromManifest) {
|
||||
expect(document.getElementById(card.getAttribute('aria-describedby')!)?.textContent).toBe('Package description.')
|
||||
}
|
||||
fireEvent.click(card)
|
||||
expect(screen.getByText(en.moduleLabel).nextElementSibling?.textContent).toBe(moduleName)
|
||||
expect(view.container.querySelector('[data-loader-entry]')?.textContent).toBe(entryId)
|
||||
fireEvent.click(card)
|
||||
}
|
||||
}
|
||||
fireEvent.change(screen.getByRole('searchbox', { name: en.search }), {
|
||||
target: { value: '@deepseek-ai/dsh-tool-subagent' },
|
||||
})
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(2)
|
||||
expect(screen.getAllByRole('button', { name: /^tool-subagent, include:short-0-/ })).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps authored locale titles verbatim when they resemble module names', async () => {
|
||||
const { locale, pageProps } = localizedProps(async () => ({
|
||||
entries: [{
|
||||
entryId: 'include:navigation' as PluginEntryId, moduleName: '@acme/dsh-navigation', enabled: true, fiberPhase: null,
|
||||
meta: { title: { en: 'dsh-Navigation', zh: 'dsh-导航' } },
|
||||
}],
|
||||
}))
|
||||
const view = render(<PluginInventorySettingsTab {...pageProps} />)
|
||||
await screen.findByRole('searchbox', { name: en.search })
|
||||
fireEvent.click(globalToggle())
|
||||
expect(screen.getByRole('button', { name: 'dsh-Navigation, include:navigation, Enabled' })).toBeTruthy()
|
||||
locale.setLocale('zh')
|
||||
view.rerender(<PluginInventorySettingsTab {...pageProps} />)
|
||||
expect(screen.getByRole('button', { name: 'dsh-导航, include:navigation, 已启用' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('localizes global and preset metadata at render time while preserving identities and query state', async () => {
|
||||
const globalError = 'locale/zh.json: invalid title'
|
||||
const presetError = 'locale/fr.json: invalid description'
|
||||
const snapshot: Snapshot = {
|
||||
entries: [{
|
||||
entryId: 'include:global-navigation' as PluginEntryId, moduleName: '@acme/navigation', enabled: true, fiberPhase: 'active',
|
||||
meta: {
|
||||
title: { en: 'Navigation', zh: '导航' },
|
||||
description: { en: 'Global navigation controls', zh: '全局导航控件' },
|
||||
error: globalError,
|
||||
},
|
||||
}],
|
||||
agentPresets: [{
|
||||
id: 'custom', trust: 'user', name: 'My preset', isDefault: true,
|
||||
rows: [{
|
||||
entryId: 'include:preset-runner', moduleName: '@acme/runner', enabled: true, fiberPhase: null,
|
||||
meta: {
|
||||
title: { en: 'Session runner', zh: '会话执行器' },
|
||||
description: { en: 'Run per session', zh: '运行会话命令' },
|
||||
error: presetError,
|
||||
},
|
||||
}],
|
||||
}],
|
||||
}
|
||||
const list = vi.fn(async () => snapshot)
|
||||
const { locale, pageProps } = localizedProps(list)
|
||||
const view = render(<PluginInventorySettingsTab {...pageProps} />)
|
||||
await screen.findByRole('searchbox', { name: en.search })
|
||||
fireEvent.click(globalToggle())
|
||||
fireEvent.click(presetToggle())
|
||||
const global = screen.getByRole('button', { name: 'Navigation, include:global-navigation, Enabled' })
|
||||
const preset = screen.getByRole('button', { name: 'Session runner, include:preset-runner, Enabled' })
|
||||
expect(document.getElementById(global.getAttribute('aria-describedby')!)?.textContent).toBe('Global navigation controls')
|
||||
expect(document.getElementById(preset.getAttribute('aria-describedby')!)?.textContent).toBe('Run per session')
|
||||
expect(screen.getByText(en.metadataError.replace('{error}', globalError))).toBeTruthy()
|
||||
expect(screen.getByText(en.metadataError.replace('{error}', presetError))).toBeTruthy()
|
||||
expect(global.closest('li')?.getAttribute('data-failed')).toBeNull()
|
||||
fireEvent.click(global)
|
||||
expect(screen.getByText(en.moduleLabel).nextElementSibling?.textContent).toBe('@acme/navigation')
|
||||
fireEvent.click(preset)
|
||||
expect(screen.getByText(en.moduleLabel).nextElementSibling?.textContent).toBe('@acme/runner')
|
||||
expect(view.container.querySelector('[data-loader-entry]')?.textContent).toBe('include:preset-runner')
|
||||
|
||||
locale.setLocale('zh')
|
||||
view.rerender(<PluginInventorySettingsTab {...pageProps} />)
|
||||
expect(screen.getByRole('button', { name: '导航, include:global-navigation, 已启用' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: '会话执行器, include:preset-runner, 已启用' })).toBeTruthy()
|
||||
expect(screen.getByText('全局导航控件')).toBeTruthy()
|
||||
expect(screen.getByText('运行会话命令')).toBeTruthy()
|
||||
expect(screen.getByText(zh.metadataError.replace('{error}', presetError))).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-loader-entry]')?.textContent).toBe('include:preset-runner')
|
||||
const search = screen.getByRole('searchbox', { name: zh.search })
|
||||
for (const query of ['导航', '全局导航控件', '@acme/navigation', 'include:global-navigation']) {
|
||||
fireEvent.change(search, { target: { value: query } })
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(1)
|
||||
expect(screen.getByRole('button', { name: '导航, include:global-navigation, 已启用' })).toBeTruthy()
|
||||
}
|
||||
fireEvent.change(search, { target: { value: '运行会话命令' } })
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(1)
|
||||
expect(screen.getByRole('button', { name: '会话执行器, include:preset-runner, 已启用' })).toBeTruthy()
|
||||
fireEvent.change(search, { target: { value: 'Run per session' } })
|
||||
expect(screen.getByText(zh.emptySearch)).toBeTruthy()
|
||||
locale.setLocale('en')
|
||||
view.rerender(<PluginInventorySettingsTab {...pageProps} />)
|
||||
expect(screen.getByRole('button', { name: 'Session runner, include:preset-runner, Enabled' })).toBeTruthy()
|
||||
expect(screen.getByRole('searchbox', { name: en.search })).toHaveProperty('value', 'Run per session')
|
||||
expect(list).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each(['global', 'preset'])('resolves each %s row field independently and hides empty English descriptions', async (scope) => {
|
||||
const rows: Snapshot['entries'] = [
|
||||
{
|
||||
entryId: 'include:navigation' as PluginEntryId, moduleName: '@acme/dsh-sidebar/navigation', enabled: true, fiberPhase: null,
|
||||
meta: { description: { en: 'Navigation description.' } },
|
||||
},
|
||||
{
|
||||
entryId: 'include:commands' as PluginEntryId, moduleName: '@acme/dsh-sidebar/commands', enabled: true, fiberPhase: null,
|
||||
meta: { title: { en: 'English title' }, description: { en: 'Package description.', zh: '中文命令说明。' } },
|
||||
},
|
||||
{
|
||||
entryId: 'include:theme' as PluginEntryId, moduleName: '@acme/dsh-theme/client', enabled: true, fiberPhase: null,
|
||||
meta: { title: { en: '@acme/dsh-theme', zh: '主题插件' }, description: { en: '', zh: '中文主题说明。' } },
|
||||
},
|
||||
]
|
||||
const snapshot: Snapshot = scope === 'global'
|
||||
? { entries: rows }
|
||||
: { entries: [], agentPresets: [{ id: 'custom', trust: 'user', isDefault: true, rows }] }
|
||||
const list = vi.fn(async () => snapshot)
|
||||
const { locale, pageProps } = localizedProps(list)
|
||||
const view = render(<PluginInventorySettingsTab {...pageProps} />)
|
||||
await screen.findByRole('searchbox', { name: en.search })
|
||||
fireEvent.click(scope === 'global' ? globalToggle() : presetToggle())
|
||||
expect(screen.getByRole('button', { name: 'sidebar/navigation, include:navigation, Enabled' })).toBeTruthy()
|
||||
expect(screen.getByText('Navigation description.')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'English title, include:commands, Enabled' })).toBeTruthy()
|
||||
expect(screen.getByText('Package description.')).toBeTruthy()
|
||||
const theme = screen.getByRole('button', { name: '@acme/dsh-theme, include:theme, Enabled' })
|
||||
expect(theme.getAttribute('aria-describedby')).toBeNull()
|
||||
expect(screen.queryByText('中文主题说明。')).toBeNull()
|
||||
|
||||
locale.setLocale('zh')
|
||||
view.rerender(<PluginInventorySettingsTab {...pageProps} />)
|
||||
for (const [name, description] of [
|
||||
['sidebar/navigation, include:navigation, 已启用', 'Navigation description.'],
|
||||
['English title, include:commands, 已启用', '中文命令说明。'],
|
||||
['主题插件, include:theme, 已启用', '中文主题说明。'],
|
||||
] as const) {
|
||||
const card = screen.getByRole('button', { name })
|
||||
expect(screen.getByText(description)).toBeTruthy()
|
||||
expect(document.getElementById(card.getAttribute('aria-describedby')!)?.textContent).toBe(description)
|
||||
}
|
||||
expect(screen.queryByText('Package description.')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: '主题插件, include:theme, 已启用' }))
|
||||
expect(view.container.querySelector('[data-loader-entry]')?.textContent).toBe('include:theme')
|
||||
expect(screen.getByText(zh.moduleLabel).nextElementSibling?.textContent).toBe('@acme/dsh-theme/client')
|
||||
|
||||
fireEvent.change(screen.getByRole('searchbox', { name: zh.search }), { target: { value: '中文主题说明。' } })
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(1)
|
||||
locale.setLocale('en')
|
||||
view.rerender(<PluginInventorySettingsTab {...pageProps} />)
|
||||
expect(screen.getByText(en.emptySearch)).toBeTruthy()
|
||||
fireEvent.change(screen.getByRole('searchbox', { name: en.search }), { target: { value: '@acme/dsh-theme/client' } })
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(1)
|
||||
expect(screen.getByRole('button', { name: '@acme/dsh-theme, include:theme, Enabled' }).getAttribute('aria-describedby')).toBeNull()
|
||||
expect(list).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps metadata-error-only rows inspectable by their full module specifier', async () => {
|
||||
const error = 'locale/zh.json: invalid title'
|
||||
await renderReady({
|
||||
entries: [{
|
||||
entryId: 'include:legacy' as PluginEntryId, moduleName: '@acme/dsh-legacy', enabled: false, fiberPhase: null,
|
||||
meta: { error },
|
||||
}],
|
||||
})
|
||||
fireEvent.click(globalToggle())
|
||||
expect(screen.getByText(en.metadataError.replace('{error}', error))).toBeTruthy()
|
||||
const card = screen.getByRole('button', { name: 'legacy, include:legacy, Disabled' })
|
||||
expect(card).toHaveProperty('disabled', false)
|
||||
expect(card.closest('li')?.getAttribute('data-failed')).toBeNull()
|
||||
fireEvent.click(card)
|
||||
expect(screen.getByText(en.moduleLabel).nextElementSibling?.textContent).toBe('@acme/dsh-legacy')
|
||||
})
|
||||
|
||||
it('shows the default preset first with both groups collapsed', async () => {
|
||||
const view = await renderReady()
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../util/package-manifest"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "Agent Teams",
|
||||
"description": "Enable team collaboration, team tools, the member roster, and the shared task board."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "智能体团队",
|
||||
"description": "启用团队协作、团队工具、成员列表和共享任务看板。"
|
||||
}
|
||||
}
|
||||
@@ -19,10 +19,12 @@
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./cordis.patch.yml": "./cordis.patch.yml",
|
||||
"./locale/*.json": "./locale/*.json",
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"locale/*.json",
|
||||
"lib/index.js",
|
||||
"cordis.patch.yml",
|
||||
"lib/types/**/*.d.ts"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "Team Collaboration",
|
||||
"description": "Manage team members, member messages, and shared tasks."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "团队协作",
|
||||
"description": "管理团队成员、成员消息和共享任务。"
|
||||
}
|
||||
}
|
||||
@@ -38,10 +38,12 @@
|
||||
"types": "./lib/typert.remote-client.d.ts",
|
||||
"default": "./lib/typert.remote-client.js"
|
||||
},
|
||||
"./locale/*.json": "./locale/*.json",
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"locale/*.json",
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "Auto Authorization Review",
|
||||
"description": "Add an Auto review permission mode that uses the model to assess authorization before each tool call."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "自动授权审查",
|
||||
"description": "提供自动审查权限模式,由模型在每次工具调用前判断是否授权。"
|
||||
}
|
||||
}
|
||||
@@ -15,11 +15,13 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./locale/*.json": "./locale/*.json",
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json",
|
||||
"./cordis.patch.yml": "./cordis.patch.yml"
|
||||
},
|
||||
"files": [
|
||||
"locale/*.json",
|
||||
"lib/index.js",
|
||||
"cordis.patch.yml",
|
||||
"lib/types/**/*.d.ts"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "Team Panel",
|
||||
"description": "View team members and shared tasks, and navigate to member conversations."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "团队面板",
|
||||
"description": "查看团队成员和共享任务,并切换到成员会话。"
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./locale/*.json": "./locale/*.json",
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
@@ -79,6 +80,7 @@
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"locale/*.json",
|
||||
"lib/index.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "Team Tools",
|
||||
"description": "Give agents tools to coordinate members, exchange messages, and manage shared tasks."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "团队工具",
|
||||
"description": "为智能体提供成员协调、消息通信和共享任务管理工具。"
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,12 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./locale/*.json": "./locale/*.json",
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"locale/*.json",
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
|
||||
Generated
+9
@@ -2471,6 +2471,9 @@ importers:
|
||||
'@deepseek-ai/dsh-client-ui-slots':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-slots
|
||||
'@deepseek-ai/dsh-package-manifest':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/package-manifest
|
||||
'@deepseek-ai/dsh-settings':
|
||||
specifier: workspace:^
|
||||
version: link:../../settings/settings
|
||||
@@ -3767,6 +3770,9 @@ importers:
|
||||
'@deepseek-ai/dsh-client-ui-slots':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-slots
|
||||
'@deepseek-ai/dsh-package-manifest':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/package-manifest
|
||||
'@deepseek-ai/dsh-plugin-manager':
|
||||
specifier: workspace:^
|
||||
version: link:../../boot/plugin-manager
|
||||
@@ -4210,6 +4216,9 @@ importers:
|
||||
'@deepseek-ai/dsh-client-ui-slots':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-slots
|
||||
'@deepseek-ai/dsh-package-manifest':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/package-manifest
|
||||
'@testing-library/react':
|
||||
specifier: ^16.1.0
|
||||
version: 16.3.2(@testing-library/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
|
||||
Reference in new issue
Block a user