fix(webui): prevent redundant thread and media reloads (#5164)
This commit is contained in:
@@ -103,6 +103,25 @@ describe("webui API helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("aborts a WebUI thread request when its caller signal is aborted", async () => {
|
||||
let requestSignal: AbortSignal | null = null;
|
||||
vi.mocked(fetch).mockImplementation((_input, init) => new Promise((_resolve, reject) => {
|
||||
requestSignal = init?.signal ?? null;
|
||||
requestSignal?.addEventListener("abort", () => {
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
});
|
||||
}));
|
||||
const controller = new AbortController();
|
||||
|
||||
const request = fetchWebuiThread("tok", "websocket:chat-1", {
|
||||
signal: controller.signal,
|
||||
});
|
||||
controller.abort();
|
||||
|
||||
await expect(request).rejects.toMatchObject({ name: "AbortError" });
|
||||
expect(requestSignal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("percent-encodes websocket keys and paths when fetching file previews", async () => {
|
||||
await fetchFilePreview("tok", "websocket:chat-1", "/tmp/project/hook.py:12");
|
||||
|
||||
|
||||
@@ -2642,4 +2642,54 @@ describe("App layout", () => {
|
||||
expect(updateUrlSpy).toHaveBeenCalledWith("ws://test?token=tok-2");
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("reuses an in-flight pairing poll when the page becomes visible again", async () => {
|
||||
let resolvePairing!: (response: Response) => void;
|
||||
const pendingPairing = new Promise<Response>((resolve) => {
|
||||
resolvePairing = resolve;
|
||||
});
|
||||
const fetchMock = vi.fn((input: RequestInfo | URL) => (
|
||||
String(input) === "/api/settings/pairing"
|
||||
? pendingPairing
|
||||
: Promise.resolve({ ok: false, status: 404 } as Response)
|
||||
));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const visibilityDescriptor = Object.getOwnPropertyDescriptor(document, "visibilityState");
|
||||
|
||||
const setVisibility = (state: DocumentVisibilityState) => {
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
configurable: true,
|
||||
value: state,
|
||||
});
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
};
|
||||
|
||||
try {
|
||||
render(<App />);
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.mock.calls.filter(([input]) => (
|
||||
String(input) === "/api/settings/pairing"
|
||||
))).toHaveLength(1);
|
||||
});
|
||||
|
||||
act(() => setVisibility("hidden"));
|
||||
act(() => setVisibility("visible"));
|
||||
|
||||
expect(fetchMock.mock.calls.filter(([input]) => (
|
||||
String(input) === "/api/settings/pairing"
|
||||
))).toHaveLength(1);
|
||||
await act(async () => {
|
||||
resolvePairing(jsonResponse({ requests: [] }));
|
||||
await pendingPairing;
|
||||
});
|
||||
} finally {
|
||||
if (visibilityDescriptor) {
|
||||
Object.defineProperty(document, "visibilityState", visibilityDescriptor);
|
||||
} else {
|
||||
delete (document as Document & {
|
||||
visibilityState?: DocumentVisibilityState;
|
||||
}).visibilityState;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { FilePreviewPanel } from "@/components/FilePreviewPanel";
|
||||
import { setAppLanguage } from "@/i18n";
|
||||
import { fetchFilePreview } from "@/lib/api";
|
||||
|
||||
vi.mock("@/components/CodeBlock", () => ({
|
||||
@@ -34,7 +35,8 @@ vi.mock("@/lib/api", async (importOriginal) => {
|
||||
});
|
||||
|
||||
describe("FilePreviewPanel", () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
await setAppLanguage("en");
|
||||
vi.mocked(fetchFilePreview).mockReset();
|
||||
});
|
||||
|
||||
@@ -73,4 +75,32 @@ describe("FilePreviewPanel", () => {
|
||||
await user.click(closeButton);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("updates translated chrome without refetching the open file", async () => {
|
||||
vi.mocked(fetchFilePreview).mockResolvedValue({
|
||||
path: "/workspace/notes.md",
|
||||
display_path: "notes.md",
|
||||
language: "markdown",
|
||||
content: "# Notes",
|
||||
truncated: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<FilePreviewPanel
|
||||
sessionKey="websocket:chat-1"
|
||||
path="notes.md"
|
||||
token="tok"
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId("mock-code-block");
|
||||
expect(fetchFilePreview).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
await setAppLanguage("zh-CN");
|
||||
});
|
||||
|
||||
expect(fetchFilePreview).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -141,4 +141,34 @@ describe("SessionInfoPopover", () => {
|
||||
);
|
||||
expect(screen.getByText("No automations in this session yet.")).toBeInTheDocument();
|
||||
}, 8000);
|
||||
|
||||
it("coalesces focus refreshes while a session automation request is in flight", async () => {
|
||||
let resolveRequest!: (response: Response) => void;
|
||||
const pendingRequest = new Promise<Response>((resolve) => {
|
||||
resolveRequest = resolve;
|
||||
});
|
||||
const fetchMock = vi.fn(() => pendingRequest);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<SessionInfoPopover
|
||||
sessionKey="websocket:chat-1"
|
||||
token="tok"
|
||||
title="Release work"
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Session details" }));
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
window.dispatchEvent(new Event("focus"));
|
||||
window.dispatchEvent(new Event("focus"));
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
await act(async () => {
|
||||
resolveRequest(automationsResponse([]));
|
||||
await pendingRequest;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SettingsView } from "@/components/settings/SettingsView";
|
||||
@@ -439,6 +439,42 @@ describe("SettingsView Apps catalog", () => {
|
||||
expect(screen.queryByText("Settings")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("coalesces focus refreshes while automations are already loading", async () => {
|
||||
let resolveAutomations!: (response: Response) => void;
|
||||
const pendingAutomations = new Promise<Response>((resolve) => {
|
||||
resolveAutomations = resolve;
|
||||
});
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
||||
if (url === "/api/webui/automations") return pendingAutomations;
|
||||
return jsonResponse({});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
renderSettingsView({
|
||||
initialSection: "automations",
|
||||
initialSettings: settingsPayload(),
|
||||
showSidebar: false,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.mock.calls.filter(([input]) => (
|
||||
String(input) === "/api/webui/automations"
|
||||
))).toHaveLength(1);
|
||||
});
|
||||
window.dispatchEvent(new Event("focus"));
|
||||
window.dispatchEvent(new Event("focus"));
|
||||
|
||||
expect(fetchMock.mock.calls.filter(([input]) => (
|
||||
String(input) === "/api/webui/automations"
|
||||
))).toHaveLength(1);
|
||||
await act(async () => {
|
||||
resolveAutomations(jsonResponse({ jobs: [] }));
|
||||
await pendingAutomations;
|
||||
});
|
||||
});
|
||||
|
||||
it("starts the managed API server from System", async () => {
|
||||
const base = settingsPayload();
|
||||
const stopped = {
|
||||
@@ -468,7 +504,9 @@ describe("SettingsView Apps catalog", () => {
|
||||
|
||||
renderSettingsView({ initialSection: "runtime", initialSettings: base, showSidebar: true });
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Start API server" }));
|
||||
const startButton = await screen.findByRole("button", { name: "Start API server" });
|
||||
await waitFor(() => expect(startButton).toBeEnabled());
|
||||
fireEvent.click(startButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
@@ -1968,6 +2006,53 @@ describe("SettingsView Apps catalog", () => {
|
||||
expect(screen.queryByText("Peak tokens")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("coalesces focus refreshes while usage is already loading", async () => {
|
||||
const payload: SettingsPayload = {
|
||||
...settingsPayload(),
|
||||
usage: {
|
||||
days: [],
|
||||
total_tokens: 0,
|
||||
total_tokens_30d: 0,
|
||||
total_tokens_365d: 0,
|
||||
peak_day_tokens: 0,
|
||||
current_streak_days: 0,
|
||||
longest_streak_days: 0,
|
||||
active_days_30d: 0,
|
||||
requests_30d: 0,
|
||||
updated_at: null,
|
||||
},
|
||||
};
|
||||
let resolveUsage!: (response: Response) => void;
|
||||
const pendingUsage = new Promise<Response>((resolve) => {
|
||||
resolveUsage = resolve;
|
||||
});
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/settings") return jsonResponse(payload);
|
||||
if (url === "/api/settings/usage") return pendingUsage;
|
||||
return jsonResponse({});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
renderSettingsView({ initialSection: "overview", initialSettings: payload });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.mock.calls.filter(([input]) => (
|
||||
String(input) === "/api/settings/usage"
|
||||
))).toHaveLength(1);
|
||||
});
|
||||
window.dispatchEvent(new Event("focus"));
|
||||
window.dispatchEvent(new Event("focus"));
|
||||
|
||||
expect(fetchMock.mock.calls.filter(([input]) => (
|
||||
String(input) === "/api/settings/usage"
|
||||
))).toHaveLength(1);
|
||||
await act(async () => {
|
||||
resolveUsage(jsonResponse(payload.usage));
|
||||
await pendingUsage;
|
||||
});
|
||||
});
|
||||
|
||||
it("aligns token activity days with the configured timezone", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-02T18:00:00Z"));
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { act, fireEvent, render, renderHook, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SkillsMarketplace } from "@/components/settings/SkillsMarketplace";
|
||||
import {
|
||||
fetchSkills,
|
||||
fetchTrendingMarketplaceSkills,
|
||||
searchMarketplaceSkills,
|
||||
} from "@/lib/api";
|
||||
import type { NanobotClient } from "@/lib/nanobot-client";
|
||||
import { SKILLS_CHANGED_EVENT } from "@/lib/skill-events";
|
||||
import { ClientProvider } from "@/providers/ClientProvider";
|
||||
import { useSkills } from "@/hooks/useSkills";
|
||||
|
||||
vi.mock("@/lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/api")>();
|
||||
return {
|
||||
...actual,
|
||||
fetchSkills: vi.fn(),
|
||||
fetchTrendingMarketplaceSkills: vi.fn(),
|
||||
searchMarketplaceSkills: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const client = {} as NanobotClient;
|
||||
|
||||
function marketplace(token: string) {
|
||||
return (
|
||||
<ClientProvider client={client} token={token}>
|
||||
<SkillsMarketplace
|
||||
installedSkills={[]}
|
||||
installing=""
|
||||
onInstallingChange={() => {}}
|
||||
/>
|
||||
</ClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("useSkills", () => {
|
||||
it("does not let an older request overwrite a newer skill event", async () => {
|
||||
let resolveSkills!: (value: Awaited<ReturnType<typeof fetchSkills>>) => void;
|
||||
vi.mocked(fetchSkills).mockReset().mockImplementationOnce(
|
||||
() => new Promise((resolve) => {
|
||||
resolveSkills = resolve;
|
||||
}),
|
||||
);
|
||||
const installed = {
|
||||
name: "react-testing",
|
||||
description: "Test React apps.",
|
||||
source: "workspace",
|
||||
available: true,
|
||||
};
|
||||
const getToken = () => "tok";
|
||||
const { result } = renderHook(() => useSkills(getToken));
|
||||
|
||||
expect(fetchSkills).toHaveBeenCalledTimes(1);
|
||||
act(() => {
|
||||
window.dispatchEvent(new CustomEvent(SKILLS_CHANGED_EVENT, {
|
||||
detail: { skills: [installed] },
|
||||
}));
|
||||
});
|
||||
expect(result.current).toEqual([installed]);
|
||||
|
||||
await act(async () => {
|
||||
resolveSkills({ skills: [] });
|
||||
});
|
||||
|
||||
expect(result.current).toEqual([installed]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SkillsMarketplace", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(fetchTrendingMarketplaceSkills).mockReset().mockResolvedValue({
|
||||
period: "mixed",
|
||||
provider: "all",
|
||||
install_supported: true,
|
||||
skills: [],
|
||||
});
|
||||
vi.mocked(searchMarketplaceSkills).mockReset().mockImplementation(
|
||||
async (_token, query) => ({
|
||||
query,
|
||||
provider: "all",
|
||||
install_supported: true,
|
||||
skills: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("keeps loaded marketplace data stable when the auth token rotates", async () => {
|
||||
const { rerender } = render(marketplace("tok-old"));
|
||||
|
||||
await act(async () => {});
|
||||
expect(fetchTrendingMarketplaceSkills).toHaveBeenCalledTimes(1);
|
||||
expect(fetchTrendingMarketplaceSkills).toHaveBeenCalledWith("tok-old");
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Search skills" }), {
|
||||
target: { value: "React" },
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
});
|
||||
expect(searchMarketplaceSkills).toHaveBeenCalledTimes(1);
|
||||
expect(searchMarketplaceSkills).toHaveBeenLastCalledWith("tok-old", "React");
|
||||
|
||||
rerender(marketplace("tok-new"));
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
});
|
||||
|
||||
expect(fetchTrendingMarketplaceSkills).toHaveBeenCalledTimes(1);
|
||||
expect(searchMarketplaceSkills).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Search skills" }), {
|
||||
target: { value: "Vue" },
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
});
|
||||
expect(searchMarketplaceSkills).toHaveBeenCalledTimes(2);
|
||||
expect(searchMarketplaceSkills).toHaveBeenLastCalledWith("tok-new", "Vue");
|
||||
});
|
||||
});
|
||||
@@ -180,11 +180,16 @@ function makeClient() {
|
||||
};
|
||||
}
|
||||
|
||||
function wrap(client: ReturnType<typeof makeClient>, children: ReactNode, modelName?: string | null) {
|
||||
function wrap(
|
||||
client: ReturnType<typeof makeClient>,
|
||||
children: ReactNode,
|
||||
modelName?: string | null,
|
||||
token = "tok",
|
||||
) {
|
||||
return (
|
||||
<ClientProvider
|
||||
client={client as unknown as import("@/lib/nanobot-client").NanobotClient}
|
||||
token="tok"
|
||||
token={token}
|
||||
modelName={modelName ?? null}
|
||||
>
|
||||
{children}
|
||||
@@ -241,6 +246,26 @@ function httpJson(body: unknown) {
|
||||
};
|
||||
}
|
||||
|
||||
function setDocumentVisibility(value: DocumentVisibilityState): void {
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
configurable: true,
|
||||
value,
|
||||
});
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
}
|
||||
|
||||
function restoreDocumentVisibility(
|
||||
descriptor: PropertyDescriptor | undefined,
|
||||
): void {
|
||||
if (descriptor) {
|
||||
Object.defineProperty(document, "visibilityState", descriptor);
|
||||
} else {
|
||||
delete (document as Document & {
|
||||
visibilityState?: DocumentVisibilityState;
|
||||
}).visibilityState;
|
||||
}
|
||||
}
|
||||
|
||||
interface ThreadResizeObserverInstance {
|
||||
elements: Element[];
|
||||
callback: ResizeObserverCallback;
|
||||
@@ -1758,7 +1783,7 @@ describe("ThreadShell", () => {
|
||||
expect(screen.getByText("row from the expired latest window")).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
||||
act(() => client._emitSessionUpdate("window-reset-chat", "thread"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("answer in the new latest window")).toBeInTheDocument(),
|
||||
@@ -1776,7 +1801,7 @@ describe("ThreadShell", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("recovers an uncommitted reset lineage on the next foreground hydrate", async () => {
|
||||
it("recovers an uncommitted reset lineage on the next canonical hydrate", async () => {
|
||||
const client = makeClient();
|
||||
let chatACalls = 0;
|
||||
vi.stubGlobal(
|
||||
@@ -1826,7 +1851,7 @@ describe("ThreadShell", () => {
|
||||
expect(screen.getByText("committed old lineage")).toBeInTheDocument();
|
||||
expect(screen.queryByText("disjoint new lineage")).not.toBeInTheDocument();
|
||||
|
||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
||||
act(() => client._emitSessionUpdate("lineage-chat-a", "thread"));
|
||||
|
||||
await waitFor(() => expect(chatACalls).toBe(3));
|
||||
await waitFor(() => expect(screen.getByText("disjoint new lineage")).toBeInTheDocument());
|
||||
@@ -1874,7 +1899,7 @@ describe("ThreadShell", () => {
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("old canonical row")).toBeInTheDocument());
|
||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
||||
act(() => client._emitSessionUpdate("reset-tail-race", "thread"));
|
||||
await waitFor(() => expect(historyCalls).toBe(2));
|
||||
|
||||
act(() => {
|
||||
@@ -1938,7 +1963,7 @@ describe("ThreadShell", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
await waitFor(() => expect(screen.getByText("rejected local turn")).toBeInTheDocument());
|
||||
|
||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
||||
act(() => client._emitSessionUpdate("empty-reset-chat", "thread"));
|
||||
|
||||
await waitFor(() => expect(historyCalls).toBe(2));
|
||||
await waitFor(() =>
|
||||
@@ -2023,7 +2048,7 @@ describe("ThreadShell", () => {
|
||||
});
|
||||
canonicalComplete = true;
|
||||
|
||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
||||
act(() => client._emitSessionUpdate("strict-canonical", "thread"));
|
||||
|
||||
await waitFor(() => expect(screen.getByText("strict canonical answer")).toBeInTheDocument());
|
||||
expect(client.reconcileCanonicalCompletion).toHaveBeenCalledTimes(1);
|
||||
@@ -2094,7 +2119,7 @@ describe("ThreadShell", () => {
|
||||
.mockImplementationOnce(() => false)
|
||||
.mockImplementation((...args) => reconcileAfterReject?.(...args) ?? false);
|
||||
|
||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
||||
act(() => client._emitSessionUpdate("layout-recheck", "thread"));
|
||||
|
||||
await waitFor(() => expect(historyCalls).toBe(2));
|
||||
await waitFor(() =>
|
||||
@@ -2104,7 +2129,7 @@ describe("ThreadShell", () => {
|
||||
expect(screen.queryByText("layout canonical answer")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Stop response" })).toBeInTheDocument();
|
||||
|
||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
||||
act(() => client._emitSessionUpdate("layout-recheck", "thread"));
|
||||
|
||||
await waitFor(() => expect(historyCalls).toBe(3));
|
||||
await waitFor(() => expect(screen.getByText("layout canonical answer")).toBeInTheDocument());
|
||||
@@ -2258,7 +2283,7 @@ describe("ThreadShell", () => {
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("old answer")).toBeInTheDocument());
|
||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
||||
act(() => client._emitSessionUpdate("run-generation-chat", "thread"));
|
||||
await waitFor(() => expect(historyCalls).toBe(2));
|
||||
|
||||
const newTurnId = "turn-started-during-refresh";
|
||||
@@ -2351,7 +2376,7 @@ describe("ThreadShell", () => {
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText("partial")).toBeInTheDocument());
|
||||
|
||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
||||
act(() => client._emitSessionUpdate("late-frame-chat", "thread"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("canonical complete answer")).toBeInTheDocument(),
|
||||
);
|
||||
@@ -2435,7 +2460,7 @@ describe("ThreadShell", () => {
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Stop response" })).toBeInTheDocument();
|
||||
|
||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
||||
act(() => client._emitSessionUpdate("visibility-complete-a", "thread"));
|
||||
await waitFor(() => expect(screen.getByText("completed while hidden")).toBeInTheDocument());
|
||||
expect(screen.queryByRole("button", { name: "Stop response" })).not.toBeInTheDocument();
|
||||
expect(client.getRunStartedAt("visibility-complete-a")).toBeNull();
|
||||
@@ -2495,7 +2520,7 @@ describe("ThreadShell", () => {
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Stop response" })).toBeInTheDocument();
|
||||
|
||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
||||
act(() => client._emitSessionUpdate("empty-answer", "thread"));
|
||||
|
||||
await waitFor(() => expect(historyCalls).toBe(2));
|
||||
await waitFor(() => expect(client.reconcileCanonicalCompletion).toHaveBeenCalledWith(
|
||||
@@ -2717,6 +2742,7 @@ describe("ThreadShell", () => {
|
||||
it("refreshes the current thread when the page returns to the foreground", async () => {
|
||||
const client = makeClient();
|
||||
let historyCalls = 0;
|
||||
const turnId = "turn-visible-chat";
|
||||
const visibilityDescriptor = Object.getOwnPropertyDescriptor(document, "visibilityState");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
@@ -2724,16 +2750,22 @@ describe("ThreadShell", () => {
|
||||
const url = String(input);
|
||||
if (url.includes("websocket%3Avisible-chat/webui-thread")) {
|
||||
historyCalls += 1;
|
||||
return httpJson(
|
||||
transcriptFromSimpleMessages(
|
||||
return httpJson({
|
||||
...transcriptFromSimpleMessages(
|
||||
historyCalls === 1
|
||||
? [{ role: "user", content: "question" }]
|
||||
? [{ role: "user", content: "question", turnId }]
|
||||
: [
|
||||
{ role: "user", content: "question" },
|
||||
{ role: "assistant", content: "answer completed in background" },
|
||||
{ role: "user", content: "question", turnId },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "answer completed in background",
|
||||
turnId,
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
has_pending_tool_calls: historyCalls === 1,
|
||||
completed_turn_ids: historyCalls === 1 ? [] : [turnId],
|
||||
});
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
@@ -2757,22 +2789,23 @@ describe("ThreadShell", () => {
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText("question")).toBeInTheDocument());
|
||||
expect(historyCalls).toBe(1);
|
||||
act(() => {
|
||||
client._emitChat("visible-chat", {
|
||||
event: "goal_status",
|
||||
chat_id: "visible-chat",
|
||||
status: "running",
|
||||
started_at: 6_000,
|
||||
turn_id: turnId,
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
configurable: true,
|
||||
value: "hidden",
|
||||
});
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
setDocumentVisibility("hidden");
|
||||
});
|
||||
expect(historyCalls).toBe(1);
|
||||
|
||||
await act(async () => {
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
configurable: true,
|
||||
value: "visible",
|
||||
});
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
setDocumentVisibility("visible");
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
@@ -2781,11 +2814,114 @@ describe("ThreadShell", () => {
|
||||
expect(screen.getByText("answer completed in background")).toBeInTheDocument(),
|
||||
);
|
||||
} finally {
|
||||
if (visibilityDescriptor) {
|
||||
Object.defineProperty(document, "visibilityState", visibilityDescriptor);
|
||||
} else {
|
||||
delete (document as Document & { visibilityState?: DocumentVisibilityState }).visibilityState;
|
||||
}
|
||||
restoreDocumentVisibility(visibilityDescriptor);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not refresh an idle thread for visibility notifications", async () => {
|
||||
const client = makeClient();
|
||||
let historyCalls = 0;
|
||||
const visibilityDescriptor = Object.getOwnPropertyDescriptor(document, "visibilityState");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
if (String(input).includes("websocket%3Aidle-visible-chat/webui-thread")) {
|
||||
historyCalls += 1;
|
||||
return httpJson(transcriptFromSimpleMessages([
|
||||
{ role: "assistant", content: "settled answer" },
|
||||
]));
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
render(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("idle-visible-chat")}
|
||||
title="Idle visible chat"
|
||||
onToggleSidebar={() => {}}
|
||||
onNewChat={() => {}}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText("settled answer")).toBeInTheDocument());
|
||||
|
||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
||||
act(() => {
|
||||
setDocumentVisibility("hidden");
|
||||
});
|
||||
await act(async () => {
|
||||
setDocumentVisibility("visible");
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(historyCalls).toBe(1);
|
||||
} finally {
|
||||
restoreDocumentVisibility(visibilityDescriptor);
|
||||
}
|
||||
});
|
||||
|
||||
it("retries a failed hydration when the page returns to the foreground", async () => {
|
||||
const client = makeClient();
|
||||
let historyCalls = 0;
|
||||
const visibilityDescriptor = Object.getOwnPropertyDescriptor(document, "visibilityState");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
if (String(input).includes("websocket%3Aretry-visible-chat/webui-thread")) {
|
||||
historyCalls += 1;
|
||||
if (historyCalls === 1) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({}),
|
||||
};
|
||||
}
|
||||
return httpJson(transcriptFromSimpleMessages([
|
||||
{ role: "assistant", content: "recovered answer" },
|
||||
]));
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
render(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("retry-visible-chat")}
|
||||
title="Retry visible chat"
|
||||
onToggleSidebar={() => {}}
|
||||
onNewChat={() => {}}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
await waitFor(() => expect(historyCalls).toBe(1));
|
||||
|
||||
act(() => {
|
||||
setDocumentVisibility("hidden");
|
||||
});
|
||||
await act(async () => {
|
||||
setDocumentVisibility("visible");
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(historyCalls).toBe(2));
|
||||
expect(await screen.findByText("recovered answer")).toBeInTheDocument();
|
||||
} finally {
|
||||
restoreDocumentVisibility(visibilityDescriptor);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2847,7 +2983,7 @@ describe("ThreadShell", () => {
|
||||
expect(historyCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("does not refetch thread history for metadata-only session updates", async () => {
|
||||
it("keeps rendered media mounted for metadata-only session updates", async () => {
|
||||
const client = makeClient();
|
||||
let historyCalls = 0;
|
||||
vi.stubGlobal(
|
||||
@@ -2856,12 +2992,16 @@ describe("ThreadShell", () => {
|
||||
const url = String(input);
|
||||
if (url.includes("websocket%3Achat-a/webui-thread")) {
|
||||
historyCalls += 1;
|
||||
return httpJson(
|
||||
transcriptFromSimpleMessages([
|
||||
{ role: "user", content: "question" },
|
||||
{ role: "assistant", content: "answer" },
|
||||
]),
|
||||
);
|
||||
const thread = transcriptFromSimpleMessages([
|
||||
{ role: "user", content: "question" },
|
||||
{ role: "assistant", content: "answer" },
|
||||
]);
|
||||
thread.messages[1]!.media = [{
|
||||
kind: "image",
|
||||
url: "/api/media/stable/image",
|
||||
name: "answer.png",
|
||||
}];
|
||||
return httpJson(thread);
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
@@ -2884,6 +3024,7 @@ describe("ThreadShell", () => {
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("answer")).toBeInTheDocument());
|
||||
const image = screen.getByRole("img", { name: "answer.png" });
|
||||
expect(historyCalls).toBe(1);
|
||||
|
||||
await act(async () => {
|
||||
@@ -2891,6 +3032,56 @@ describe("ThreadShell", () => {
|
||||
});
|
||||
|
||||
expect(historyCalls).toBe(1);
|
||||
expect(screen.getByRole("img", { name: "answer.png" })).toBe(image);
|
||||
});
|
||||
|
||||
it("keeps rendered media mounted when the auth token rotates", async () => {
|
||||
const client = makeClient();
|
||||
let historyCalls = 0;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
if (String(input).includes("websocket%3Atoken-media/webui-thread")) {
|
||||
historyCalls += 1;
|
||||
const thread = transcriptFromSimpleMessages([
|
||||
{ role: "user", content: "question" },
|
||||
{ role: "assistant", content: "answer" },
|
||||
]);
|
||||
thread.messages[1]!.media = [{
|
||||
kind: "image",
|
||||
url: "/api/media/stable/token-image",
|
||||
name: "token-answer.png",
|
||||
}];
|
||||
return httpJson(thread);
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
const view = (token: string) => wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("token-media")}
|
||||
title="Token media"
|
||||
onToggleSidebar={() => {}}
|
||||
onNewChat={() => {}}
|
||||
/>,
|
||||
null,
|
||||
token,
|
||||
);
|
||||
const { rerender } = render(view("tok-old"));
|
||||
|
||||
await waitFor(() => expect(screen.getByText("answer")).toBeInTheDocument());
|
||||
const image = screen.getByRole("img", { name: "token-answer.png" });
|
||||
|
||||
rerender(view("tok-new"));
|
||||
await act(async () => Promise.resolve());
|
||||
|
||||
expect(historyCalls).toBe(1);
|
||||
expect(screen.getByRole("img", { name: "token-answer.png" })).toBe(image);
|
||||
});
|
||||
|
||||
it("does not scroll again when canonical history refreshes after a session update", async () => {
|
||||
@@ -3454,6 +3645,68 @@ describe("ThreadShell", () => {
|
||||
expect(screen.getByRole("option", { name: /@gimp/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not let an older catalog request overwrite a newer install event", async () => {
|
||||
const client = makeClient();
|
||||
let resolveCatalog!: (response: Response) => void;
|
||||
const pendingCatalog = new Promise<Response>((resolve) => {
|
||||
resolveCatalog = resolve;
|
||||
});
|
||||
vi.mocked(fetch).mockImplementation((input) => {
|
||||
if (String(input).includes("/api/settings/cli-apps?installed_only=1")) {
|
||||
return pendingCatalog;
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({}),
|
||||
} as Response);
|
||||
});
|
||||
render(wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("chat-cli-race")}
|
||||
title="Chat chat-cli-race"
|
||||
onToggleSidebar={() => {}}
|
||||
onGoHome={() => {}}
|
||||
onNewChat={() => {}}
|
||||
/>,
|
||||
));
|
||||
|
||||
const input = await screen.findByLabelText("Message input");
|
||||
await waitFor(() => expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/cli-apps?installed_only=1",
|
||||
expect.anything(),
|
||||
));
|
||||
const payload: CliAppsPayload = {
|
||||
apps: [{
|
||||
name: "gimp",
|
||||
display_name: "GIMP",
|
||||
category: "image",
|
||||
description: "Image editing",
|
||||
requires: "",
|
||||
source: "harness",
|
||||
entry_point: "cli-anything-gimp",
|
||||
install_supported: true,
|
||||
installed: true,
|
||||
available: true,
|
||||
status: "installed",
|
||||
logo_url: null,
|
||||
brand_color: "#5C5543",
|
||||
skill_installed: true,
|
||||
}],
|
||||
installed_count: 1,
|
||||
catalog_updated_at: "2026-07-30",
|
||||
};
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new CustomEvent(CLI_APPS_CHANGED_EVENT, { detail: payload }));
|
||||
resolveCatalog(httpJson({ apps: [], installed_count: 0 }));
|
||||
await pendingCatalog;
|
||||
});
|
||||
|
||||
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
|
||||
expect(screen.getByRole("option", { name: /@gimp/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps installed app mentions available during transient catalog refresh failures", async () => {
|
||||
const client = makeClient();
|
||||
const payload: CliAppsPayload = {
|
||||
|
||||
@@ -42,12 +42,16 @@ function fakeClient() {
|
||||
};
|
||||
}
|
||||
|
||||
function wrap(client: ReturnType<typeof fakeClient>) {
|
||||
function wrap(
|
||||
client: ReturnType<typeof fakeClient>,
|
||||
tokenSource: string | { current: string } = "tok",
|
||||
) {
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
const token = typeof tokenSource === "string" ? tokenSource : tokenSource.current;
|
||||
return (
|
||||
<ClientProvider
|
||||
client={client as unknown as import("@/lib/nanobot-client").NanobotClient}
|
||||
token="tok"
|
||||
token={token}
|
||||
>
|
||||
{children}
|
||||
</ClientProvider>
|
||||
@@ -190,6 +194,76 @@ describe("useSessions", () => {
|
||||
expect(api.listSessions).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("coalesces a same-task burst of session updates into one refresh", async () => {
|
||||
vi.mocked(api.listSessions).mockResolvedValue([]);
|
||||
const client = fakeClient();
|
||||
|
||||
const { result } = renderHook(() => useSessions(), {
|
||||
wrapper: wrap(client),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(api.listSessions).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
client.emitSessionUpdate("chat-a", "metadata");
|
||||
client.emitSessionUpdate("chat-a", "thread");
|
||||
client.emitSessionUpdate("chat-b", "metadata");
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(api.listSessions).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("runs one trailing refresh when an update arrives during a session request", async () => {
|
||||
let resolveInFlight!: (rows: []) => void;
|
||||
vi.mocked(api.listSessions)
|
||||
.mockResolvedValueOnce([])
|
||||
.mockImplementationOnce(() => new Promise((resolve) => {
|
||||
resolveInFlight = resolve;
|
||||
}))
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
key: "websocket:chat-a",
|
||||
channel: "websocket",
|
||||
chatId: "chat-a",
|
||||
createdAt: "2026-04-16T10:00:00Z",
|
||||
updatedAt: "2026-04-16T10:01:00Z",
|
||||
title: "Latest title",
|
||||
preview: "Latest preview",
|
||||
},
|
||||
]);
|
||||
const client = fakeClient();
|
||||
|
||||
const { result } = renderHook(() => useSessions(), {
|
||||
wrapper: wrap(client),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
client.emitSessionUpdate("chat-a", "metadata");
|
||||
await Promise.resolve();
|
||||
});
|
||||
await waitFor(() => expect(api.listSessions).toHaveBeenCalledTimes(2));
|
||||
|
||||
await act(async () => {
|
||||
client.emitSessionUpdate("chat-a", "thread");
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(api.listSessions).toHaveBeenCalledTimes(2);
|
||||
|
||||
await act(async () => {
|
||||
resolveInFlight([]);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(api.listSessions).toHaveBeenCalledTimes(3));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.sessions[0]?.title).toBe("Latest title");
|
||||
});
|
||||
|
||||
it("keeps a newly created chat visible until the server session list catches up", async () => {
|
||||
vi.mocked(api.listSessions)
|
||||
.mockResolvedValueOnce([])
|
||||
@@ -506,6 +580,73 @@ describe("useSessions", () => {
|
||||
expect(result.current.hasPendingToolCalls).toBe(false);
|
||||
});
|
||||
|
||||
it("does not reload history when only the auth token rotates", async () => {
|
||||
const tokenSource = { current: "tok-old" };
|
||||
vi.mocked(api.fetchWebuiThread).mockResolvedValue({
|
||||
schemaVersion: 3,
|
||||
messages: [
|
||||
{ id: "a1", role: "assistant", content: "stable", createdAt: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
() => useSessionHistory("websocket:token-rotation"),
|
||||
{ wrapper: wrap(fakeClient(), tokenSource) },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(api.fetchWebuiThread).toHaveBeenCalledTimes(1);
|
||||
expect(api.fetchWebuiThread).toHaveBeenLastCalledWith(
|
||||
"tok-old",
|
||||
"websocket:token-rotation",
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
tokenSource.current = "tok-new";
|
||||
rerender();
|
||||
await act(async () => Promise.resolve());
|
||||
expect(api.fetchWebuiThread).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => result.current.refresh());
|
||||
await waitFor(() => expect(api.fetchWebuiThread).toHaveBeenCalledTimes(2));
|
||||
expect(api.fetchWebuiThread).toHaveBeenLastCalledWith(
|
||||
"tok-new",
|
||||
"websocket:token-rotation",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("aborts a superseded latest-history request without surfacing an error", async () => {
|
||||
let firstSignal: AbortSignal | undefined;
|
||||
vi.mocked(api.fetchWebuiThread)
|
||||
.mockImplementationOnce((_token, _key, optionsOrBase) => new Promise((_resolve, reject) => {
|
||||
if (typeof optionsOrBase !== "string") firstSignal = optionsOrBase?.signal;
|
||||
firstSignal?.addEventListener("abort", () => {
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
});
|
||||
}))
|
||||
.mockResolvedValueOnce({
|
||||
schemaVersion: 3,
|
||||
messages: [
|
||||
{ id: "a2", role: "assistant", content: "latest", createdAt: 2 },
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useSessionHistory("websocket:superseded"),
|
||||
{ wrapper: wrap(fakeClient()) },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(firstSignal).toBeDefined());
|
||||
act(() => result.current.refresh());
|
||||
|
||||
await waitFor(() => expect(api.fetchWebuiThread).toHaveBeenCalledTimes(2));
|
||||
expect(firstSignal?.aborted).toBe(true);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.messages.map((message) => message.id)).toEqual(["a2"]);
|
||||
});
|
||||
|
||||
it("loads older transcript pages before the current history", async () => {
|
||||
vi.mocked(api.fetchWebuiThread)
|
||||
.mockResolvedValueOnce({
|
||||
@@ -540,10 +681,15 @@ describe("useSessions", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(api.fetchWebuiThread).toHaveBeenCalledWith("tok", "websocket:paged", {
|
||||
limit: 160,
|
||||
direction: "latest",
|
||||
});
|
||||
expect(api.fetchWebuiThread).toHaveBeenCalledWith(
|
||||
"tok",
|
||||
"websocket:paged",
|
||||
expect.objectContaining({
|
||||
limit: 160,
|
||||
direction: "latest",
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
expect(result.current.hasMoreBefore).toBe(true);
|
||||
expect(result.current.userMessageOffset).toBe(1);
|
||||
const latestVersion = result.current.version;
|
||||
@@ -554,10 +700,15 @@ describe("useSessions", () => {
|
||||
await result.current.loadOlder();
|
||||
});
|
||||
|
||||
expect(api.fetchWebuiThread).toHaveBeenLastCalledWith("tok", "websocket:paged", {
|
||||
limit: 120,
|
||||
before: "cursor-2",
|
||||
});
|
||||
expect(api.fetchWebuiThread).toHaveBeenLastCalledWith(
|
||||
"tok",
|
||||
"websocket:paged",
|
||||
expect.objectContaining({
|
||||
limit: 120,
|
||||
before: "cursor-2",
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
expect(result.current.messages.map((message) => message.content)).toEqual([
|
||||
"old question",
|
||||
"old answer",
|
||||
@@ -571,6 +722,46 @@ describe("useSessions", () => {
|
||||
expect(result.current.continuity).toBe("initial");
|
||||
});
|
||||
|
||||
it("aborts an older-history request when the consumer unmounts", async () => {
|
||||
let olderSignal: AbortSignal | undefined;
|
||||
vi.mocked(api.fetchWebuiThread)
|
||||
.mockResolvedValueOnce({
|
||||
schemaVersion: 3,
|
||||
messages: [
|
||||
{ id: "u2", role: "user", content: "latest question", createdAt: 2 },
|
||||
],
|
||||
page: {
|
||||
before_cursor: "cursor-2",
|
||||
has_more_before: true,
|
||||
loaded_message_count: 1,
|
||||
user_message_offset: 1,
|
||||
},
|
||||
})
|
||||
.mockImplementationOnce((_token, _key, optionsOrBase) => new Promise((_resolve, reject) => {
|
||||
if (typeof optionsOrBase !== "string") olderSignal = optionsOrBase?.signal;
|
||||
olderSignal?.addEventListener("abort", () => {
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
});
|
||||
}));
|
||||
|
||||
const { result, unmount } = renderHook(
|
||||
() => useSessionHistory("websocket:unmount-older"),
|
||||
{ wrapper: wrap(fakeClient()) },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
let olderRequest!: Promise<void>;
|
||||
act(() => {
|
||||
olderRequest = result.current.loadOlder();
|
||||
});
|
||||
await waitFor(() => expect(olderSignal).toBeDefined());
|
||||
|
||||
unmount();
|
||||
|
||||
expect(olderSignal?.aborted).toBe(true);
|
||||
await expect(olderRequest).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves a loaded prefix when a canonical latest window overlaps its tail", async () => {
|
||||
vi.mocked(api.fetchWebuiThread)
|
||||
.mockResolvedValueOnce({
|
||||
|
||||
Reference in New Issue
Block a user