diff --git a/apps/web/tests/expected/plugin-config/official.expected.md b/apps/web/tests/expected/plugin-config/official.expected.md index 3b7a533f1a..d062b53002 100644 --- a/apps/web/tests/expected/plugin-config/official.expected.md +++ b/apps/web/tests/expected/plugin-config/official.expected.md @@ -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" diff --git a/apps/web/tests/expected/plugin-config/row.expected.md b/apps/web/tests/expected/plugin-config/row.expected.md index 85b1d52b9e..03308874b2 100644 --- a/apps/web/tests/expected/plugin-config/row.expected.md +++ b/apps/web/tests/expected/plugin-config/row.expected.md @@ -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: 示例配置项 diff --git a/apps/web/tests/expected/plugin-manager/exports-en.expected.md b/apps/web/tests/expected/plugin-manager/exports-en.expected.md new file mode 100644 index 0000000000..74cc1b4c15 --- /dev/null +++ b/apps/web/tests/expected/plugin-manager/exports-en.expected.md @@ -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" diff --git a/apps/web/tests/expected/plugin-manager/exports.expected.md b/apps/web/tests/expected/plugin-manager/exports.expected.md new file mode 100644 index 0000000000..0512893d3e --- /dev/null +++ b/apps/web/tests/expected/plugin-manager/exports.expected.md @@ -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: 已关闭 diff --git a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md index ae850f5daa..92fafe32cb 100644 --- a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md +++ b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md @@ -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] diff --git a/apps/web/tests/expected/plugin-manager/manager.expected.md b/apps/web/tests/expected/plugin-manager/manager.expected.md index a13d1a001e..45658f20cf 100644 --- a/apps/web/tests/expected/plugin-manager/manager.expected.md +++ b/apps/web/tests/expected/plugin-manager/manager.expected.md @@ -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" diff --git a/apps/web/tests/expected/plugin-manager/missing-bundle.expected.md b/apps/web/tests/expected/plugin-manager/missing-bundle.expected.md index 5eec14673b..93ebcf1143 100644 --- a/apps/web/tests/expected/plugin-manager/missing-bundle.expected.md +++ b/apps/web/tests/expected/plugin-manager/missing-bundle.expected.md @@ -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: 这个插件包不包含任何组件。 diff --git a/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md b/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md index 4c654a7971..612291fe84 100644 --- a/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md +++ b/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md @@ -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 diff --git a/apps/web/tests/expected/settings-chrome/plugins.expected.md b/apps/web/tests/expected/settings-chrome/plugins.expected.md index e8536c690a..7cfe33f5f0 100644 --- a/apps/web/tests/expected/settings-chrome/plugins.expected.md +++ b/apps/web/tests/expected/settings-chrome/plugins.expected.md @@ -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 diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/cordis.patch.yml b/apps/web/tests/fixtures/plugins/fixture-bundle/cordis.patch.yml index a3e71146b9..8038d9e378 100644 --- a/apps/web/tests/fixtures/plugins/fixture-bundle/cordis.patch.yml +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/cordis.patch.yml @@ -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' diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/locale/review/en.json b/apps/web/tests/fixtures/plugins/fixture-bundle/locale/review/en.json new file mode 100644 index 0000000000..6da7422b1a --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/locale/review/en.json @@ -0,0 +1,3 @@ +{ + "meta": {} +} diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/locale/review/zh.json b/apps/web/tests/fixtures/plugins/fixture-bundle/locale/review/zh.json new file mode 100644 index 0000000000..90a5b56fb2 --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/locale/review/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "代码审查", + "description": "审查工作区中的改动。" + } +} diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/locale/search/en.json b/apps/web/tests/fixtures/plugins/fixture-bundle/locale/search/en.json new file mode 100644 index 0000000000..e27e893200 --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/locale/search/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "File Search" + } +} diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/locale/search/zh.json b/apps/web/tests/fixtures/plugins/fixture-bundle/locale/search/zh.json new file mode 100644 index 0000000000..44f2e03849 --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/locale/search/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "文件搜索", + "description": "搜索工作区中的文件。" + } +} diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/package.json b/apps/web/tests/fixtures/plugins/fixture-bundle/package.json index 263634fb08..3224dfa296 100644 --- a/apps/web/tests/fixtures/plugins/fixture-bundle/package.json +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/package.json @@ -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" diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/review.js b/apps/web/tests/fixtures/plugins/fixture-bundle/review.js new file mode 100644 index 0000000000..356cb5cf9a --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/review.js @@ -0,0 +1,2 @@ +/** Inert review plugin for exported metadata browser coverage. */ +export function apply() {} diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/search.js b/apps/web/tests/fixtures/plugins/fixture-bundle/search.js new file mode 100644 index 0000000000..c8858a7889 --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/search.js @@ -0,0 +1,2 @@ +/** Inert search plugin for exported metadata browser coverage. */ +export function apply() {} diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/search.package.json b/apps/web/tests/fixtures/plugins/fixture-bundle/search.package.json new file mode 100644 index 0000000000..52a0c0f420 --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/search.package.json @@ -0,0 +1,4 @@ +{ + "name": "Search plugin", + "description": "Search package introduction." +} diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts index 825d95b081..11394b867b 100644 --- a/apps/web/tests/plugin-config.e2e.ts +++ b/apps/web/tests/plugin-config.e2e.ts @@ -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) diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index f2146bc906..373b0ba459 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -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 { + 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() diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index b396d5f969..d84614d33c 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -37,7 +37,10 @@ describe('web e2e: settings modal and General preferences', () => { let tripwire: ReturnType 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). diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index b75b622d52..7a40d48b79 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -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:^", diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index 8370696886..cd1ef5cac1 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -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). diff --git a/packages/client/locale/tests/localized-text.client.spec.ts b/packages/client/locale/tests/localized-text.client.spec.ts new file mode 100644 index 0000000000..a567962ae7 --- /dev/null +++ b/packages/client/locale/tests/localized-text.client.spec.ts @@ -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') + }) +}) diff --git a/packages/client/locale/tsconfig.json b/packages/client/locale/tsconfig.json index 8737cc0c01..0d4fa48c3f 100644 --- a/packages/client/locale/tsconfig.json +++ b/packages/client/locale/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../util/package-manifest" + }, { "path": "../../../vendor/schemastery" }, diff --git a/packages/client/ui-plugin-manager/package.json b/packages/client/ui-plugin-manager/package.json index a5f08aa391..cc7db22e85 100644 --- a/packages/client/ui-plugin-manager/package.json +++ b/packages/client/ui-plugin-manager/package.json @@ -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", diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index a97e31e8ca..5235d53cd0 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -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 ( {t('metadataError', { error })}

+} + /** * 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 (
@@ -216,7 +226,7 @@ function RowsSection({ rows, t, toggle, configure }: { ? null : (
    - {shown.map(row => ( + {shown.map(({ row, title, description }) => (
  • {configure?.has(row) === true ? ( - ) - : {row.rowId}} - {row.moduleName} + : {title}} + {description === undefined ? null : {description}} + {title === row.rowId ? null : {row.rowId}} + {title === row.moduleName ? null : {row.moduleName}}
@@ -242,8 +254,12 @@ function RowsSection({ rows, t, toggle, configure }: { {toggle === undefined ? null - : { toggle.onSetEnabled(row, enabled) }} />} + : { toggle.onSetEnabled(row, enabled) }} + />} + ))} @@ -290,15 +306,16 @@ function CardHead({ title, t, onOpen, icon, tags, description, end }: { readonly description: ReactNode readonly end?: ReactNode }): ReactNode { + const descriptionId = useId() return (
- + {tags}
- {description === undefined ? null : {description}} + {description === undefined ? null : {description}}
{end === undefined ? null :
{end}
}
@@ -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 (
  • } /> +
  • ) } @@ -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 (
    } />
    -

    {row.rowId}

    +

    {rowTitle}

    + {rowTitle === row.rowId ? null :

    {row.rowId}

    }

    {row.moduleName}

    -

    {renderSlot('plugins.row.config', { view: 'summary' }, { entryKey: key })}

    +

    {description ?? renderSlot('plugins.row.config', { view: 'summary' }, { entryKey: key })}

    +
    {renderSlot('plugins.row.config', { view: 'page' }, { entryKey: key })}
    @@ -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 (
    @@ -497,8 +521,9 @@ function PackageDetail({ {status === 'problem' ? {t('statusProblem')} : null}

    {pkg.name}

    -

    {description ?? t('noDescription')}

    + {description === undefined ? null :

    {description}

    }
    + {pkg.error === undefined ? null :

    {t('reasonLabel')}: {managementText(pkg.error, t)}

    } {pkg.readOnlyReason === undefined ? null :

    {managementText({ code: pkg.readOnlyReason }, t)}

    }
    @@ -512,6 +537,7 @@ function PackageDetail({ 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 ( 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 { 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 : ( pkg.name === state.confirm?.packageName) ?? { name: state.confirm.packageName }, + resolveText, + ).title} t={t} onConfirm={props.confirm} onCancel={props.cancelConfirm} diff --git a/packages/client/ui-plugin-manager/src/client/index.ts b/packages/client/ui-plugin-manager/src/client/index.ts index 1e2c9a60b1..f3394b69cc 100644 --- a/packages/client/ui-plugin-manager/src/client/index.ts +++ b/packages/client/ui-plugin-manager/src/client/index.ts @@ -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' }, diff --git a/packages/client/ui-plugin-manager/src/client/locales.ts b/packages/client/ui-plugin-manager/src/client/locales.ts index 74d367aec6..30116aa2bf 100644 --- a/packages/client/ui-plugin-manager/src/client/locales.ts +++ b/packages/client/ui-plugin-manager/src/client/locales.ts @@ -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', diff --git a/packages/client/ui-plugin-manager/src/client/manager-store.ts b/packages/client/ui-plugin-manager/src/client/manager-store.ts index 533893caa8..2113612733 100644 --- a/packages/client/ui-plugin-manager/src/client/manager-store.ts +++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts @@ -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 @@ -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): PluginManagerFace { + inject(configLedger: HostObservable, resolveText: PluginManagerFace['resolveText']): PluginManagerFace { return { + resolveText, hooks: { pluginManager: this.store, configLedger }, ensure: () => { if (this.getSnapshot().status === 'idle') void this.load() }, refresh: () => { void this.load() }, diff --git a/packages/client/ui-plugin-manager/src/client/presentation.ts b/packages/client/ui-plugin-manager/src/client/presentation.ts index d3446a13db..c76633ff97 100644 --- a/packages/client/ui-plugin-manager/src/client/presentation.ts +++ b/packages/client/ui-plugin-manager/src/client/presentation.ts @@ -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([ - ['@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, t: Translate, + pkg: Pick, 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, 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, + } } /** diff --git a/packages/client/ui-plugin-manager/src/client/slot-contract.ts b/packages/client/ui-plugin-manager/src/client/slot-contract.ts index 2fd0b41d40..89a9b52115 100644 --- a/packages/client/ui-plugin-manager/src/client/slot-contract.ts +++ b/packages/client/ui-plugin-manager/src/client/slot-contract.ts @@ -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 * `#` 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 } } diff --git a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx index d3825fc6a0..60194747fe 100644 --- a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx @@ -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' }]) diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index 22e5d51828..0737596fb5 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -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 ReactNode> const NO_CONFIG: ConfigLedger = { items: [], bundles: new Set(), rows: new Set() } function renderTab(state: Partial = {}, config: Partial = {}, 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({ ...READY, ...state }) const ledger = createSnapshotStore({ ...NO_CONFIG, ...config }) const actions = { @@ -96,6 +103,7 @@ function renderTab(state: Partial = {}, config: Partial = {}, config: Partial) => { act(() => { store.set({ ...store.getSnapshot(), ...next }) }) }, - setLanguage: (dict: typeof en) => { rerender() }, + setLanguage: (dict: typeof en) => { + locale.setLocale(dict === zh ? 'zh' : 'en') + rerender() + }, } } @@ -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 + 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' ?
    : 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' ? : 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' : }, + ) + 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.' : , @@ -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(`[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) }) diff --git a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts index e60bd80ee8..939d54fbf7 100644 --- a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts +++ b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts @@ -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>> = {} } 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 => { @@ -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>>() const { plugins, inventory, face, state, controller } = bench({ diff --git a/packages/client/ui-plugin-manager/tsconfig.json b/packages/client/ui-plugin-manager/tsconfig.json index 3cd74d0ad4..931e716c86 100644 --- a/packages/client/ui-plugin-manager/tsconfig.json +++ b/packages/client/ui-plugin-manager/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../util/package-manifest" + }, { "path": "../../../vendor/cordis" }, diff --git a/packages/client/ui-settings-plugin-inventory/package.json b/packages/client/ui-settings-plugin-inventory/package.json index 8915a02331..1f98f2a539 100644 --- a/packages/client/ui-settings-plugin-inventory/package.json +++ b/packages/client/ui-settings-plugin-inventory/package.json @@ -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:^", diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx index 50cb060116..c5064e5441 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx @@ -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 } /** 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 (
  • { onToggle(rowKey) }} > - {moduleShortName(moduleName)} + {title} {trailing} + {description === undefined ? null : {description}} {entryId === null ? null : {entrySubtitle(entryId)}} + {metadataError === undefined ? null :

    {metadataError}

    } {open ?
    {children}
    : null}
  • ) @@ -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} diff --git a/packages/client/ui-settings-plugin-inventory/src/client/index.ts b/packages/client/ui-settings-plugin-inventory/src/client/index.ts index 026f7da686..7d7117554f 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/index.ts +++ b/packages/client/ui-settings-plugin-inventory/src/client/index.ts @@ -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) }) }, }) diff --git a/packages/client/ui-settings-plugin-inventory/src/client/locales.ts b/packages/client/ui-settings-plugin-inventory/src/client/locales.ts index 11e8986eef..e4063d238c 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/locales.ts +++ b/packages/client/ui-settings-plugin-inventory/src/client/locales.ts @@ -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.', diff --git a/packages/client/ui-settings-plugin-inventory/tests/browser-plugin.client.spec.tsx b/packages/client/ui-settings-plugin-inventory/tests/browser-plugin.client.spec.tsx index 0dcaa5758a..22b7e218ae 100644 --- a/packages/client/ui-settings-plugin-inventory/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-settings-plugin-inventory/tests/browser-plugin.client.spec.tsx @@ -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')) diff --git a/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx b/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx index 7c727f260e..de7274e7a5 100644 --- a/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx @@ -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): 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({ 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() diff --git a/packages/client/ui-settings-plugin-inventory/tsconfig.json b/packages/client/ui-settings-plugin-inventory/tsconfig.json index 1a8569048d..491b34792f 100644 --- a/packages/client/ui-settings-plugin-inventory/tsconfig.json +++ b/packages/client/ui-settings-plugin-inventory/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../util/package-manifest" + }, { "path": "../../../vendor/cordis" }, diff --git a/packages/experimental/agent-team-profile/locale/en.json b/packages/experimental/agent-team-profile/locale/en.json new file mode 100644 index 0000000000..b69c01b6c7 --- /dev/null +++ b/packages/experimental/agent-team-profile/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Agent Teams", + "description": "Enable team collaboration, team tools, the member roster, and the shared task board." + } +} diff --git a/packages/experimental/agent-team-profile/locale/zh.json b/packages/experimental/agent-team-profile/locale/zh.json new file mode 100644 index 0000000000..4943f297e7 --- /dev/null +++ b/packages/experimental/agent-team-profile/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "智能体团队", + "description": "启用团队协作、团队工具、成员列表和共享任务看板。" + } +} diff --git a/packages/experimental/agent-team-profile/package.json b/packages/experimental/agent-team-profile/package.json index a8b8967147..73999ee5d4 100644 --- a/packages/experimental/agent-team-profile/package.json +++ b/packages/experimental/agent-team-profile/package.json @@ -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" diff --git a/packages/experimental/agent-team/locale/en.json b/packages/experimental/agent-team/locale/en.json new file mode 100644 index 0000000000..5d93c64811 --- /dev/null +++ b/packages/experimental/agent-team/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Team Collaboration", + "description": "Manage team members, member messages, and shared tasks." + } +} diff --git a/packages/experimental/agent-team/locale/zh.json b/packages/experimental/agent-team/locale/zh.json new file mode 100644 index 0000000000..5c27bbbf75 --- /dev/null +++ b/packages/experimental/agent-team/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "团队协作", + "description": "管理团队成员、成员消息和共享任务。" + } +} diff --git a/packages/experimental/agent-team/package.json b/packages/experimental/agent-team/package.json index 8a1ebd621d..db127f9f7e 100644 --- a/packages/experimental/agent-team/package.json +++ b/packages/experimental/agent-team/package.json @@ -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", diff --git a/packages/experimental/auto-review/locale/en.json b/packages/experimental/auto-review/locale/en.json new file mode 100644 index 0000000000..54c040dfcd --- /dev/null +++ b/packages/experimental/auto-review/locale/en.json @@ -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." + } +} diff --git a/packages/experimental/auto-review/locale/zh.json b/packages/experimental/auto-review/locale/zh.json new file mode 100644 index 0000000000..6a9005d0a1 --- /dev/null +++ b/packages/experimental/auto-review/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "自动授权审查", + "description": "提供自动审查权限模式,由模型在每次工具调用前判断是否授权。" + } +} diff --git a/packages/experimental/auto-review/package.json b/packages/experimental/auto-review/package.json index 853cc277c6..f75c46c8da 100644 --- a/packages/experimental/auto-review/package.json +++ b/packages/experimental/auto-review/package.json @@ -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" diff --git a/packages/experimental/client-ui-agent-team/locale/en.json b/packages/experimental/client-ui-agent-team/locale/en.json new file mode 100644 index 0000000000..ad6f1665e5 --- /dev/null +++ b/packages/experimental/client-ui-agent-team/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Team Panel", + "description": "View team members and shared tasks, and navigate to member conversations." + } +} diff --git a/packages/experimental/client-ui-agent-team/locale/zh.json b/packages/experimental/client-ui-agent-team/locale/zh.json new file mode 100644 index 0000000000..383f0dfec9 --- /dev/null +++ b/packages/experimental/client-ui-agent-team/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "团队面板", + "description": "查看团队成员和共享任务,并切换到成员会话。" + } +} diff --git a/packages/experimental/client-ui-agent-team/package.json b/packages/experimental/client-ui-agent-team/package.json index b344f4cfe0..c980e32c16 100644 --- a/packages/experimental/client-ui-agent-team/package.json +++ b/packages/experimental/client-ui-agent-team/package.json @@ -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" diff --git a/packages/experimental/tool-agent-team/locale/en.json b/packages/experimental/tool-agent-team/locale/en.json new file mode 100644 index 0000000000..f517900c98 --- /dev/null +++ b/packages/experimental/tool-agent-team/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Team Tools", + "description": "Give agents tools to coordinate members, exchange messages, and manage shared tasks." + } +} diff --git a/packages/experimental/tool-agent-team/locale/zh.json b/packages/experimental/tool-agent-team/locale/zh.json new file mode 100644 index 0000000000..fee0b1da5e --- /dev/null +++ b/packages/experimental/tool-agent-team/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "团队工具", + "description": "为智能体提供成员协调、消息通信和共享任务管理工具。" + } +} diff --git a/packages/experimental/tool-agent-team/package.json b/packages/experimental/tool-agent-team/package.json index f638578aac..f1436528f3 100644 --- a/packages/experimental/tool-agent-team/package.json +++ b/packages/experimental/tool-agent-team/package.json @@ -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" ], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01edd8cb6a..ba8a30e914 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)