fix(canvas): 画布批量生成 - 修复节点丢失、资源引用及刷新状态不一致
This commit is contained in:
88
web/test/canvas-editor-consistency.test.ts
Normal file
88
web/test/canvas-editor-consistency.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createCanvasStateWriter } from "@/lib/canvas/canvas-editor-state";
|
||||
import { commitCanvasGenerationResult } from "@/lib/canvas/canvas-generation-result";
|
||||
import { selectCanvasVisibleNodes } from "@/lib/canvas/canvas-node-visibility";
|
||||
import { buildCanvasSpatialIndex, canvasNodeBounds } from "@/lib/canvas/canvas-spatial-index";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
import { markCanvasTaskRecoveryUnconfirmed, resetGenerationTaskMetadata } from "@/lib/canvas/canvas-task-state";
|
||||
|
||||
const image = (id: string, x = 0): CanvasNodeData => ({ id, type: CanvasNodeType.Image, title: id, position: { x, y: 0 }, width: 20, height: 20, metadata: { taskId: `task-${id}`, status: "loading" } });
|
||||
|
||||
describe("canvas synchronous editor state", () => {
|
||||
test("commands see previous commands without waiting for React, and execute once", () => {
|
||||
const ref = { current: [] as string[] };
|
||||
const snapshots: string[][] = [];
|
||||
const write = createCanvasStateWriter(ref, (value) => snapshots.push(value));
|
||||
let calls = 0;
|
||||
write((current) => { calls++; return [...current, "first"]; });
|
||||
write((current) => { calls++; return [...current, "second"]; });
|
||||
expect(calls).toBe(2);
|
||||
expect(ref.current).toEqual(["first", "second"]);
|
||||
expect(snapshots).toEqual([["first"], ["first", "second"]]);
|
||||
});
|
||||
|
||||
test("explicit ref writes must still publish to React", () => {
|
||||
const ref = { current: [] as string[] };
|
||||
const snapshots: string[][] = [];
|
||||
const write = createCanvasStateWriter(ref, (value) => snapshots.push(value));
|
||||
ref.current = ["restored"];
|
||||
write(ref.current);
|
||||
expect(snapshots).toEqual([["restored"]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canvas generation node commits", () => {
|
||||
test("task lookup errors preserve unknown state and cannot undo success or a new binding", () => {
|
||||
const pending = image("a");
|
||||
expect(markCanvasTaskRecoveryUnconfirmed(pending, "task-a", "network unavailable").metadata).toMatchObject({ status: "loading", taskId: "task-a", errorDetails: "network unavailable" });
|
||||
expect(markCanvasTaskRecoveryUnconfirmed(pending, "old-task", "missing")).toBe(pending);
|
||||
const success: CanvasNodeData = { ...pending, metadata: { ...pending.metadata, status: "success", storageKey: "resource:a" } };
|
||||
expect(markCanvasTaskRecoveryUnconfirmed(success, "task-a", "missing")).toBe(success);
|
||||
expect(resetGenerationTaskMetadata({ ...success.metadata, taskStatus: "failed" }, "loading")).toMatchObject({ storageKey: "resource:a", status: "loading" });
|
||||
expect(resetGenerationTaskMetadata(success.metadata).taskId).toBeUndefined();
|
||||
});
|
||||
test("five concurrent results preserve two successes, three failures, and unrelated edits", async () => {
|
||||
const before = Array.from({ length: 5 }, (_, i) => image(String(i)));
|
||||
const ref = { current: [...before, image("new")] };
|
||||
const write = createCanvasStateWriter(ref, () => {});
|
||||
await Promise.all(before.map(async (node, i) => {
|
||||
await Promise.resolve();
|
||||
const result: CanvasNodeData = { ...node, metadata: { ...node.metadata, status: i < 2 ? "success" : "error", storageKey: i < 2 ? `resource:${i}` : undefined } };
|
||||
write((current) => commitCanvasGenerationResult(current, node, result, `task-${i}`));
|
||||
}));
|
||||
expect(ref.current.filter((node) => node.metadata?.status === "success")).toHaveLength(2);
|
||||
expect(ref.current.filter((node) => node.metadata?.status === "error")).toHaveLength(3);
|
||||
expect(ref.current.at(-1)?.id).toBe("new");
|
||||
});
|
||||
|
||||
test("keeps movement, manual resize, rename and other node deletion during materialization", () => {
|
||||
const before = image("a");
|
||||
const live = { ...before, title: "renamed", position: { x: 300, y: 400 }, width: 600 };
|
||||
const result: CanvasNodeData = { ...before, width: 100, title: "generated", metadata: { ...before.metadata, status: "success", storageKey: "resource:a" } };
|
||||
const committed = commitCanvasGenerationResult([live], before, result, "task-a");
|
||||
expect(committed).toHaveLength(1);
|
||||
expect(committed[0]).toMatchObject({ title: "renamed", position: live.position, width: 600, metadata: { storageKey: "resource:a" } });
|
||||
});
|
||||
|
||||
test("does not resurrect deleted nodes or overwrite a reset/new task", () => {
|
||||
const before = image("a");
|
||||
expect(() => commitCanvasGenerationResult([], before, before, "task-a")).toThrow();
|
||||
expect(() => commitCanvasGenerationResult([{ ...before, metadata: { taskId: "new" } }], before, before, "task-a")).toThrow();
|
||||
expect(() => commitCanvasGenerationResult([{ ...before, metadata: {} }], before, before, "task-a")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("canvas viewport selection", () => {
|
||||
test("hidden and overscan nodes cannot consume the viewport budget", () => {
|
||||
const nodes = [...Array.from({ length: 400 }, (_, i) => image(`hidden-${i}`)), ...Array.from({ length: 400 }, (_, i) => image(`buffer-${i}`, -100)), ...Array.from({ length: 1000 }, (_, i) => image(`visible-${i}`)), image("forced", 2000)];
|
||||
const index = buildCanvasSpatialIndex(nodes.map((node) => ({ id: node.id, value: node.id, bounds: canvasNodeBounds(node) })));
|
||||
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
||||
const view = { left: 0, top: 0, right: 100, bottom: 100 };
|
||||
const retain = { ...view, left: -200, right: 200 };
|
||||
const selected = selectCanvasVisibleNodes({ index, nodeById, view, enter: retain, retain, hiddenIds: new Set(nodes.filter((node) => node.id.startsWith("hidden")).map((node) => node.id)), retainedIds: new Set(), forcedIds: new Set(["forced"]), budget: 280 });
|
||||
expect(selected).toHaveLength(1001);
|
||||
expect(selected.filter((node) => node.id.startsWith("visible"))).toHaveLength(1000);
|
||||
expect(selected.at(-1)?.id).toBe("forced");
|
||||
expect(nodes).toHaveLength(1801);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,10 @@ function node(id: string, x: number, y: number, width = 340, height = 240): Canv
|
||||
}
|
||||
|
||||
describe("findAvailableGenerationGroupPosition", () => {
|
||||
test("已有存储标识但预览未恢复的媒体不能当作空节点覆盖", () => {
|
||||
expect(canGenerateImageInPlace({ ...node("stored", 0, 0), metadata: { storageKey: "resource:image" } })).toBe(false);
|
||||
expect(canGenerateMediaInPlace({ ...node("video", 0, 0), type: CanvasNodeType.Video, metadata: { storageKey: "resource:video" } }, CanvasNodeType.Video)).toBe(false);
|
||||
});
|
||||
test("首选位置没有占用时保持原坐标", () => {
|
||||
expect(findAvailableGenerationGroupPosition([node("source", 0, 0)], { x: 436, y: 0 }, { width: 340, height: 240 })).toEqual({ x: 436, y: 0 });
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { applyGeneratedMediaResultMetadata, videoMetadata } from "@/lib/canvas/canvas-generation-task-sync";
|
||||
import { applyGeneratedMediaResultMetadata, applyGenerationTaskResultToNodes, videoMetadata } from "@/lib/canvas/canvas-generation-task-sync";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
function videoNode(assetId: string, storageKey: string): CanvasNodeData {
|
||||
@@ -16,6 +16,12 @@ function videoNode(assetId: string, storageKey: string): CanvasNodeData {
|
||||
}
|
||||
|
||||
describe("applyGeneratedMediaResultMetadata", () => {
|
||||
test("明确指定的节点已删除时不能根据 taskId 误更新其他节点", async () => {
|
||||
const other = { ...videoNode("asset", "video:key"), metadata: { taskId: "task" } };
|
||||
const applied = await applyGenerationTaskResultToNodes([other], { id: "task", type: "canvas_video", status: "succeeded", prompt: "", attempts: 1, createdAt: "", updatedAt: "" }, "deleted-node");
|
||||
expect(applied.updated).toBe(false);
|
||||
expect(applied.nodes).toEqual([other]);
|
||||
});
|
||||
test("clears the previous asset binding when a regenerated media result lands", () => {
|
||||
const node = videoNode("asset-old", "video:old");
|
||||
const next = applyGeneratedMediaResultMetadata(node, videoMetadata({
|
||||
|
||||
@@ -17,6 +17,32 @@ function imageNode(id: string, status: CanvasNodeStatus, metadata: Partial<NonNu
|
||||
}
|
||||
|
||||
describe("canvas image batch retry", () => {
|
||||
test("旧批次索引不能删除属于其他批次的节点", () => {
|
||||
const root = imageNode("root", "loading", { isBatchRoot: true, batchChildIds: ["foreign"] });
|
||||
const foreign = imageNode("foreign", "loading", { batchRootId: "other-root" });
|
||||
expect(retireImageBatchChildren(root, [root, foreign], []).removedIds).toEqual([]);
|
||||
expect(cancelIncompleteImageBatch(root.id, [foreign.id], [root, foreign], []).nodes).toContain(foreign);
|
||||
});
|
||||
test("清理和取消只移除无持久资源的占位,不能删除尚未恢复预览的成功图片", () => {
|
||||
const root = imageNode("root", "loading", { isBatchRoot: true, batchChildIds: ["done", "pending"] });
|
||||
const done = imageNode("done", "success", { batchRootId: root.id, storageKey: "resource:done", assetId: "asset-done" });
|
||||
const pending = imageNode("pending", "loading", { batchRootId: root.id });
|
||||
const nodes = [root, done, pending];
|
||||
expect(retireImageBatchChildren(root, nodes, []).removedIds).toEqual(["pending"]);
|
||||
expect(cancelIncompleteImageBatch(root.id, ["done", "pending"], nodes, []).removedIds).toEqual(["pending"]);
|
||||
expect(reconcileImageBatchRoot(root, nodes).metadata).toMatchObject({ status: "success", storageKey: "resource:done", assetId: "asset-done", primaryImageId: "done" });
|
||||
});
|
||||
|
||||
test("刷新后五张图两成三败,根节点保留成功资源及准确失败数", () => {
|
||||
const children = Array.from({ length: 5 }, (_, i) => imageNode(`child-${i}`, i < 2 ? "success" : "error", {
|
||||
batchRootId: "root", ...(i < 2 ? { storageKey: `resource:${i}`, assetId: `asset-${i}` } : { errorDetails: "上游失败" }),
|
||||
}));
|
||||
const root = imageNode("root", "error", { isBatchRoot: true, batchChildIds: children.map((node) => node.id), taskId: "old-task", taskStatus: "failed" });
|
||||
const next = reconcileImageBatchRoot(root, [root, ...children]);
|
||||
expect(next.metadata).toMatchObject({ status: "success", storageKey: "resource:0", batchFailedCount: 3 });
|
||||
expect(next.metadata.taskId).toBeUndefined();
|
||||
});
|
||||
|
||||
test("只按批次顺序返回属于当前根节点的失败图片", () => {
|
||||
const root = imageNode("root", "error", { isBatchRoot: true, batchChildIds: ["failed-2", "success", "failed-1", "loading", "foreign"] });
|
||||
const nodes = [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { buildNodeGenerationContext } from "../src/components/canvas/canvas-node-generation";
|
||||
import { buildCanvasResourceReferences } from "../src/lib/canvas/canvas-resource-references";
|
||||
import { buildCanvasResourceReferences, getGenerationResourceNodes } from "../src/lib/canvas/canvas-resource-references";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../src/types/canvas";
|
||||
|
||||
function node(id: string, type: CanvasNodeType, content: string): CanvasNodeData {
|
||||
@@ -33,6 +33,40 @@ function connection(fromNodeId: string): CanvasConnection {
|
||||
}
|
||||
|
||||
describe("canvas node generation position mentions", () => {
|
||||
test("父图无预览时仍可继承输入,显式子图输入优先且去重", () => {
|
||||
const a = node("a", CanvasNodeType.Image, "");
|
||||
a.metadata = { storageKey: "resource:a" };
|
||||
const b = node("b", CanvasNodeType.Image, "b");
|
||||
const root = node("root", CanvasNodeType.Image, "");
|
||||
root.metadata = { isBatchRoot: true };
|
||||
const child = node("child", CanvasNodeType.Image, "");
|
||||
child.metadata = { batchRootId: root.id };
|
||||
const nodes = [a, b, root, child];
|
||||
const connections = [{ id: "a-root", fromNodeId: a.id, toNodeId: root.id }, { id: "group", fromNodeId: root.id, toNodeId: child.id }];
|
||||
expect(getGenerationResourceNodes(child.id, nodes, connections).map((item) => item.id)).toEqual(["a"]);
|
||||
expect(getGenerationResourceNodes(child.id, nodes, [...connections, { id: "b-child", fromNodeId: b.id, toNodeId: child.id }, { id: "b-child-duplicate", fromNodeId: b.id, toNodeId: child.id }]).map((item) => item.id)).toEqual(["b"]);
|
||||
root.metadata = { isBatchRoot: true, batchRootId: child.id };
|
||||
child.metadata = { isBatchRoot: true, batchRootId: root.id };
|
||||
expect(getGenerationResourceNodes(child.id, nodes, [])).toEqual([]);
|
||||
});
|
||||
test("批次子图沿父批次继承原始参考图,不把结果分组边当作参考图", () => {
|
||||
const a = node("a", CanvasNodeType.Image, "data:image/png;base64,a");
|
||||
const b = node("b", CanvasNodeType.Image, "data:image/png;base64,b");
|
||||
const root = node("root", CanvasNodeType.Image, "data:image/png;base64,result");
|
||||
root.metadata = { ...root.metadata, isBatchRoot: true, batchChildIds: ["child"] };
|
||||
const child = node("child", CanvasNodeType.Image, "");
|
||||
child.metadata = { batchRootId: root.id, composerContent: "结合 @图片1 和 @图片2" };
|
||||
const nodes = [a, b, root, child];
|
||||
const connections = [
|
||||
{ id: "a-root", fromNodeId: a.id, toNodeId: root.id },
|
||||
{ id: "b-root", fromNodeId: b.id, toNodeId: root.id },
|
||||
{ id: "group", fromNodeId: root.id, toNodeId: child.id },
|
||||
];
|
||||
const context = buildNodeGenerationContext(child.id, nodes, connections, child.metadata.composerContent!, []);
|
||||
expect(context.referenceImages.map((image) => image.id)).toEqual([a.id, b.id]);
|
||||
expect(buildCanvasResourceReferences(nodes, connections, child.id).filter((ref) => ref.active).map((ref) => ref.nodeId)).toEqual([a.id, b.id]);
|
||||
});
|
||||
|
||||
test("Agent 保存的素材引用块在编辑器和再次提交时保持相同编号", () => {
|
||||
const target = targetNode();
|
||||
const character = node("character", CanvasNodeType.Image, "data:image/png;base64,a");
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import { expect, test } from "bun:test";
|
||||
|
||||
type Scenario = "image-cleanup" | "scope-cleanup-switch" | "scope-cleanup-late-canvas-reference" | "video-commit-race" | "audio-commit-race";
|
||||
type Scenario = "image-cleanup" | "scope-cleanup-switch" | "scope-cleanup-late-canvas-reference" | "video-commit-race" | "audio-commit-race" | "canvas-batch-commit-race";
|
||||
type ScenarioResponse<T> = { ok: true; result: T } | { ok: false; error: string };
|
||||
|
||||
test("并发生成结果经真实持久化消费链路及重新读取后保留成功、失败和用户编辑", async () => {
|
||||
type Node = import("../src/types/canvas").CanvasNodeData;
|
||||
const result = await runScenario<{ edited: boolean; live: Node[]; restored: Node[] }>("canvas-batch-commit-race");
|
||||
expect(result.edited).toBe(true);
|
||||
for (const nodes of [result.live, result.restored]) {
|
||||
expect(nodes).toHaveLength(6);
|
||||
expect(nodes.filter((node) => node.metadata?.status === "success")).toHaveLength(2);
|
||||
expect(nodes.filter((node) => node.metadata?.status === "error")).toHaveLength(3);
|
||||
expect(nodes.find((node) => node.id === "node-0")).toMatchObject({ title: "用户改名", position: { x: 700, y: 500 }, metadata: { assetId: "asset-0" } });
|
||||
expect(nodes.find((node) => node.id === "node-1")?.metadata?.assetId).toBe("asset-1");
|
||||
}
|
||||
});
|
||||
|
||||
function runScenario<T>(scenario: Scenario): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const worker = new Worker(new URL("./helpers/generation-storage-consistency.worker.ts", import.meta.url).href, { type: "module" });
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getActiveUserScope, setActiveUserScope } from "../../src/lib/user-scope
|
||||
|
||||
type InstanceHook = (storeName: string, key: string, value: unknown) => Promise<void> | void;
|
||||
|
||||
type Scenario = "image-cleanup" | "scope-cleanup-switch" | "scope-cleanup-late-canvas-reference" | "video-commit-race" | "audio-commit-race";
|
||||
type Scenario = "image-cleanup" | "scope-cleanup-switch" | "scope-cleanup-late-canvas-reference" | "video-commit-race" | "audio-commit-race" | "canvas-batch-commit-race";
|
||||
|
||||
function installStorageHarness() {
|
||||
const originalCreateInstance = localforage.createInstance.bind(localforage);
|
||||
@@ -35,6 +35,7 @@ function installStorageHarness() {
|
||||
localforage.getItem = (async (key: string) => defaultValues.get(key) ?? null) as typeof localforage.getItem;
|
||||
localforage.setItem = (async (key: string, value: unknown) => {
|
||||
defaultValues.set(key, value);
|
||||
await hooks.onSet?.("app_state", key, value);
|
||||
return value;
|
||||
}) as typeof localforage.setItem;
|
||||
localforage.removeItem = (async (key: string) => {
|
||||
@@ -392,6 +393,49 @@ async function runMediaCommitRace(mediaType: "video" | "audio") {
|
||||
}
|
||||
}
|
||||
|
||||
async function runCanvasBatchCommitRace() {
|
||||
const harness = installStorageHarness();
|
||||
const previousScope = getActiveUserScope();
|
||||
let unregister: (() => void) | undefined;
|
||||
try {
|
||||
setActiveUserScope("canvas-batch-commit-race");
|
||||
const { useCanvasStore, flushCanvasStorePersistence, CANVAS_STORE_KEY } = await import("../../src/stores/canvas/use-canvas-store");
|
||||
const { useAssetStore } = await import("../../src/stores/use-asset-store");
|
||||
const { applyCanvasGenerationTaskNodeEffect, registerCanvasGenerationLiveProject } = await import("../../src/services/canvas-generation-consumer");
|
||||
const { createCanvasStateWriter } = await import("../../src/lib/canvas/canvas-editor-state");
|
||||
const { localForageStorageForScope } = await import("../../src/lib/localforage-storage");
|
||||
const { parseCanvasStorageDocument } = await import("../../src/lib/canvas/canvas-storage-revision");
|
||||
const { CanvasNodeType } = await import("../../src/types/canvas");
|
||||
const projectId = useCanvasStore.getState().createProject("batch race");
|
||||
const nodes = Array.from({ length: 5 }, (_, index) => ({ id: `node-${index}`, type: CanvasNodeType.Image, title: `image-${index}`, position: { x: index * 400, y: 0 }, width: 320, height: 240, metadata: { taskId: `task-${index}`, status: index < 2 ? "loading" as const : "error" as const } }));
|
||||
const ref: { current: import("../../src/types/canvas").CanvasNodeData[] } = { current: nodes };
|
||||
const setNodes = createCanvasStateWriter(ref, () => {});
|
||||
useCanvasStore.getState().updateProject(projectId, { nodes });
|
||||
await flushCanvasStorePersistence();
|
||||
useAssetStore.setState({ assets: [0, 1].map((index) => ({ id: `asset-${index}`, kind: "image" as const, title: "generated", coverUrl: "/image.png", tags: [], createdAt: "2026-09-21T00:00:00Z", updatedAt: "2026-09-21T00:00:00Z", data: { dataUrl: "/image.png", storageKey: `resource:image-${index}`, width: 640, height: 480, bytes: 100, mimeType: "image/png" } })) });
|
||||
unregister = registerCanvasGenerationLiveProject({ scope: getActiveUserScope(), projectId, adapter: { read: () => ({ nodes: ref.current, connections: [], chatSessions: [], activeChatId: null }), write: (state) => setNodes(state.nodes) } });
|
||||
let edited = false;
|
||||
harness.hooks.onSet = (storeName, _key, value) => {
|
||||
if (edited || storeName !== "app_state" || typeof value !== "string" || !value.includes("attach:task-")) return;
|
||||
edited = true;
|
||||
setNodes((current) => [...current.map((node) => node.id === "node-0" ? { ...node, title: "用户改名", position: { x: 700, y: 500 } } : node), { ...nodes[0], id: "new-during-save", metadata: {} }]);
|
||||
};
|
||||
await Promise.all([0, 1].map((index) => applyCanvasGenerationTaskNodeEffect({
|
||||
projectId, nodeId: `node-${index}`, nodesRef: ref, setNodes,
|
||||
task: { id: `task-${index}`, projectId, type: "canvas_image", status: "succeeded", prompt: "test", attempts: 1, createdAt: "2026-09-21T00:00:00Z", updatedAt: "2026-09-21T00:00:01Z", resultJson: "{}" },
|
||||
output: { outputIndex: 0, mediaType: "image", materializedAssetId: `asset-${index}` }, effectKey: `attach:task-${index}:0`,
|
||||
})));
|
||||
useCanvasStore.getState().updateProject(projectId, { nodes: ref.current });
|
||||
await flushCanvasStorePersistence();
|
||||
const stored = parseCanvasStorageDocument(await localForageStorageForScope(getActiveUserScope()).getItem(CANVAS_STORE_KEY), []);
|
||||
return { edited, live: ref.current, restored: stored.state.projects.find((project) => project.id === projectId)?.nodes };
|
||||
} finally {
|
||||
unregister?.();
|
||||
setActiveUserScope(previousScope);
|
||||
harness.restore();
|
||||
}
|
||||
}
|
||||
|
||||
self.onmessage = async (event: MessageEvent<Scenario>) => {
|
||||
try {
|
||||
const result =
|
||||
@@ -401,7 +445,9 @@ self.onmessage = async (event: MessageEvent<Scenario>) => {
|
||||
? await runScopeCleanupAfterSwitch()
|
||||
: event.data === "scope-cleanup-late-canvas-reference"
|
||||
? await runScopeCleanupAfterLateCanvasReference()
|
||||
: await runMediaCommitRace(event.data === "audio-commit-race" ? "audio" : "video");
|
||||
: event.data === "canvas-batch-commit-race"
|
||||
? await runCanvasBatchCommitRace()
|
||||
: await runMediaCommitRace(event.data === "audio-commit-race" ? "audio" : "video");
|
||||
self.postMessage({ ok: true, result });
|
||||
} catch (error) {
|
||||
self.postMessage({ ok: false, error: error instanceof Error ? `${error.name}: ${error.message}` : String(error) });
|
||||
|
||||
Reference in New Issue
Block a user