mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-25 18:11:16 +00:00
fix(ui): recover stalled Nostr profile requests (#111093)
* fix(ui): bound Nostr profile requests * fix(ui): clarify Nostr timeout outcome --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -6,6 +6,8 @@ import { createChannelCapability } from "../../lib/channels/index.ts";
|
||||
import { createRuntimeConfigCapability } from "../../lib/config/index.ts";
|
||||
import "./channels-page.ts";
|
||||
|
||||
const NOSTR_PROFILE_REQUEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
type ChannelsPageTestElement = HTMLElement & {
|
||||
context: ApplicationContext;
|
||||
updateComplete: Promise<boolean>;
|
||||
@@ -17,6 +19,7 @@ type NostrTestPage = ChannelsPageTestElement & {
|
||||
values: NostrProfile;
|
||||
saving: boolean;
|
||||
importing: boolean;
|
||||
error: string | null;
|
||||
} | null;
|
||||
nostrProfileAccountId: string | null;
|
||||
editNostrProfile: (accountId: string, profile: NostrProfile | null) => void;
|
||||
@@ -39,6 +42,21 @@ function createDeferred<T>() {
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function stubHangingFetch() {
|
||||
const fetchMock = vi.fn<typeof fetch>(
|
||||
async (_input, init) =>
|
||||
await new Promise<Response>((_resolve, reject) => {
|
||||
const signal = init?.signal;
|
||||
if (!signal) {
|
||||
throw new Error("Expected Nostr profile request to carry an AbortSignal");
|
||||
}
|
||||
signal.addEventListener("abort", () => reject(signal.reason as Error), { once: true });
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
function createGateway(): TestGateway {
|
||||
const client = { request: vi.fn(async () => ({})) } as unknown as GatewayBrowserClient;
|
||||
const snapshot: ApplicationGatewaySnapshot = {
|
||||
@@ -96,6 +114,7 @@ afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("ChannelsPage lifecycle", () => {
|
||||
@@ -222,4 +241,52 @@ describe("ChannelsPage lifecycle", () => {
|
||||
source.runtimeConfig.dispose();
|
||||
source.channels.dispose();
|
||||
});
|
||||
|
||||
it("clears profile saving when the gateway response times out", async () => {
|
||||
vi.useFakeTimers();
|
||||
const gateway = createGateway();
|
||||
const source = createContext(gateway);
|
||||
const fetchMock = stubHangingFetch();
|
||||
const page = document.createElement("openclaw-channels-page") as NostrTestPage;
|
||||
page.context = source.context;
|
||||
document.body.append(page);
|
||||
await page.updateComplete;
|
||||
page.editNostrProfile("default", { name: "Alice" });
|
||||
|
||||
const save = page.saveNostrProfile();
|
||||
await vi.advanceTimersByTimeAsync(NOSTR_PROFILE_REQUEST_TIMEOUT_MS);
|
||||
await save;
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(page.nostrProfileFormState?.saving).toBe(false);
|
||||
expect(page.nostrProfileFormState?.error).toBe(
|
||||
"Request timed out after 30 seconds; the server may still have applied the change — check the profile before retrying.",
|
||||
);
|
||||
source.runtimeConfig.dispose();
|
||||
source.channels.dispose();
|
||||
});
|
||||
|
||||
it("clears profile importing when the gateway response times out", async () => {
|
||||
vi.useFakeTimers();
|
||||
const gateway = createGateway();
|
||||
const source = createContext(gateway);
|
||||
const fetchMock = stubHangingFetch();
|
||||
const page = document.createElement("openclaw-channels-page") as NostrTestPage;
|
||||
page.context = source.context;
|
||||
document.body.append(page);
|
||||
await page.updateComplete;
|
||||
page.editNostrProfile("default", { name: "Alice" });
|
||||
|
||||
const load = page.importNostrProfile();
|
||||
await vi.advanceTimersByTimeAsync(NOSTR_PROFILE_REQUEST_TIMEOUT_MS);
|
||||
await load;
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(page.nostrProfileFormState?.importing).toBe(false);
|
||||
expect(page.nostrProfileFormState?.error).toBe(
|
||||
"Request timed out after 30 seconds; the server may still have applied the change — check the profile before retrying.",
|
||||
);
|
||||
source.runtimeConfig.dispose();
|
||||
source.channels.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,9 @@ import { ChannelWizardHost } from "./wizard-host.ts";
|
||||
|
||||
type NostrProfileFormState = ReturnType<typeof createNostrProfileFormState> | null;
|
||||
|
||||
const NOSTR_PROFILE_TIMEOUT_ERROR =
|
||||
"Request timed out after 30 seconds; the server may still have applied the change — check the profile before retrying.";
|
||||
|
||||
type NostrOperation = {
|
||||
generation: number;
|
||||
gateway: ApplicationContext["gateway"];
|
||||
@@ -26,6 +29,12 @@ type NostrOperation = {
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
|
||||
function formatNostrProfileOperationError(error: unknown, prefix: string): string {
|
||||
return error instanceof DOMException && error.name === "TimeoutError"
|
||||
? NOSTR_PROFILE_TIMEOUT_ERROR
|
||||
: `${prefix}: ${String(error)}`;
|
||||
}
|
||||
|
||||
class ChannelsPage extends OpenClawLightDomElement {
|
||||
@consume({ context: applicationContext, subscribe: true })
|
||||
private context!: ApplicationContext;
|
||||
@@ -356,7 +365,7 @@ class ChannelsPage extends OpenClawLightDomElement {
|
||||
this.nostrProfileFormState = {
|
||||
...currentForm,
|
||||
saving: false,
|
||||
error: `Profile update failed: ${String(err)}`,
|
||||
error: formatNostrProfileOperationError(err, "Profile update failed"),
|
||||
success: null,
|
||||
};
|
||||
}
|
||||
@@ -421,7 +430,7 @@ class ChannelsPage extends OpenClawLightDomElement {
|
||||
this.nostrProfileFormState = {
|
||||
...currentForm,
|
||||
importing: false,
|
||||
error: `Profile import failed: ${String(err)}`,
|
||||
error: formatNostrProfileOperationError(err, "Profile import failed"),
|
||||
success: null,
|
||||
};
|
||||
}
|
||||
|
||||
127
ui/src/pages/channels/nostr-profile-ops.test.ts
Normal file
127
ui/src/pages/channels/nostr-profile-ops.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { importNostrProfile, putNostrProfile } from "./nostr-profile-ops.ts";
|
||||
|
||||
const NOSTR_PROFILE_REQUEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
function requireRequestSignal(init: RequestInit | undefined): AbortSignal {
|
||||
const signal = init?.signal;
|
||||
if (!(signal instanceof AbortSignal)) {
|
||||
throw new Error("Expected Nostr profile request to carry an AbortSignal");
|
||||
}
|
||||
return signal;
|
||||
}
|
||||
|
||||
function rejectWhenAborted(signal: AbortSignal): Promise<never> {
|
||||
return new Promise((_resolve, reject) => {
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
const error = new Error("Nostr profile request timed out after 30 seconds");
|
||||
error.name = "TimeoutError";
|
||||
reject(error);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("Nostr profile HTTP operations", () => {
|
||||
it("aborts a profile PUT when response headers never arrive", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = vi.fn<typeof fetch>(async (_input, init) => {
|
||||
return await rejectWhenAborted(requireRequestSignal(init));
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const request = putNostrProfile({
|
||||
accountId: "main/account",
|
||||
headers: { Authorization: "Bearer test" },
|
||||
values: { name: "Alice" },
|
||||
});
|
||||
const result = expect(request).rejects.toMatchObject({
|
||||
name: "TimeoutError",
|
||||
message: "Nostr profile request timed out after 30 seconds",
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(NOSTR_PROFILE_REQUEST_TIMEOUT_MS);
|
||||
await result;
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/channels/nostr/main%2Faccount/profile",
|
||||
expect.objectContaining({
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer test",
|
||||
},
|
||||
body: JSON.stringify({ name: "Alice" }),
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
expect(fetchMock.mock.calls[0]?.[1]?.signal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the import deadline active while the response body is pending", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = vi.fn<typeof fetch>(async (_input, init) => {
|
||||
const signal = requireRequestSignal(init);
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => rejectWhenAborted(signal),
|
||||
} as unknown as Response;
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const request = importNostrProfile({ accountId: "default", headers: {} });
|
||||
const result = expect(request).rejects.toMatchObject({ name: "TimeoutError" });
|
||||
await vi.advanceTimersByTimeAsync(NOSTR_PROFILE_REQUEST_TIMEOUT_MS);
|
||||
await result;
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/channels/nostr/default/profile/import",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ autoMerge: true }),
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves successful JSON responses for PUT and import", async () => {
|
||||
const putResponse = new Response(JSON.stringify({ ok: true, persisted: true }), {
|
||||
status: 200,
|
||||
});
|
||||
const importResponse = new Response(
|
||||
JSON.stringify({ ok: true, saved: true, merged: { name: "Alice" } }),
|
||||
{ status: 200 },
|
||||
);
|
||||
const fetchMock = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(putResponse)
|
||||
.mockResolvedValueOnce(importResponse);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
putNostrProfile({ accountId: "default", headers: {}, values: { name: "Alice" } }),
|
||||
).resolves.toEqual({ data: { ok: true, persisted: true }, response: putResponse });
|
||||
await expect(importNostrProfile({ accountId: "default", headers: {} })).resolves.toEqual({
|
||||
data: { ok: true, saved: true, merged: { name: "Alice" } },
|
||||
response: importResponse,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the response when an error body is not JSON", async () => {
|
||||
const response = new Response("gateway unavailable", { status: 503 });
|
||||
vi.stubGlobal("fetch", vi.fn<typeof fetch>().mockResolvedValue(response));
|
||||
|
||||
await expect(importNostrProfile({ accountId: "default", headers: {} })).resolves.toEqual({
|
||||
data: null,
|
||||
response,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,41 @@
|
||||
// publishing and importing the relay profile, plus validation-error parsing.
|
||||
import type { NostrProfile } from "../../api/types.ts";
|
||||
|
||||
const NOSTR_PROFILE_REQUEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
type NostrProfileHttpResult<T> = {
|
||||
data: T | null;
|
||||
response: Response;
|
||||
};
|
||||
|
||||
async function requestNostrProfile<T>(
|
||||
url: string,
|
||||
init: Omit<RequestInit, "signal">,
|
||||
): Promise<NostrProfileHttpResult<T>> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() =>
|
||||
controller.abort(
|
||||
new DOMException("Nostr profile request timed out after 30 seconds", "TimeoutError"),
|
||||
),
|
||||
NOSTR_PROFILE_REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
try {
|
||||
const response = await fetch(url, { ...init, signal: controller.signal });
|
||||
let data: T | null = null;
|
||||
try {
|
||||
data = (await response.json()) as T;
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
throw controller.signal.reason ?? error;
|
||||
}
|
||||
}
|
||||
return { data, response };
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseValidationErrors(details: unknown): Record<string, string> {
|
||||
if (!Array.isArray(details)) {
|
||||
return {};
|
||||
@@ -33,7 +68,12 @@ export async function putNostrProfile(params: {
|
||||
headers: Record<string, string>;
|
||||
values: NostrProfile;
|
||||
}) {
|
||||
const response = await fetch(buildNostrProfileUrl(params.accountId), {
|
||||
return await requestNostrProfile<{
|
||||
ok?: boolean;
|
||||
error?: string;
|
||||
details?: unknown;
|
||||
persisted?: boolean;
|
||||
}>(buildNostrProfileUrl(params.accountId), {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -41,20 +81,19 @@ export async function putNostrProfile(params: {
|
||||
},
|
||||
body: JSON.stringify(params.values),
|
||||
});
|
||||
const data = (await response.json().catch(() => null)) as {
|
||||
ok?: boolean;
|
||||
error?: string;
|
||||
details?: unknown;
|
||||
persisted?: boolean;
|
||||
} | null;
|
||||
return { data, response };
|
||||
}
|
||||
|
||||
export async function importNostrProfile(params: {
|
||||
accountId: string;
|
||||
headers: Record<string, string>;
|
||||
}) {
|
||||
const response = await fetch(buildNostrProfileUrl(params.accountId, "/import"), {
|
||||
return await requestNostrProfile<{
|
||||
ok?: boolean;
|
||||
error?: string;
|
||||
imported?: NostrProfile;
|
||||
merged?: NostrProfile;
|
||||
saved?: boolean;
|
||||
}>(buildNostrProfileUrl(params.accountId, "/import"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -62,12 +101,4 @@ export async function importNostrProfile(params: {
|
||||
},
|
||||
body: JSON.stringify({ autoMerge: true }),
|
||||
});
|
||||
const data = (await response.json().catch(() => null)) as {
|
||||
ok?: boolean;
|
||||
error?: string;
|
||||
imported?: NostrProfile;
|
||||
merged?: NostrProfile;
|
||||
saved?: boolean;
|
||||
} | null;
|
||||
return { data, response };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user