diff --git a/packages/webui/src/client/components/plugins/PluginCatalogue.tsx b/packages/webui/src/client/components/plugins/PluginCatalogue.tsx
index fb07d394a..5cd01db8f 100644
--- a/packages/webui/src/client/components/plugins/PluginCatalogue.tsx
+++ b/packages/webui/src/client/components/plugins/PluginCatalogue.tsx
@@ -13,7 +13,7 @@ import { ToggleSwitch } from "../shared/ToggleSwitch.js";
import { readWebuiPluginRow as read, type WebuiPluginRow as Row } from "../../contracts/plugins/plugin-rows.js";
import { PluginImportDialog } from "./PluginImportDialog.js";
import { PluginItemDialog } from "./PluginItemDialog.js";
-import { describeWebuiMcpServerStatus } from "../../projection/plugins/mcp-status.js";
+import { describeWebuiMcpServerStatus, describeWebuiMcpTestResult, type WebuiMcpTestResultView } from "../../projection/plugins/mcp-status.js";
type Area = "plugins" | "skills" | "apps" | "mcp" | "agents";
type PluginCatalogueSelection = {
@@ -126,6 +126,15 @@ export function PluginCatalogue({
2,
),
);
+ /* The on-demand connection probe's outcome, keyed by server name: the one
+ * action that answers with a real connection result, driven from the row so
+ * a server that cannot connect can say why without a log file. One slot is
+ * enough — a user tests one server at a time. */
+ const [mcpTest, setMcpTest] = useState<{
+ readonly name: string;
+ readonly busy: boolean;
+ readonly view?: WebuiMcpTestResultView;
+ } | null>(null);
const [agentName, setAgentName] = useState("");
const [agentDescription, setAgentDescription] = useState("");
const [agentPrompt, setAgentPrompt] = useState("");
@@ -232,6 +241,22 @@ export function PluginCatalogue({
if (window.confirm(`确定${label}?此操作会立即影响本地配置。`))
void mutate(action, input);
};
+ /* Drive `testMcpServer` from the row and show what came back. Unlike the
+ * other mutations this one's RESULT is the feature: `{ success, toolCount?,
+ * errorCode?, errorMessage? }` is the only place the real connection reason
+ * exists, so the outcome is read here rather than through `mutate`'s
+ * boolean. A thrown request is reported as a failed probe with an honest
+ * "never sent" detail — the row must never stay silent after a click. */
+ const runMcpTest = async (item: Row) => {
+ const name = nameOf(item);
+ setMcpTest({ name, busy: true });
+ try {
+ const result = await runAction("testMcpServer", { name });
+ setMcpTest({ name, busy: false, view: describeWebuiMcpTestResult(result) });
+ } catch {
+ setMcpTest({ name, busy: false, view: { key: "failed", label: "连接失败", detail: "测试请求未能发送" } });
+ }
+ };
const nameOf = (item: Row) =>
read(
item,
@@ -1050,8 +1075,29 @@ export function PluginCatalogue({
{mcpStatus.reason}
) : null}
+ {/* The probe's outcome for this row: the runtime's own
+ * reason as visible text, replacing the need to read a
+ * server log. Keyed by name so a test on one row does
+ * not decorate another. */}
+ {mcpTest?.name === name && mcpTest.view ? (
+
+ {mcpTest.view.label}
+ {mcpTest.view.detail ? ` · ${mcpTest.view.detail}` : ""}
+
+ ) : null}
+ void runMcpTest(item)}
+ >
+ {mcpTest?.name === name && mcpTest.busy ? "测试中…" : "测试连接"}
+
void prepareMcp(item)}>编辑
diff --git a/packages/webui/src/client/infrastructure/connection-health.ts b/packages/webui/src/client/infrastructure/connection-health.ts
index b1a7696c4..58df4c033 100644
--- a/packages/webui/src/client/infrastructure/connection-health.ts
+++ b/packages/webui/src/client/infrastructure/connection-health.ts
@@ -12,15 +12,21 @@
// closes: `watchEvents` reports its socket's health here, and the banner
// merges the signal with the per-session stream phase.
//
-// Aggregation rule — a watcher is *degraded* only once it has been healthy
-// and then went down. A watcher that has never been accepted (boot in
-// progress, or a host that never answers events) is "unknown", not degraded:
-// flagging it would flash 重连 at every page load, and the honest statement
-// about a link that never existed is nothing, not "reconnecting". With
-// several watchers mounted (the shell's plus the workspace panels'), any
-// once-healthy watcher being down degrades the channel; the transport owns
-// the reporting, so every current and future `watchEvents` caller is covered
-// without each of them wiring callbacks.
+// Aggregation rule — a watcher is *degraded* once it has been healthy and
+// then went down, or once the startup grace ran out without it ever being
+// accepted. The grace exists because of the boot shape the original rule was
+// protecting: a watcher that has not been accepted *yet* is "unknown", not
+// degraded, so a page that is still loading never flashes a reconnecting
+// banner. But "unknown" used to last forever, and a page whose backend was
+// already unreachable when it loaded stayed completely silent — the banner
+// read the stream phase (`idle` = connected) and nothing else was watching.
+// So the unknown state now has a deadline: a watcher still unaccepted after
+// `STARTUP_GRACE_MS` degrades the channel, because at that point the honest
+// statement is "this link is not coming up", not "we are still booting".
+// With several watchers mounted (the shell's plus the workspace panels'), any
+// once-healthy watcher being down — or any watcher past its grace — degrades
+// the channel; the transport owns the reporting, so every current and future
+// `watchEvents` caller is covered without each of them wiring callbacks.
//
// This module is framework-free. The React subscription lives in
// `client/bindings/use-connection-health.ts`.
@@ -29,15 +35,24 @@ interface WebuiWatcherHealth {
readonly token: number;
healthy: boolean;
everHealthy: boolean;
+ /** The startup grace ran out while the watcher was still unaccepted. */
+ startupExpired: boolean;
+ graceTimer?: ReturnType;
}
+/** How long a freshly registered watcher may stay unaccepted before the
+ * channel is declared degraded. Generous next to the transport's 250ms
+ * reconnect loop and a local WebSocket handshake, and far below the point
+ * where a user concludes the page is simply dead. */
+const STARTUP_GRACE_MS = 10_000;
+
const watchers = new Map();
const listeners = new Set<() => void>();
let nextToken = 0;
function degraded(): boolean {
for (const watcher of watchers.values())
- if (watcher.everHealthy && !watcher.healthy) return true;
+ if (!watcher.healthy && (watcher.everHealthy || watcher.startupExpired)) return true;
return false;
}
@@ -45,10 +60,33 @@ function notifyIfChanged(before: boolean): void {
if (degraded() !== before) for (const listener of listeners) listener();
}
+/** Node's timer exposes `unref`; the DOM lib types `setTimeout` as returning
+ * a plain number, so the capability is read through a narrow cast rather
+ * than assumed on the type. */
+function unrefTimer(timer: ReturnType): void {
+ (timer as { unref?: () => void }).unref?.();
+}
+
/** Registers one `watchEvents` subscription. Called by the transport only. */
export function registerWebuiEventWatcher(): number {
nextToken += 1;
- watchers.set(nextToken, { token: nextToken, healthy: false, everHealthy: false });
+ const watcher: WebuiWatcherHealth = {
+ token: nextToken,
+ healthy: false,
+ everHealthy: false,
+ startupExpired: false,
+ };
+ watchers.set(nextToken, watcher);
+ // The unknown state's deadline. `unref` where the host offers it so the
+ // timer never holds a process (or a test worker) open on its own.
+ watcher.graceTimer = setTimeout(() => {
+ watcher.graceTimer = undefined;
+ if (watcher.healthy) return;
+ const before = degraded();
+ watcher.startupExpired = true;
+ if (degraded() !== before) for (const listener of listeners) listener();
+ }, STARTUP_GRACE_MS);
+ unrefTimer(watcher.graceTimer);
return nextToken;
}
@@ -57,6 +95,10 @@ export function markWebuiEventWatcherHealthy(token: number): void {
const watcher = watchers.get(token);
if (!watcher) return;
const before = degraded();
+ if (watcher.graceTimer !== undefined) {
+ clearTimeout(watcher.graceTimer);
+ watcher.graceTimer = undefined;
+ }
watcher.healthy = true;
watcher.everHealthy = true;
notifyIfChanged(before);
@@ -73,6 +115,8 @@ export function markWebuiEventWatcherDown(token: number): void {
/** The subscription unsubscribed (component unmounted); it no longer counts. */
export function unregisterWebuiEventWatcher(token: number): void {
+ const watcher = watchers.get(token);
+ if (watcher?.graceTimer !== undefined) clearTimeout(watcher.graceTimer);
const before = degraded();
watchers.delete(token);
notifyIfChanged(before);
diff --git a/packages/webui/src/client/main.tsx b/packages/webui/src/client/main.tsx
index 08178c953..4ce0d2bcc 100644
--- a/packages/webui/src/client/main.tsx
+++ b/packages/webui/src/client/main.tsx
@@ -13,13 +13,14 @@ import { NotFound } from "./components/shell/NotFound.js";
import { ArchonPage } from "./components/shell/ArchonPage.js";
import { WebuiErrorBoundary } from "./components/shell/WebuiErrorBoundary.js";
import { WebuiStartupFallback } from "./components/shell/StartupFallback.js";
-import { WebuiBrowserCapabilitiesProvider } from "./bindings/browser-capabilities.js";
+import { WebuiBrowserCapabilitiesProvider, useWebuiBrowserCapabilities } from "./bindings/browser-capabilities.js";
import { createWebuiBrowserDom } from "./infrastructure/browser-dom.js";
import {
createWebuiBrowserStorage,
getWebuiBrowserStorage,
} from "./infrastructure/storage.js";
import { createWebuiApplication } from "./application/create-application.js";
+import { createWebuiComposerStore } from "./application/composer/composer-store.js";
import { createWebuiEventEffectsRegistry } from "./application/turn/event-effects-registry.js";
import { createWebuiPortSlices } from "./application/port-slices.js";
import { createWebuiOpenEventChannel } from "./bindings/event-channel-adapter.js";
@@ -69,6 +70,17 @@ function WebuiClientRoot({
() => createWebuiEventEffectsRegistry(),
[],
);
+ // The same storage adapter the shell would have built for its own composer
+ // store. The application layer may not import infrastructure, so the
+ // composition root injects it — without this the adopted composer store has
+ // no storage at all and every draft dies with the page (regression from
+ // e40a63f: the root stopped handing the shell a store to build, and the
+ // application's own default is storage-less by design).
+ const { storage } = useWebuiBrowserCapabilities();
+ const composerStore = React.useMemo(
+ () => createWebuiComposerStore({ storage }),
+ [storage],
+ );
const slices = React.useMemo(
() => createWebuiPortSlices(transport),
[transport],
@@ -85,6 +97,7 @@ function WebuiClientRoot({
// own view reactions on top and registers them through
// `setEventEffects`, because it renders after this instance exists.
effects: effectsRegistry.asWebuiEventEffects(),
+ composer: composerStore,
turns: {
resumeSession: transport.resumeSession ?? (async () => {}),
...(transport.loadMessages
@@ -96,7 +109,7 @@ function WebuiClientRoot({
? { loadMessages: transport.loadMessages }
: {}),
}),
- [effectsRegistry, transport],
+ [composerStore, effectsRegistry, transport],
);
React.useEffect(() => () => application.dispose(), [application]);
diff --git a/packages/webui/src/client/projection/plugins/mcp-status.ts b/packages/webui/src/client/projection/plugins/mcp-status.ts
index 32cb080e1..e87ff804f 100644
--- a/packages/webui/src/client/projection/plugins/mcp-status.ts
+++ b/packages/webui/src/client/projection/plugins/mcp-status.ts
@@ -53,3 +53,40 @@ export function describeWebuiMcpServerStatus(item: Row): WebuiMcpServerStatusVie
return { key: "unknown", label: "未知状态", tone: "text-text_default_tertiary" };
}
}
+
+/**
+ * One on-demand connection test's outcome, as the row shows it.
+ *
+ * `testMcpServer` is the only action that returns a real connection result
+ * (`McpConnectionTestResult`: `{ success, toolCount?, errorCode?,
+ * errorMessage? }`), and the panel never called it — so a server that could
+ * not connect had no way to say WHY from the UI, even though the reason
+ * (a `spawnSync ENOENT` binary, an unlogged-in CLI) was sitting in the
+ * server log. The listing rows carry no status at all
+ * (`listConfiguredServers` → `configuredSummary` is upstream's to fix), so
+ * this probe is the honest surface until then.
+ *
+ * The failure text is the runtime's own `errorMessage` verbatim; when it is
+ * absent the `errorCode` rides along rather than a blank detail, because a
+ * machine token still beats nothing when it is all the runtime said.
+ */
+export interface WebuiMcpTestResultView {
+ readonly key: "connected" | "failed";
+ readonly label: string;
+ readonly detail?: string;
+}
+
+export function describeWebuiMcpTestResult(result: unknown): WebuiMcpTestResultView {
+ const record = result && typeof result === "object" && !Array.isArray(result)
+ ? (result as Record)
+ : {};
+ if (record.success !== true) {
+ const message = typeof record.errorMessage === "string" ? record.errorMessage.trim() : "";
+ const code = typeof record.errorCode === "string" ? record.errorCode.trim() : "";
+ return { key: "failed", label: "连接失败", ...(message || code ? { detail: message || code } : {}) };
+ }
+ const toolCount = typeof record.toolCount === "number" && Number.isFinite(record.toolCount)
+ ? `${record.toolCount} 个工具`
+ : undefined;
+ return { key: "connected", label: "已连接", ...(toolCount ? { detail: toolCount } : {}) };
+}
diff --git a/packages/webui/test/unit/connection-health.test.ts b/packages/webui/test/unit/connection-health.test.ts
index 1bd0ed044..ec14e71d8 100644
--- a/packages/webui/test/unit/connection-health.test.ts
+++ b/packages/webui/test/unit/connection-health.test.ts
@@ -11,7 +11,9 @@
// Two layers here:
// * the store's aggregation rule — degraded means "lost a link it
// previously held"; a never-accepted watcher is unknown, not degraded,
-// so a booting page never flashes a reconnecting banner;
+// so a booting page never flashes a reconnecting banner — but that
+// unknown state has a deadline, so a page whose backend was already
+// unreachable when it loaded stops being silent (the Q-4b remnant);
// * the transport wiring — `watchEvents` registers, marks healthy on the
// server's acceptance, marks down on socket close, and unregisters on
// unsubscribe, so every current and future watcher counts without its
@@ -21,7 +23,7 @@
// unregisters in `afterEach` — a leaked once-healthy watcher would degrade
// every later suite in this worker.
-import { afterEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
isWebuiEventChannelDegraded,
@@ -53,6 +55,60 @@ describe("the watcher health store", () => {
expect(isWebuiEventChannelDegraded()).toBe(false);
});
+describe("the startup grace on a never-accepted watcher", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("keeps a booting watcher unknown until the grace runs out, then degrades", () => {
+ // The remnant: "unknown" used to last forever, so a page that loaded
+ // against an already-unreachable backend showed nothing at all — the
+ // banner reads the stream phase, which stays idle ("connected"). The
+ // grace is what keeps the boot shape quiet while giving the dead-backend
+ // shape a deadline.
+ register();
+ vi.advanceTimersByTime(9_000);
+ expect(isWebuiEventChannelDegraded()).toBe(false);
+ vi.advanceTimersByTime(1_500);
+ expect(isWebuiEventChannelDegraded()).toBe(true);
+ });
+
+ it("stays unknown for good once the server accepts the watcher", () => {
+ // The ordinary boot: accepted in milliseconds, and the cancelled grace
+ // never fires — a healthy channel must not degrade on a timer later.
+ const token = register();
+ markWebuiEventWatcherHealthy(token);
+ vi.advanceTimersByTime(120_000);
+ expect(isWebuiEventChannelDegraded()).toBe(false);
+ });
+
+ it("an acceptance after the grace still clears the flag", () => {
+ // A slow-but-alive backend: the grace fired while the socket was still
+ // opening, and the acceptance that arrives afterwards must restore the
+ // channel rather than leave it degraded for the page's lifetime.
+ const token = register();
+ vi.advanceTimersByTime(10_000);
+ expect(isWebuiEventChannelDegraded()).toBe(true);
+ markWebuiEventWatcherHealthy(token);
+ expect(isWebuiEventChannelDegraded()).toBe(false);
+ });
+
+ it("expires the grace even when the socket keeps closing and retrying", () => {
+ // The unreachable-backend shape end to end: the connect fails, the
+ // transport's close handler marks the watcher down and retries every
+ // 250ms. None of that is an acceptance, so the grace still runs out —
+ // which is exactly the state that used to stay silent.
+ const token = register();
+ markWebuiEventWatcherDown(token);
+ vi.advanceTimersByTime(10_000);
+ expect(isWebuiEventChannelDegraded()).toBe(true);
+ });
+});
+
it("degrades only after a healthy watcher goes down", () => {
const token = register();
markWebuiEventWatcherHealthy(token);
diff --git a/packages/webui/test/unit/plugin-mcp-status.test.tsx b/packages/webui/test/unit/plugin-mcp-status.test.tsx
index 6173e08c4..c5099e511 100644
--- a/packages/webui/test/unit/plugin-mcp-status.test.tsx
+++ b/packages/webui/test/unit/plugin-mcp-status.test.tsx
@@ -25,7 +25,7 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
-import { describeWebuiMcpServerStatus } from "../../src/client/projection/plugins/mcp-status.js";
+import { describeWebuiMcpServerStatus, describeWebuiMcpTestResult } from "../../src/client/projection/plugins/mcp-status.js";
const SOURCE = readFileSync(
new URL("../../src/client/components/plugins/PluginCatalogue.tsx", import.meta.url),
@@ -137,3 +137,75 @@ describe("the MCP row wiring", () => {
expect(deriveAt - mapAt).toBeLessThan(2_000);
});
});
+
+describe("describeWebuiMcpTestResult", () => {
+ /* D-5's surface: the row's 测试连接 button drives `testMcpServer`, the only
+ * action whose answer is a real connection result, and the reason it
+ * returns is what the user reads instead of a server log. These assertions
+ * pin the shape the runtime actually sends (`McpConnectionTestResult`:
+ * success / toolCount / errorCode / errorMessage). */
+ it("shows a passing probe with its tool count", () => {
+ expect(describeWebuiMcpTestResult({ success: true, toolCount: 7 })).toEqual({
+ key: "connected",
+ label: "已连接",
+ detail: "7 个工具",
+ });
+ });
+
+ it("shows a failing probe with the runtime's own reason verbatim", () => {
+ // The real field pair from a binary that never spawned.
+ expect(
+ describeWebuiMcpTestResult({
+ success: false,
+ errorCode: "spawn_enoent",
+ errorMessage: 'spawnSync /usr/local/bin/bmap-cli ENOENT — command not found; the CLI may not be installed',
+ }),
+ ).toEqual({
+ key: "failed",
+ label: "连接失败",
+ detail: "spawnSync /usr/local/bin/bmap-cli ENOENT — command not found; the CLI may not be installed",
+ });
+ });
+
+ it("falls back to the code when the runtime sent no message", () => {
+ expect(describeWebuiMcpTestResult({ success: false, errorCode: "MCP_TIMEOUT" })).toEqual({
+ key: "failed",
+ label: "连接失败",
+ detail: "MCP_TIMEOUT",
+ });
+ });
+
+ it("stays honest when the answer carries neither message nor code", () => {
+ // A bare `{ success: false }` is reported as failed with no invented
+ // detail — the label alone is what the runtime actually said.
+ expect(describeWebuiMcpTestResult({ success: false })).toEqual({
+ key: "failed",
+ label: "连接失败",
+ });
+ // …and a non-object answer is the same failure, not a crash.
+ expect(describeWebuiMcpTestResult(undefined)).toEqual({ key: "failed", label: "连接失败" });
+ expect(describeWebuiMcpTestResult("nope")).toEqual({ key: "failed", label: "连接失败" });
+ });
+
+ it("wires the probe into the MCP row, button and result", () => {
+ // Source-level pins, same contract as the status chip above. The handler
+ // and the row JSX live apart by design (the handler beside the other
+ // mutations, the row in the render), so each fact is pinned where it
+ // actually lives rather than inside one window that cannot span both.
+ const buttonAt = SOURCE.indexOf('data-testid="mcp-test-connection"');
+ expect(buttonAt, "the mcp row no longer renders the test button").toBeGreaterThanOrEqual(0);
+ const button = SOURCE.slice(buttonAt - 300, buttonAt + 500);
+ expect(button).toContain("runMcpTest(item)");
+
+ const handlerAt = SOURCE.indexOf('runAction("testMcpServer"');
+ expect(handlerAt, "the probe no longer drives the real action").toBeGreaterThanOrEqual(0);
+ const handler = SOURCE.slice(handlerAt - 200, handlerAt + 300);
+ expect(handler).toContain("describeWebuiMcpTestResult(result)");
+
+ const resultAt = SOURCE.indexOf('data-testid="mcp-test-result"');
+ expect(resultAt, "the row no longer renders the probe outcome").toBeGreaterThanOrEqual(0);
+ const result = SOURCE.slice(resultAt - 200, resultAt + 700);
+ expect(result).toContain("mcpTest.view.label");
+ expect(result).toContain("data-webui-mcp-test={mcpTest.view.key}");
+ });
+});
diff --git a/test/webui-browser/plugin-mcp-status.spec.mjs b/test/webui-browser/plugin-mcp-status.spec.mjs
index b13cc42d3..132546493 100644
--- a/test/webui-browser/plugin-mcp-status.spec.mjs
+++ b/test/webui-browser/plugin-mcp-status.spec.mjs
@@ -93,3 +93,59 @@ test("the calm states are labelled too, so trouble has honest neighbours", async
// And no row invents a reason where the runtime gave none.
await expect(page.locator("[data-webui-mcp-status-reason]")).toHaveCount(0);
});
+
+/* D-5 — the row's 测试连接 probe. The listing rows carry no connection
+ * status at all (upstream `configuredSummary` sends only name/enabled/
+ * transport/description/endpoint/configJson), and `testMcpServer` is the one
+ * action whose answer is a real connection result — so the probe is where a
+ * user learns WHY a server cannot connect, without a server log. The fixture
+ * answers with the same `{ success, toolCount?, errorCode?, errorMessage? }`
+ * the runtime sends. */
+test("测试连接 on a row shows the runtime's real reason", async ({ page }) => {
+ // The honest listing shape: no `status` key anywhere, because upstream
+ // does not send one. A panel that only worked on rows already carrying a
+ // status would be a demo, not a fix.
+ await stageMcpServers(page, [
+ { name: "baidu-map", transport: "stdio", enabled: true, description: "地图服务", configJson: "{}" },
+ ]);
+ await openApp(page, "#session=A");
+ await openMcpArea(page);
+
+ const row = rowOf(page, "baidu-map");
+ await expect(row.locator('[data-testid="mcp-test-connection"]')).toBeVisible();
+
+ // The failing probe, in the runtime's own words: a CLI that never spawned.
+ await page.evaluate(() =>
+ window.__fixture.setPluginManagementResult("testMcpServer", {
+ success: false,
+ errorCode: "spawn_enoent",
+ errorMessage: "spawnSync /usr/local/bin/bmap-cli ENOENT — command not found; the CLI may not be installed",
+ }),
+ );
+ await row.locator('[data-testid="mcp-test-connection"]').click();
+
+ // The click reached the transport — not a decoration. Plugin actions ride
+ // the one `pluginManagement` facade, so the request is recorded as that
+ // operation with the action in its body.
+ await expect.poll(() =>
+ page.evaluate(() =>
+ window.__fixture.requests.some(
+ (request) => request.operation === "pluginManagement" && request.body?.action === "testMcpServer",
+ ),
+ ),
+ ).toBe(true);
+ // …and the runtime's reason is on the row, verbatim, as visible text.
+ const failed = row.locator('[data-testid="mcp-test-result"]');
+ await expect(failed).toHaveAttribute("data-webui-mcp-test", "failed");
+ await expect(failed).toHaveText("连接失败 · spawnSync /usr/local/bin/bmap-cli ENOENT — command not found; the CLI may not be installed");
+
+ // The passing probe is labelled too, with its tool count — a button that
+ // only ever reports failure would train users to ignore it.
+ await page.evaluate(() =>
+ window.__fixture.setPluginManagementResult("testMcpServer", { success: true, toolCount: 12 }),
+ );
+ await row.locator('[data-testid="mcp-test-connection"]').click();
+ const passed = row.locator('[data-testid="mcp-test-result"]');
+ await expect(passed).toHaveAttribute("data-webui-mcp-test", "connected");
+ await expect(passed).toHaveText("已连接 · 12 个工具");
+});