From 3d4a7d49622371fed1afd2d6955eb13fde05cb79 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 10:35:15 -0700 Subject: [PATCH] fix(ui): isolate debug diagnostics and supersede stale RPC calls --- ui/src/pages/debug/debug-page.ts | 17 ++- ui/src/pages/debug/view.test.ts | 190 +++++++++++++++++++++++++++++++ 2 files changed, 203 insertions(+), 4 deletions(-) diff --git a/ui/src/pages/debug/debug-page.ts b/ui/src/pages/debug/debug-page.ts index 5c859d6e5715..51a1c292fcd1 100644 --- a/ui/src/pages/debug/debug-page.ts +++ b/ui/src/pages/debug/debug-page.ts @@ -1,6 +1,6 @@ import { consume } from "@lit/context"; import { initialState, Task, TaskStatus } from "@lit/task"; -import { html } from "lit"; +import { html, nothing } from "lit"; import { state } from "lit/decorators.js"; import type { EventLogEntry } from "../../api/event-log.ts"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; @@ -34,6 +34,7 @@ class DebugPage extends OpenClawLightDomElement { @state() private debugCallParams = "{}"; @state() private debugCallResult: string | null = null; @state() private debugCallError: string | null = null; + @state() private debugDiagnosticsError: string | null = null; @state() private eventLog: readonly EventLogEntry[] = []; private readonly polling = new PollController( @@ -55,6 +56,7 @@ class DebugPage extends OpenClawLightDomElement { client ? loadGatewayDiagnostics(client, signal) : initialState, onComplete: (result) => { this.diagnosticsTaskActiveClient = null; + this.debugDiagnosticsError = null; this.debugStatus = result.status; this.debugHealth = result.health; this.debugModels = result.models; @@ -62,7 +64,7 @@ class DebugPage extends OpenClawLightDomElement { }, onError: (error) => { this.diagnosticsTaskActiveClient = null; - this.debugCallError = String(error); + this.debugDiagnosticsError = String(error); }, }); private readonly subscriptions = new SubscriptionsController(this) @@ -122,6 +124,7 @@ class DebugPage extends OpenClawLightDomElement { this.debugHeartbeat = null; this.debugCallResult = null; this.debugCallError = null; + this.debugDiagnosticsError = null; } private syncPolling() { @@ -156,7 +159,7 @@ class DebugPage extends OpenClawLightDomElement { this.debugCallError = null; this.debugCallResult = null; const gateway = this.gatewaySource; - const epoch = this.callEpoch; + const epoch = ++this.callEpoch; const isCurrent = () => this.connected && this.client === client && @@ -179,7 +182,7 @@ class DebugPage extends OpenClawLightDomElement { } override render() { - const body = renderDebug({ + const debugView = renderDebug({ loading: this.diagnosticsTask.status === TaskStatus.PENDING, status: this.debugStatus, health: this.debugHealth, @@ -196,6 +199,12 @@ class DebugPage extends OpenClawLightDomElement { onRefresh: () => void this.loadDiagnostics(), onCall: () => void this.callDebugMethod(), }); + const body = html` + ${this.debugDiagnosticsError + ? html`` + : nothing} + ${debugView} + `; return html`
diff --git a/ui/src/pages/debug/view.test.ts b/ui/src/pages/debug/view.test.ts index 68008ddee92f..0aa8536f5bce 100644 --- a/ui/src/pages/debug/view.test.ts +++ b/ui/src/pages/debug/view.test.ts @@ -1,11 +1,82 @@ // Control UI tests cover debug behavior. import { render } from "lit"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/context.ts"; import { i18n } from "../../i18n/index.ts"; import { createStorageMock } from "../../test-helpers/storage.ts"; +import "./debug-page.ts"; import { renderDebug } from "./view.ts"; type DebugProps = Parameters[0]; +type TestDebugPage = HTMLElement & { + context: ApplicationContext; + debugCallError: string | null; + debugCallMethod: string; + debugCallResult: string | null; + debugStatus: unknown; + loadDiagnostics: () => Promise; + requestUpdate: () => void; + updateComplete: Promise; +}; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve; + reject = nextReject; + }); + return { promise, resolve, reject }; +} + +async function mountDebugPage( + request: (method: string) => Promise, +): Promise { + const client = { request } as unknown as GatewayBrowserClient; + const snapshot = { + phase: "connected", + client, + hello: { features: { methods: ["manual.first", "manual.latest"] } }, + } as ApplicationGatewaySnapshot; + const gateway = { + snapshot, + eventLog: [], + subscribe: () => () => undefined, + subscribeEventLog: () => () => undefined, + } as unknown as ApplicationContext["gateway"]; + const page = document.createElement("openclaw-debug-page") as TestDebugPage; + page.context = { basePath: "", gateway } as ApplicationContext; + document.body.append(page); + await vi.waitFor(() => expect(page.debugStatus).not.toBeNull()); + await page.updateComplete; + return page; +} + +function clickManualCall(page: TestDebugPage): void { + const button = [...page.querySelectorAll("button")].find( + (candidate) => candidate.textContent?.trim() === "Call", + ); + if (!button) { + throw new Error("Expected the rendered manual RPC Call button"); + } + button.click(); +} + +function diagnosticResponse(method: string): unknown { + switch (method) { + case "status": + return { version: "healthy" }; + case "health": + return { ok: true }; + case "models.list": + return { models: [] }; + case "last-heartbeat": + return null; + default: + throw new Error(`Unexpected diagnostics method: ${method}`); + } +} function createProps(overrides: Partial = {}): DebugProps { return { @@ -39,6 +110,7 @@ describe("renderDebug", () => { }); afterEach(async () => { + document.body.replaceChildren(); await i18n.setLocale("en"); vi.unstubAllGlobals(); }); @@ -95,4 +167,122 @@ describe("renderDebug", () => { expect(container.textContent).toContain("gateway"); expect(container.textContent).not.toContain("Invalid Date"); }); + + it.each([ + { label: "response", staleError: false }, + { label: "error", staleError: true }, + ])( + "ignores an older manual RPC $label after the latest call succeeds", + async ({ staleError }) => { + const older = deferred(); + const latest = deferred(); + const request = vi.fn(async (method: string) => { + if (method === "manual.first") { + return older.promise; + } + if (method === "manual.latest") { + return latest.promise; + } + return diagnosticResponse(method); + }); + const page = await mountDebugPage(request); + + page.debugCallMethod = "manual.first"; + await page.updateComplete; + clickManualCall(page); + page.debugCallMethod = "manual.latest"; + await page.updateComplete; + clickManualCall(page); + await vi.waitFor(() => expect(request).toHaveBeenCalledWith("manual.latest", {})); + + latest.resolve({ result: "latest response" }); + await vi.waitFor(() => expect(page.textContent).toContain("latest response")); + if (staleError) { + older.reject(new Error("stale manual failure")); + } else { + older.resolve({ result: "stale response" }); + } + await older.promise.catch(() => undefined); + await Promise.resolve(); + await Promise.resolve(); + await page.updateComplete; + + expect(page.textContent).toContain("latest response"); + expect(page.textContent).not.toContain("stale response"); + expect(page.textContent).not.toContain("stale manual failure"); + expect(page.debugCallError).toBeNull(); + }, + ); + + it("reports polling failures separately from manual RPC and clears them on recovery", async () => { + let diagnosticsUnavailable = false; + const request = vi.fn(async (method: string) => { + if (method === "manual.latest") { + return { result: "manual response" }; + } + if (method === "status" && diagnosticsUnavailable) { + throw new Error("background snapshots unavailable"); + } + return diagnosticResponse(method); + }); + const page = await mountDebugPage(request); + page.debugCallMethod = "manual.latest"; + await page.updateComplete; + clickManualCall(page); + await vi.waitFor(() => expect(page.textContent).toContain("manual response")); + + diagnosticsUnavailable = true; + await page.loadDiagnostics(); + await page.updateComplete; + + expect(page.querySelector('[role="alert"]')?.textContent).toContain( + "background snapshots unavailable", + ); + expect(page.textContent).not.toContain("Call failed"); + expect(page.debugCallError).toBeNull(); + expect(page.debugCallResult).toContain("manual response"); + + diagnosticsUnavailable = false; + await page.loadDiagnostics(); + await page.updateComplete; + + expect(page.querySelector('[role="alert"]')).toBeNull(); + expect(page.textContent).toContain("manual response"); + }); + + it("clears a failed snapshots alert when the Gateway source changes", async () => { + let diagnosticsUnavailable = false; + const initialRequest = vi.fn(async (method: string) => { + if (method === "status" && diagnosticsUnavailable) { + throw new Error("old Gateway snapshots unavailable"); + } + return diagnosticResponse(method); + }); + const page = await mountDebugPage(initialRequest); + diagnosticsUnavailable = true; + await page.loadDiagnostics(); + await page.updateComplete; + expect(page.querySelector('[role="alert"]')?.textContent).toContain( + "old Gateway snapshots unavailable", + ); + + const nextStatus = deferred(); + const nextRequest = vi.fn(async (method: string) => + method === "status" ? nextStatus.promise : diagnosticResponse(method), + ); + const nextClient = { request: nextRequest } as unknown as GatewayBrowserClient; + const nextGateway = { + ...page.context.gateway, + snapshot: { ...page.context.gateway.snapshot, client: nextClient }, + subscribe: () => () => undefined, + subscribeEventLog: () => () => undefined, + } as ApplicationContext["gateway"]; + page.context = { basePath: "", gateway: nextGateway } as ApplicationContext; + page.requestUpdate(); + await page.updateComplete; + + expect(page.querySelector('[role="alert"]')).toBeNull(); + nextStatus.resolve({ version: "replacement" }); + await vi.waitFor(() => expect(page.debugStatus).toEqual({ version: "replacement" })); + }); });