




















@@ -243,15 +243,118 @@ function clearDeleteConfirmSkip() {
243243localStorageValues.delete("openclaw:skipDeleteConfirm");
244244}
245245246+function stubAnimationFrameQueue() {
247+const callbacks: FrameRequestCallback[] = [];
248+vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
249+callbacks.push(callback);
250+return callbacks.length;
251+});
252+return () => {
253+const pending = callbacks.splice(0);
254+for (const callback of pending) {
255+callback(performance.now());
256+}
257+};
258+}
259+260+function getLastCaptureClickListener(calls: readonly unknown[][]) {
261+for (let index = calls.length - 1; index >= 0; index--) {
262+const [type, listener, options] = calls[index] ?? [];
263+if (type === "click" && options === true && listener) {
264+return listener;
265+}
266+}
267+return null;
268+}
269+270+function countCaptureClickListenerRemovals(calls: readonly unknown[][], listener: unknown) {
271+return calls.filter(
272+([type, removedListener, options]) =>
273+type === "click" && options === true && removedListener === listener,
274+).length;
275+}
276+277+function renderDeleteConfirmFixture() {
278+const container = document.createElement("div");
279+container.dataset.deleteConfirmFixture = "true";
280+document.body.appendChild(container);
281+const onDelete = vi.fn();
282+clearDeleteConfirmSkip();
283+renderMessageGroups(
284+container,
285+[
286+createMessageGroup(
287+{
288+role: "assistant",
289+content: "hello from assistant",
290+timestamp: 1000,
291+},
292+"assistant",
293+),
294+],
295+{ onDelete },
296+);
297+const deleteButton = container.querySelector<HTMLButtonElement>(".chat-group-delete");
298+expect(deleteButton).not.toBeNull();
299+return { container, deleteButton: deleteButton!, onDelete };
300+}
301+302+function openDeleteConfirm(deleteButton: HTMLButtonElement) {
303+deleteButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));
304+}
305+306+function clickDeleteButtonIconPath(deleteButton: HTMLButtonElement) {
307+const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
308+const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
309+icon.appendChild(path);
310+deleteButton.appendChild(icon);
311+path.dispatchEvent(new MouseEvent("click", { bubbles: true }));
312+}
313+314+function setupArmedDeleteConfirm() {
315+const flushAnimationFrames = stubAnimationFrameQueue();
316+const addListenerSpy = vi.spyOn(document, "addEventListener");
317+const removeListenerSpy = vi.spyOn(document, "removeEventListener");
318+const fixture = renderDeleteConfirmFixture();
319+320+openDeleteConfirm(fixture.deleteButton);
321+flushAnimationFrames();
322+323+const outsideClickListener = getLastCaptureClickListener(addListenerSpy.mock.calls);
324+expect(outsideClickListener).not.toBeNull();
325+expect(fixture.container.querySelector(".chat-delete-confirm")).not.toBeNull();
326+327+return { ...fixture, outsideClickListener, removeListenerSpy };
328+}
329+330+function expectDeleteConfirmDismissed(params: {
331+container: HTMLElement;
332+outsideClickListener: unknown;
333+removeListenerSpy: ReturnType<typeof vi.spyOn>;
334+}) {
335+expect(params.container.querySelector(".chat-delete-confirm")).toBeNull();
336+expect(
337+countCaptureClickListenerRemovals(
338+params.removeListenerSpy.mock.calls,
339+params.outsideClickListener,
340+),
341+).toBe(1);
342+}
343+246344async function flushAssistantAttachmentAvailabilityChecks() {
247345for (let i = 0; i < 6; i++) {
248346await Promise.resolve();
249347}
250348}
251349252350afterEach(() => {
351+document.querySelectorAll("[data-delete-confirm-fixture]").forEach((element) => {
352+element.remove();
353+});
354+clearDeleteConfirmSkip();
253355vi.useRealTimers();
254356vi.unstubAllGlobals();
357+vi.restoreAllMocks();
255358});
256359257360describe("grouped chat rendering", () => {
@@ -318,6 +421,71 @@ describe("grouped chat rendering", () => {
318421expect(assistantConfirm?.classList.contains("chat-delete-confirm--right")).toBe(true);
319422});
320423424+it("removes the delete confirm outside-click listener when Cancel dismisses it", () => {
425+const fixture = setupArmedDeleteConfirm();
426+const cancel = fixture.container.querySelector<HTMLButtonElement>(
427+".chat-delete-confirm__cancel",
428+);
429+430+expect(cancel).not.toBeNull();
431+cancel?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
432+433+expectDeleteConfirmDismissed(fixture);
434+expect(fixture.onDelete).not.toHaveBeenCalled();
435+});
436+437+it("removes the delete confirm outside-click listener when Delete dismisses it", () => {
438+const fixture = setupArmedDeleteConfirm();
439+const confirm = fixture.container.querySelector<HTMLButtonElement>(".chat-delete-confirm__yes");
440+441+expect(confirm).not.toBeNull();
442+confirm?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
443+444+expectDeleteConfirmDismissed(fixture);
445+expect(fixture.onDelete).toHaveBeenCalledTimes(1);
446+});
447+448+it("removes the delete confirm outside-click listener when an outside click dismisses it", () => {
449+const fixture = setupArmedDeleteConfirm();
450+451+document.body.dispatchEvent(new MouseEvent("click", { bubbles: true }));
452+453+expectDeleteConfirmDismissed(fixture);
454+expect(fixture.onDelete).not.toHaveBeenCalled();
455+});
456+457+it("removes the delete confirm outside-click listener when the delete button toggles it", () => {
458+const fixture = setupArmedDeleteConfirm();
459+460+openDeleteConfirm(fixture.deleteButton);
461+462+expectDeleteConfirmDismissed(fixture);
463+expect(fixture.onDelete).not.toHaveBeenCalled();
464+});
465+466+it("removes the delete confirm outside-click listener when the delete button icon toggles it", () => {
467+const fixture = setupArmedDeleteConfirm();
468+469+clickDeleteButtonIconPath(fixture.deleteButton);
470+471+expectDeleteConfirmDismissed(fixture);
472+expect(fixture.onDelete).not.toHaveBeenCalled();
473+});
474+475+it("does not attach the delete confirm outside-click listener after an immediate toggle", () => {
476+const flushAnimationFrames = stubAnimationFrameQueue();
477+const addListenerSpy = vi.spyOn(document, "addEventListener");
478+const fixture = renderDeleteConfirmFixture();
479+480+openDeleteConfirm(fixture.deleteButton);
481+openDeleteConfirm(fixture.deleteButton);
482+flushAnimationFrames();
483+484+expect(fixture.container.querySelector(".chat-delete-confirm")).toBeNull();
485+expect(getLastCaptureClickListener(addListenerSpy.mock.calls)).toBeNull();
486+expect(fixture.onDelete).not.toHaveBeenCalled();
487+});
488+321489it("renders assistant context usage from input and cache tokens", () => {
322490const renderUsage = (usage: Record<string, number>, contextWindow: number) => {
323491const container = document.createElement("div");
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。