fix(ui): isolate debug diagnostics and supersede stale RPC calls

This commit is contained in:
Peter Steinberger
2026-08-01 10:35:15 -07:00
committed by Shakker
parent 6543e6f7c9
commit 3d4a7d4962
2 changed files with 203 additions and 4 deletions
+13 -4
View File
@@ -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`<div class="callout danger" role="alert">${this.debugDiagnosticsError}</div>`
: nothing}
${debugView}
`;
return html`
<section class="content-header">
<div>
+190
View File
@@ -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<typeof renderDebug>[0];
type TestDebugPage = HTMLElement & {
context: ApplicationContext;
debugCallError: string | null;
debugCallMethod: string;
debugCallResult: string | null;
debugStatus: unknown;
loadDiagnostics: () => Promise<void>;
requestUpdate: () => void;
updateComplete: Promise<boolean>;
};
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((nextResolve, nextReject) => {
resolve = nextResolve;
reject = nextReject;
});
return { promise, resolve, reject };
}
async function mountDebugPage(
request: (method: string) => Promise<unknown>,
): Promise<TestDebugPage> {
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> = {}): 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<unknown>();
const latest = deferred<unknown>();
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<unknown>();
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" }));
});
});