mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
fix(ui): isolate debug diagnostics and supersede stale RPC calls
This commit is contained in:
committed by
Shakker
parent
6543e6f7c9
commit
3d4a7d4962
@@ -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>
|
||||
|
||||
@@ -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" }));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user