diff --git a/apps/app/src/react-app/domains/connections/cloud-mcp-submit-readiness.ts b/apps/app/src/react-app/domains/connections/cloud-mcp-submit-readiness.ts index a92f800d3a..9508c383e8 100644 --- a/apps/app/src/react-app/domains/connections/cloud-mcp-submit-readiness.ts +++ b/apps/app/src/react-app/domains/connections/cloud-mcp-submit-readiness.ts @@ -265,7 +265,7 @@ export function assessCloudMcpSubmissionReadiness(input: { code: "provider_tool_projection_unverified", stage: "provider_projection", message: "OpenWork could not read tool capability information for the selected provider and model.", - recommendedAction: "Retry, or check Settings → Advanced → Agent access diagnostics if the problem continues.", + recommendedAction: "Retry, or check Settings → Advanced → Diagnostics if the problem continues.", }), }; } @@ -286,8 +286,8 @@ export function assessCloudMcpSubmissionReadiness(input: { ? "The selected model does not support tool calling." : "OpenWork could not confirm that the selected model supports tool calling.", recommendedAction: modelMissing - ? "Choose a model available from this provider, or check Settings → Advanced → Agent access diagnostics." - : "Choose a model with tool calling, or check Settings → Advanced → Agent access diagnostics.", + ? "Choose a model available from this provider, or check Settings → Advanced → Diagnostics." + : "Choose a model with tool calling, or check Settings → Advanced → Diagnostics.", }), }; } @@ -300,7 +300,7 @@ export function assessCloudMcpSubmissionReadiness(input: { stage: "provider_projection", retryable: false, message: "OpenWork received an unsupported tool capability result for the selected provider and model.", - recommendedAction: "Choose a model with tool calling, or check Settings → Advanced → Agent access diagnostics.", + recommendedAction: "Choose a model with tool calling, or check Settings → Advanced → Diagnostics.", }), }; } diff --git a/apps/app/src/react-app/domains/settings/advanced-sections.ts b/apps/app/src/react-app/domains/settings/advanced-sections.ts index 72acc2673e..52817e5305 100644 --- a/apps/app/src/react-app/domains/settings/advanced-sections.ts +++ b/apps/app/src/react-app/domains/settings/advanced-sections.ts @@ -1,7 +1,7 @@ export const ADVANCED_SETTINGS_SECTIONS = [ { id: "organization-server", title: "Organization server", keywords: ["den", "control plane", "server url", "reset server"] }, { id: "runtime", title: "Runtime", keywords: ["opencode engine", "openwork server", "connection status"] }, - { id: "agent-access", title: "Agent access diagnostics", keywords: ["cloud mcp", "health", "tools", "connections"] }, + { id: "agent-access", title: "Diagnostics", keywords: ["agent access", "cloud mcp", "health", "tools", "connections", "copy report", "support", "version"] }, { id: "config-sources", title: "OpenCode config sources", keywords: ["runtime db", "injected config", "project config", "global config"] }, { id: "experimental-engine", title: "Experimental engine", keywords: ["engine v2", "chat engine", "preview"] }, { id: "workspace-run-mode", title: "Workspace run mode", keywords: ["approvals", "permissions", "keep going", "feature flag"] }, diff --git a/apps/app/src/react-app/domains/settings/pages/advanced-diagnostics-section.tsx b/apps/app/src/react-app/domains/settings/pages/advanced-diagnostics-section.tsx new file mode 100644 index 0000000000..f5d5f0d0b5 --- /dev/null +++ b/apps/app/src/react-app/domains/settings/pages/advanced-diagnostics-section.tsx @@ -0,0 +1,228 @@ +/** @jsxImportSource react */ +import { useEffect, useRef, useState } from "react"; +import { ChevronRight, Copy } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import type { OpenworkCloudMcpHealth } from "@/app/lib/openwork-server"; +import { sanitizeCloudMcpHealthDiagnostic } from "@/app/lib/diagnostic-sanitizer"; +import { RefreshButton, SettingsInset, SettingsNotice, SettingsStatusBadge } from "../settings-section"; +import { + LayoutSection, + LayoutSectionHeader, + LayoutSectionItem, + LayoutSectionTitle, +} from "../settings-layout"; +import { diagnosticsVersionLine, summarizeDiagnostics } from "./diagnostics-summary"; + +function DiagnosticRow(props: { label: string; value: string }) { + return ( +
+
{props.label}
+
{props.value}
+
+ ); +} + +function joinList(values: string[]): string { + return values.length ? values.join(", ") : "none"; +} + +function formatMaybe(value: string | number | boolean | null | undefined): string { + if (value === null || value === undefined || value === "") return "unknown"; + return String(value); +} + +function formatMetadataRecord(value: Record | null | undefined): string { + if (!value || Object.keys(value).length === 0) return "none"; + return Object.entries(value).map(([key, nested]) => `${key}=${formatMaybe(nested)}`).join(", "); +} + +function formatSupportedFeatures(features: OpenworkCloudMcpHealth["compatibility"]["supportedFeatures"]): string { + return Object.entries(features).map(([key, enabled]) => `${key}:${enabled ? "yes" : "no"}`).join(", "); +} + +function formatPluginHashes(hashes: OpenworkCloudMcpHealth["compatibility"]["pluginFileHashes"]): string { + if (hashes.length === 0) return "none"; + return hashes.map((hash) => `${hash.name}=${hash.sha256 ? hash.sha256.slice(0, 12) : `unavailable${hash.error ? ` (${hash.error})` : ""}`}`).join(", "); +} + +function formatMcpToolExposure(input: { checked: boolean; includesMcpTools: boolean | null; present: string[]; missing: string[]; limitation?: string }): string { + if (!input.checked) return "not checked"; + const includes = input.includesMcpTools === null ? "unknown" : input.includesMcpTools ? "yes" : "no"; + return `includes MCP tools: ${includes}; present ${joinList(input.present)}; missing ${joinList(input.missing)}${input.limitation ? `; limitation: ${input.limitation}` : ""}`; +} + +function formatCheckedAt(checkedAt: string): string | null { + const date = new Date(checkedAt); + if (Number.isNaN(date.getTime())) return null; + return `checked ${date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}`; +} + +/** The exact runtime state, for the support engineer reading over someone's shoulder. Closed by default. */ +function TechnicalDetails(props: { health: OpenworkCloudMcpHealth }) { + const health = props.health; + const projection = health.tools.providerProjection; + const compatibility = health.compatibility; + + return ( +
+ + + Technical details + +
+ + + + + + {health.engineInspection?.checked ? ( + `${server.name} ${server.status}${server.error ? ` (${server.error})` : ""}`).join("; ") + : "none tracked"} + /> + ) : null} + + + + {health.tools.direct.trace ? ( + `${step.step} ${step.ok ? "ok" : "failed"}${step.httpStatus !== undefined ? ` (HTTP ${step.httpStatus})` : ""} ${Math.max(0, Math.round(step.latencyMs))}ms`).join(" → ") || "no steps"}`} + /> + ) : null} + {health.firstFailure ? ( + + ) : null} + + + {compatibility ? ( + <> + + + + + + + + ) : null} + +
+ Show sanitized health JSON +
+            {JSON.stringify(sanitizeCloudMcpHealthDiagnostic(health), null, 2)}
+          
+
+
+
+ ); +} + +export interface AdvancedDiagnosticsSectionProps { + appVersion: string | null; + signedIn: boolean; + orgSelected: boolean; + cloudMcpHealth: OpenworkCloudMcpHealth | null; + /** False until the workspace has a server connection the check can ask. */ + canCheck: boolean; + onRefresh: () => Promise; + /** Copies the full sanitized report and tells the person it worked. */ + onCopyReport: () => Promise; +} + +export function AdvancedDiagnosticsSection(props: AdvancedDiagnosticsSectionProps) { + const [checking, setChecking] = useState(false); + const [copying, setCopying] = useState(false); + const [checkError, setCheckError] = useState(null); + const autoChecked = useRef(false); + + const check = async () => { + setChecking(true); + setCheckError(null); + try { + await props.onRefresh(); + } catch (error) { + setCheckError(error instanceof Error ? error.message : "Could not run the check."); + } finally { + setChecking(false); + } + }; + + const copy = async () => { + setCopying(true); + try { + await props.onCopyReport(); + } finally { + setCopying(false); + } + }; + + // "Run diagnostics" lands here: show a result instead of asking for another click. + // The page can open before the workspace connection exists, so wait for it. + useEffect(() => { + if (autoChecked.current || props.cloudMcpHealth || !props.canCheck || !props.signedIn || !props.orgSelected) return; + autoChecked.current = true; + void check(); + }, [props.cloudMcpHealth, props.canCheck, props.signedIn, props.orgSelected]); + + const summary = summarizeDiagnostics({ + checking, + canCheck: props.canCheck, + signedIn: props.signedIn, + orgSelected: props.orgSelected, + health: props.cloudMcpHealth, + }); + const facts = [ + diagnosticsVersionLine({ appVersion: props.appVersion, health: props.cloudMcpHealth }), + props.cloudMcpHealth ? formatCheckedAt(props.cloudMcpHealth.checkedAt) : null, + ].filter((fact): fact is string => Boolean(fact)); + + return ( + + + Diagnostics + + + + +
+ +
{summary.detail}
+ {facts.length > 0 ? ( +
+ {facts.join(" — ")} +
+ ) : null} +
+
+ + Check again + + +
+
+ + {checkError ? {checkError} : null} + {props.cloudMcpHealth ? : null} +
+
+ ); +} diff --git a/apps/app/src/react-app/domains/settings/pages/advanced-view-sections.tsx b/apps/app/src/react-app/domains/settings/pages/advanced-view-sections.tsx index 4bde15876c..034070558e 100644 --- a/apps/app/src/react-app/domains/settings/pages/advanced-view-sections.tsx +++ b/apps/app/src/react-app/domains/settings/pages/advanced-view-sections.tsx @@ -8,8 +8,8 @@ import { Field, FieldLabel } from "@/components/ui/field"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; -import type { OpenworkServerClient, OpenworkCloudMcpHealth, OpenworkRuntimeConfigStatus } from "@/app/lib/openwork-server"; -import { sanitizeCloudMcpHealthDiagnostic, sanitizeDiagnosticRecord } from "@/app/lib/diagnostic-sanitizer"; +import type { OpenworkServerClient, OpenworkRuntimeConfigStatus } from "@/app/lib/openwork-server"; +import { sanitizeDiagnosticRecord } from "@/app/lib/diagnostic-sanitizer"; import { DEFAULT_DEN_API_BASE_URL, DEFAULT_DEN_BASE_URL, @@ -329,164 +329,6 @@ export function AdvancedRuntimeSection(props: AdvancedRuntimeSectionProps) { ); } -function DiagnosticRow(props: { label: string; value: string }) { - return ( -
-
{props.label}
-
{props.value}
-
- ); -} - -function joinList(values: string[]): string { - return values.length ? values.join(", ") : "none"; -} - -function formatMaybe(value: string | number | boolean | null | undefined): string { - if (value === null || value === undefined || value === "") return "unknown"; - return String(value); -} - -function formatMetadataRecord(value: Record | null | undefined): string { - if (!value || Object.keys(value).length === 0) return "none"; - return Object.entries(value).map(([key, nested]) => `${key}=${formatMaybe(nested)}`).join(", "); -} - -function formatSupportedFeatures(features: OpenworkCloudMcpHealth["compatibility"]["supportedFeatures"]): string { - return Object.entries(features).map(([key, enabled]) => `${key}:${enabled ? "yes" : "no"}`).join(", "); -} - -function formatPluginHashes(hashes: OpenworkCloudMcpHealth["compatibility"]["pluginFileHashes"]): string { - if (hashes.length === 0) return "none"; - return hashes.map((hash) => `${hash.name}=${hash.sha256 ? hash.sha256.slice(0, 12) : `unavailable${hash.error ? ` (${hash.error})` : ""}`}`).join(", "); -} - -function formatMcpToolExposure(input: { checked: boolean; includesMcpTools: boolean | null; present: string[]; missing: string[]; limitation?: string }): string { - if (!input.checked) return "not checked"; - const includes = input.includesMcpTools === null ? "unknown" : input.includesMcpTools ? "yes" : "no"; - return `includes MCP tools: ${includes}; present ${joinList(input.present)}; missing ${joinList(input.missing)}${input.limitation ? `; limitation: ${input.limitation}` : ""}`; -} - -interface AdvancedCloudMcpDiagnosticsSectionProps { - cloudMcpHealth: OpenworkCloudMcpHealth | null; - onRefresh: () => Promise; -} - -export function AdvancedCloudMcpDiagnosticsSection(props: AdvancedCloudMcpDiagnosticsSectionProps) { - const [busy, setBusy] = useState(false); - const [copyStatus, setCopyStatus] = useState(null); - const [refreshError, setRefreshError] = useState(null); - const safeHealth = sanitizeCloudMcpHealthDiagnostic(props.cloudMcpHealth); - const projection = props.cloudMcpHealth?.tools.providerProjection; - const compatibility = props.cloudMcpHealth?.compatibility; - - const refresh = async () => { - setBusy(true); - setCopyStatus(null); - setRefreshError(null); - try { - await props.onRefresh(); - } catch (error) { - setRefreshError(error instanceof Error ? error.message : "Could not refresh Cloud MCP diagnostics."); - } finally { - setBusy(false); - } - }; - - const copy = async () => { - const payload = JSON.stringify({ cloudMcpHealth: safeHealth }, null, 2); - await navigator.clipboard.writeText(payload); - setCopyStatus("Copied sanitized Cloud diagnostic."); - }; - - return ( - - - Agent access diagnostics - - Technical details for OpenWork Cloud MCP delivery. Tokens and Authorization headers are redacted before display or copy. - - - - - - OpenWork Cloud MCP health - - Use this when support needs exact runtime state. The main Connect card stays user-facing. - - - - - - - - {copyStatus ? {copyStatus} : null} - {refreshError ? {refreshError} : null} - {props.cloudMcpHealth ? ( -
-
- - - - - - {props.cloudMcpHealth.engineInspection?.checked ? ( - `${server.name} ${server.status}${server.error ? ` (${server.error})` : ""}`).join("; ") - : "none tracked"} - /> - ) : null} - - - - {props.cloudMcpHealth.tools.direct.trace ? ( - `${step.step} ${step.ok ? "ok" : "failed"}${step.httpStatus !== undefined ? ` (HTTP ${step.httpStatus})` : ""} ${Math.max(0, Math.round(step.latencyMs))}ms`).join(" → ") || "no steps"}`} - /> - ) : null} - {props.cloudMcpHealth.firstFailure ? ( - - ) : null} - - - {compatibility ? ( - <> - - - - - - - - ) : null} - -
-
- Show sanitized health JSON -
-                {JSON.stringify(safeHealth, null, 2)}
-              
-
-
- ) : ( - No Cloud MCP health has been loaded for this workspace yet. - )} -
-
- ); -} - interface AdvancedRuntimeConfigSourcesSectionProps { busy: boolean; canInspect: boolean; diff --git a/apps/app/src/react-app/domains/settings/pages/advanced-view.tsx b/apps/app/src/react-app/domains/settings/pages/advanced-view.tsx index 7122a936c3..91705dde8b 100644 --- a/apps/app/src/react-app/domains/settings/pages/advanced-view.tsx +++ b/apps/app/src/react-app/domains/settings/pages/advanced-view.tsx @@ -7,16 +7,17 @@ import { ADVANCED_SETTINGS_SECTIONS } from "../advanced-sections"; import { Separator } from "@/components/ui/separator"; import type { OpencodeConnectStatus } from "@/app/types"; +import { readDenSettings } from "@/app/lib/den"; import type { OpenworkServerClient, OpenworkCloudMcpHealth, OpenworkRuntimeConfigStatus, OpenworkServerStatus } from "@/app/lib/openwork-server"; import { t } from "@/i18n"; import { LayoutStack } from "../settings-layout"; import type { useDenSession } from "../cloud/use-den-session"; +import { AdvancedDiagnosticsSection } from "./advanced-diagnostics-section"; import { advancedLocalReducer, initialAdvancedLocalState } from "./advanced-view-state"; import { AdvancedDeveloperSection, AdvancedEngineV2PreviewSection, - AdvancedCloudMcpDiagnosticsSection, AdvancedOrganizationServerSection, AdvancedRuntimeConfigSourcesSection, AdvancedRuntimeSection, @@ -53,6 +54,9 @@ export type AdvancedViewProps = { cloudMcpUrl: string | null; cloudMcpHealth: OpenworkCloudMcpHealth | null; refreshCloudMcpHealth: () => Promise; + canCheckCloudMcpHealth: boolean; + appVersion: string | null; + copyDiagnosticsReport: () => Promise; engineClient: OpenworkServerClient | null; }; @@ -173,6 +177,8 @@ export function AdvancedView(props: AdvancedViewProps) { void refreshRuntimeConfigStatus(); }, [props.canInspectRuntimeConfig]); + const denSettings = readDenSettings(); + return (
@@ -199,9 +205,14 @@ export function AdvancedView(props: AdvancedViewProps) { openworkDetailLines={openworkDetailLines} /> - 0 ? parts.join(", ") : null; +} diff --git a/apps/app/src/react-app/shell/settings-route.tsx b/apps/app/src/react-app/shell/settings-route.tsx index 893b8a08e5..fa31521ef4 100644 --- a/apps/app/src/react-app/shell/settings-route.tsx +++ b/apps/app/src/react-app/shell/settings-route.tsx @@ -21,6 +21,7 @@ import { resolveOpenWorkExtensionCatalogPlatform, } from "@/app/constants"; import type { EnablementContext } from "@/app/enablement"; +import { buildDiagnosticsBundleJson } from "@/app/lib/diagnostics-bundle"; import { createClient, unwrap } from "@/app/lib/opencode"; import { createOpenworkServerClient, @@ -1310,6 +1311,23 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { setCloudMcpHealthResult({ workspaceId, health }); return health; }, [currentCloudMcpModel, openworkClient, runtimeWorkspaceId, selectedWorkspaceEndpoint]); + // Same report as the command palette's "Copy diagnostics", plus the Cloud check shown on the Advanced page. + const copyDiagnosticsReport = useCallback(async () => { + try { + const report = await buildDiagnosticsBundleJson({ + clientConnected: Boolean(opencodeClient), + developerMode, + openworkServerStatus: openworkServerSnapshot.openworkServerStatus, + openworkServerUrl: openworkClient?.baseUrl ?? "", + runtimeWorkspaceId, + cloudMcpHealth, + }); + await navigator.clipboard.writeText(report); + toast.success(t("session.diagnostics_copied")); + } catch (error) { + toast.error(t("session.diagnostics_failed"), { description: describeRouteError(error) }); + } + }, [cloudMcpHealth, developerMode, opencodeClient, openworkClient, openworkServerSnapshot.openworkServerStatus, runtimeWorkspaceId]); const { commandPaletteOpen, setCommandPaletteOpen } = useCommandPaletteShortcut(!props.embedded); const developerModePaletteItem = useMemo( () => settingsDeveloperModePaletteItem(developerMode, () => { @@ -2641,6 +2659,9 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { }} cloudMcpHealth={cloudMcpHealth} refreshCloudMcpHealth={refreshCloudMcpHealth} + canCheckCloudMcpHealth={Boolean((selectedWorkspaceEndpoint?.client ?? openworkClient) && runtimeWorkspaceId?.trim())} + appVersion={electronUpdaterState.appVersion} + copyDiagnosticsReport={copyDiagnosticsReport} organizationServer={denSession} engineClient={openworkClient} /> diff --git a/apps/app/tests/advanced-diagnostics-section.test.tsx b/apps/app/tests/advanced-diagnostics-section.test.tsx new file mode 100644 index 0000000000..0c39f8b7c7 --- /dev/null +++ b/apps/app/tests/advanced-diagnostics-section.test.tsx @@ -0,0 +1,220 @@ +/** @jsxImportSource react */ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { act } from "react"; +import type { Root } from "react-dom/client"; + +import type { OpenworkCloudMcpHealth } from "../src/app/lib/openwork-server"; +import { + diagnosticsVersionLine, + summarizeDiagnostics, +} from "../src/react-app/domains/settings/pages/diagnostics-summary"; + +const ownedDom = !GlobalRegistrator.isRegistered && typeof document === "undefined"; +let createRoot: typeof import("react-dom/client")["createRoot"]; +let AdvancedDiagnosticsSection: typeof import("../src/react-app/domains/settings/pages/advanced-diagnostics-section")["AdvancedDiagnosticsSection"]; +let root: Root | null = null; +let host: HTMLDivElement; +let previousAct: unknown; + +function health(overrides: Partial = {}): OpenworkCloudMcpHealth { + return { + schemaVersion: 1, + phase: "ready", + usable: true, + usableByCurrentModel: true, + connectCatalogEnabled: true, + workspace: { id: "workspace-1", type: "local", directory: "/tmp/project", path: "/tmp/project" }, + desired: { + present: true, + name: "openwork-cloud", + revision: "revision-1", + config: { enabled: true }, + token: { present: true, metadata: {} }, + }, + delivery: { + state: "ready", + desiredRevision: "revision-1", + appliedRevision: "revision-1", + updatedAt: null, + appliedAt: null, + lastAttemptAt: null, + }, + engine: { status: "connected" }, + tools: { + expected: ["search_capabilities", "execute_capability"], + present: ["search_capabilities", "execute_capability"], + missing: [], + direct: { checked: true, source: "mcp_tools_list", expected: [], present: [], missing: [] }, + providerProjection: { checked: false, present: [], missing: [] }, + }, + pluginCanaries: { expected: [], present: [], missing: [] }, + compatibility: { + openwork: { serverVersion: "0.18.70", app: { version: "0.18.70" } }, + opencode: { expectedVersion: "2.2.0", actualVersion: "2.2.0", probe: "ok" }, + pluginFileHashes: [], + supportedFeatures: { + dynamicMcp: true, + directoryScoping: true, + toolIds: true, + providerToolProjection: true, + pluginCanaries: true, + }, + experimentalToolIds: { checked: false, expected: [], present: [], missing: [], includesMcpTools: null }, + experimentalProviderTools: { checked: false, expected: [], present: [], missing: [], includesMcpTools: null }, + }, + toolDenies: [], + firstFailure: null, + checkedAt: "2026-11-13T18:42:00.000Z", + ...overrides, + }; +} + +const expiredSignIn = health({ + usable: false, + firstFailure: { + code: "openwork_cloud_token_expired", + stage: "cloud_auth", + retryable: true, + recommendedAction: "Sign in again.", + message: "The Cloud token expired.", + }, +}); + +describe("diagnostics summary", () => { + const signedIn = { checking: false, canCheck: true, signedIn: true, orgSelected: true }; + + test("working tools read as one reassuring line", () => { + expect(summarizeDiagnostics({ ...signedIn, health: health() })).toEqual({ + tone: "ready", + label: "Working", + detail: "Your connected tools are ready to use.", + }); + }); + + test("a failure names what went wrong in plain words, not the failure code", () => { + const summary = summarizeDiagnostics({ ...signedIn, health: expiredSignIn }); + expect(summary).toEqual({ + tone: "error", + label: "Needs attention", + detail: "Cloud authentication expired. Copy the report and send it to OpenWork support.", + }); + expect(summary.detail).not.toContain("openwork_cloud_token_expired"); + }); + + test("tools switched off are not reported as a problem", () => { + const off = health({ usable: false, desired: { ...health().desired, config: { enabled: false } } }); + expect(summarizeDiagnostics({ ...signedIn, health: off }).label).toBe("Turned off"); + }); + + test("without a result it says what is missing instead of guessing a status", () => { + expect(summarizeDiagnostics({ ...signedIn, health: null }).label).toBe("Not checked"); + expect(summarizeDiagnostics({ ...signedIn, signedIn: false, health: null }).label).toBe("Signed out"); + expect(summarizeDiagnostics({ ...signedIn, orgSelected: false, health: null }).label).toBe("No organization"); + expect(summarizeDiagnostics({ ...signedIn, checking: true, health: null }).label).toBe("Checking"); + expect(summarizeDiagnostics({ ...signedIn, canCheck: false, health: null }).detail) + .toBe("The check runs once this workspace is connected."); + }); + + test("checking again keeps the last result on screen", () => { + expect(summarizeDiagnostics({ ...signedIn, checking: true, health: expiredSignIn }).label).toBe("Needs attention"); + }); + + test("the version line leads with the app version and works before any check", () => { + expect(diagnosticsVersionLine({ appVersion: "0.18.70", health: null })).toBe("OpenWork v0.18.70"); + expect(diagnosticsVersionLine({ appVersion: null, health: health() })).toBe("OpenWork v0.18.70, OpenCode 2.2.0"); + expect(diagnosticsVersionLine({ appVersion: null, health: null })).toBeNull(); + }); +}); + +describe("Diagnostics section", () => { + beforeAll(async () => { + if (ownedDom) GlobalRegistrator.register({ url: "http://localhost/" }); + createRoot = (await import("react-dom/client")).createRoot; + AdvancedDiagnosticsSection = (await import("../src/react-app/domains/settings/pages/advanced-diagnostics-section")).AdvancedDiagnosticsSection; + }); + afterAll(async () => { if (ownedDom) await GlobalRegistrator.unregister(); }); + beforeEach(() => { + previousAct = Reflect.get(globalThis, "IS_REACT_ACT_ENVIRONMENT"); + Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + }); + afterEach(async () => { + if (root) await act(async () => root?.unmount()); + root = null; + host.remove(); + Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", previousAct); + }); + + async function render(props: Partial[0]> = {}) { + const calls = { refresh: 0, copy: 0 }; + await act(async () => root?.render( + { calls.refresh += 1; return null; }} + onCopyReport={async () => { calls.copy += 1; }} + {...props} + />, + )); + return calls; + } + + function button(label: string) { + return [...host.querySelectorAll("button")] + .find((candidate) => candidate.textContent?.trim() === label); + } + + test("shows the status and app version up front and keeps the technical rows folded away", async () => { + await render(); + const summary = host.querySelector('[data-testid="diagnostics-summary"]')?.textContent ?? ""; + expect(summary).toContain("Working"); + expect(summary).toContain("OpenWork v0.18.70, OpenCode 2.2.0 — checked "); + const details = host.querySelector("details"); + expect(details?.querySelector("summary")?.textContent?.trim()).toBe("Technical details"); + expect(details?.open).toBe(false); + expect(details?.textContent).toContain("Desired revision"); + }); + + test("Copy report hands the whole report to the caller in one click", async () => { + const calls = await render(); + await act(async () => { button("Copy report")?.click(); }); + expect(calls.copy).toBe(1); + }); + + test("copying still works before anything has been checked", async () => { + const calls = await render({ cloudMcpHealth: null, signedIn: false }); + expect(host.textContent).toContain("Signed out"); + expect(host.textContent).toContain("OpenWork v0.18.70"); + expect(host.querySelector("details")).toBeNull(); + await act(async () => { button("Copy report")?.click(); }); + expect(calls).toEqual({ refresh: 0, copy: 1 }); + }); + + test("opening it signed in with no result runs the check once", async () => { + const first = await render({ cloudMcpHealth: null }); + expect(first.refresh).toBe(1); + const rerender = await render({ cloudMcpHealth: null }); + expect(rerender.refresh).toBe(0); + }); + + test("the page can open before the workspace is connected; the check runs when it is", async () => { + const early = await render({ cloudMcpHealth: null, canCheck: false }); + expect(early.refresh).toBe(0); + expect(host.textContent).toContain("The check runs once this workspace is connected."); + expect(button("Check again")?.disabled).toBe(true); + const connected = await render({ cloudMcpHealth: null, canCheck: true }); + expect(connected.refresh).toBe(1); + }); + + test("a failed check says so instead of leaving a stale status", async () => { + await render({ cloudMcpHealth: null, onRefresh: async () => { throw new Error("OpenWork server is not running."); } }); + expect(host.textContent).toContain("OpenWork server is not running."); + expect(host.textContent).toContain("Not checked"); + }); +}); diff --git a/evals/proofs/diagnostics-copy-report.proof.test.ts b/evals/proofs/diagnostics-copy-report.proof.test.ts new file mode 100644 index 0000000000..bbe5482d18 --- /dev/null +++ b/evals/proofs/diagnostics-copy-report.proof.test.ts @@ -0,0 +1,79 @@ +import { expect } from "vitest"; +import { spec } from "@openwork/testkit"; +import { accountDiagnostics } from "../worlds/account-diagnostics.ts"; + +const test = spec.world(accountDiagnostics, { + timeout: 300_000, + resources: { surfaces: ["appWeb"], services: ["den"] }, + needs: { commands: ["bun"] }, +}); + +const SECTION = "#advanced-agent-access"; + +test("a member whose connected tools stopped working sees what is wrong and copies one report to share", async ({ world, user, probe, step, evidence }) => { + /** The status lines, as the member reads them. */ + const status = async () => (await probe.dom('[data-testid="diagnostics-summary"]')).elements[0]?.text.replace(/\s+/g, " ").trim() ?? ""; + const technicalDetails = async () => ({ + all: (await probe.dom(`${SECTION} details`)).elements.length, + open: (await probe.dom(`${SECTION} details[open]`)).elements.length, + }); + await user.resizeViewport({ width: 1280, height: 900, deviceScaleFactor: 1 }); + + await step("given: the member's connection is off, so the account menu offers Run diagnostics", async () => { + await user.click({ testId: "account-status-menu" }); + await user.see({ role: "menuitem", label: "Run diagnostics" }, { timeoutMs: 30_000 }); + await user.screenshot(); + }); + + await step("after: Run diagnostics shows one status line, the versions and a Copy report button", async () => { + await user.click({ role: "menuitem", label: "Run diagnostics" }); + await user.see({ role: "button", label: "Copy report" }, { timeoutMs: 30_000 }); + // The check starts without another click and ends on a result, not a spinner. + const line = await probe.eventually(status, { + within: 90_000, label: "the check finishes without another click", + until: text => ["Working", "Turned off", "Needs attention"].some(result => text.startsWith(result)), + }); + const details = await technicalDetails(); + await user.notSee({ role: "button", label: "Copy sanitized diagnostic" }); + const short = details.all > 0 && details.open === 0; + evidence.recordAssertionEvidence( + "The state is one plain line and the technical rows stay closed", + `status reads: ${line}; technical details open: ${details.open} of ${details.all}`, + short, + ); + await user.screenshot(); + expect(short, "the technical rows are present but closed").toBe(true); + }); + + await step("after: Copy report puts the full report on the clipboard in one click", async () => { + await user.click({ role: "button", label: "Copy report" }); + await user.see({ text: "Diagnostics copied" }, { timeoutMs: 30_000 }); + const copied = await probe.eventually(() => world.readClipboard(), { + within: 15_000, label: "the report reached the clipboard", until: value => value.includes('"capturedAt"'), + }); + const report: unknown = JSON.parse(copied); + const has = (key: string) => typeof report === "object" && report !== null && Reflect.get(report, key) != null; + // Witness: the copied text is the whole report, including the check shown on screen. + const whole = has("capturedAt") && has("openworkServer") && has("cloudMcp") && has("developerLogs"); + evidence.recordAssertionEvidence( + "One click copies the whole report, with the result of the check in it", + `clipboard: ${copied.length} characters of JSON; has the capture time, server state, connected-tools check and recent logs: ${whole}`, + whole, + ); + await user.screenshot(); + expect(whole, "the copied report carries the connected-tools check and recent logs").toBe(true); + }); + + await step("the technical rows open with one click for whoever is helping", async () => { + await user.click({ text: "Technical details" }); + await user.see({ text: "Desired revision" }); + const details = await technicalDetails(); + evidence.recordAssertionEvidence( + "The exact runtime state is still one click away", + `technical details open: ${details.open} of ${details.all}; "Desired revision" row visible`, + details.open === 1, + ); + await user.screenshot(); + expect(details.open).toBe(1); + }); +}); diff --git a/evals/worlds/account-diagnostics.ts b/evals/worlds/account-diagnostics.ts index 8ccabf001d..ab58b2dad7 100644 --- a/evals/worlds/account-diagnostics.ts +++ b/evals/worlds/account-diagnostics.ts @@ -15,5 +15,13 @@ export async function accountDiagnostics(seed: Seed) { const { app, workspace } = await signedInAppWeb(seed, { den, webPort, workspacePath, name: "account-diagnostics", engine: "v2", headless: true, }); - return { app, workspace, advancedUrl: `${app.webUrl}/settings/advanced/developer` }; + // Headless Chrome only lets the page write and read the clipboard once granted. + await app.client.send("Browser.grantPermissions", { + origin: new URL(app.webUrl).origin, permissions: ["clipboardReadWrite", "clipboardSanitizedWrite"], + }); + return { + app, workspace, advancedUrl: `${app.webUrl}/settings/advanced/developer`, + // TODO(primitive): probe.clipboard should expose this read-only witness without browser evaluation. + readClipboard: () => seed.evalIn(app, () => navigator.clipboard.readText(), { awaitPromise: true }), + }; }