fix(webui): restore session drag and review findings

This commit is contained in:
chengyongru
2026-08-12 17:26:13 +08:00
committed by chengyongru
parent 4b5319b760
commit 686dd0603e
24 changed files with 494 additions and 169 deletions
@@ -105,8 +105,11 @@ describe("channel locale registry", () => {
const i18nEntry = readFileSync(resolve(process.cwd(), "src/i18n/index.ts"), "utf8");
expect(localeRegistry).toContain("webui/locales/*.json");
expect(localeRegistry).not.toContain("eager: true");
expect(localeRegistry).not.toMatch(/channel-plugins\/registry|\.tsx|\breact\b/i);
expect(i18nEntry).toContain("channel-plugins/locale-registry");
expect(i18nEntry).toContain("import.meta.glob");
expect(i18nEntry).not.toMatch(/import\s+\w+Common\s+from/);
expect(i18nEntry).not.toContain("channel-plugins/registry");
});
});
+4 -3
View File
@@ -12,8 +12,8 @@ import {
describe("channel UI contributions", () => {
it("selects channel-owned UI only through the backend manifest entry", () => {
expect(channelUiContribution("feishu", "webui/index.tsx")?.Panel).toBeTypeOf("function");
expect(channelUiContribution("weixin", "webui/index.tsx")?.ConnectFlow).toBeTypeOf("function");
expect(channelUiContribution("feishu", "webui/index.tsx")?.Panel).toBeDefined();
expect(channelUiContribution("weixin", "webui/index.tsx")?.ConnectFlow).toBeDefined();
expect(channelUiContribution("feishu", undefined)).toBeUndefined();
expect(channelUiContribution("feishu", "webui/missing.tsx")).toBeUndefined();
expect(channelUiContribution("missing", "webui/index.tsx")).toBeUndefined();
@@ -56,7 +56,8 @@ describe("channel UI contributions", () => {
"utf8",
);
expect(source).toContain("../../../nanobot/channels/*/webui/**/*.{ts,tsx}");
expect(source).toContain("../../../nanobot/channels/*/webui/index.{ts,tsx}");
expect(source).not.toContain("webui/**/*.{ts,tsx}");
expect(source).not.toContain('"./*/index.tsx"');
});
+19 -4
View File
@@ -2,6 +2,7 @@ import { fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ChatList } from "@/components/ChatList";
import { readDraggedSession, SESSION_DRAG_TYPE } from "@/lib/session-drag";
import type { ChatSummary } from "@/lib/types";
function session(overrides: Partial<ChatSummary>): ChatSummary {
@@ -24,7 +25,7 @@ describe("ChatList", () => {
vi.unstubAllGlobals();
});
it("keeps tabs and panes outside every drag-and-drop protocol", () => {
it("keeps tab grouping out of drag protocols while exposing inactive panes as mention sources", () => {
render(
<ChatList
sessions={[session({ chatId: "root", title: "Root topic" })]}
@@ -33,7 +34,7 @@ describe("ChatList", () => {
"websocket:root": {
tabKey: "websocket:root",
title: "Root topic",
activePaneKey: "websocket:child",
activePaneKey: "websocket:root",
panes: [
{ key: "websocket:root", chatId: "root", title: "Root topic" },
{ key: "websocket:child", chatId: "child", title: "Research pane" },
@@ -50,8 +51,22 @@ describe("ChatList", () => {
expect(screen.getByRole("button", { name: "Tab: Root topic" }))
.toHaveAttribute("draggable", "false");
expect(screen.getByRole("button", { name: "Research pane" }))
.toHaveAttribute("draggable", "false");
const pane = screen.getByRole("button", { name: "Research pane" });
expect(pane).toHaveAttribute("draggable", "true");
const dataTransfer = {
effectAllowed: "none",
setData: vi.fn(),
getData: vi.fn(() => ""),
types: [],
} as unknown as DataTransfer;
fireEvent.dragStart(pane, { dataTransfer });
expect(dataTransfer.setData).toHaveBeenCalledWith(
SESSION_DRAG_TYPE,
"websocket:child",
);
expect(readDraggedSession(dataTransfer)).toBe("websocket:child");
fireEvent.dragEnd(pane, { dataTransfer });
expect(readDraggedSession(dataTransfer)).toBeNull();
expect(document.querySelector("[data-pane-drag-overlay]")).not.toBeInTheDocument();
expect(document.querySelector("[data-pane-snap-slot]")).not.toBeInTheDocument();
});
+15
View File
@@ -0,0 +1,15 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
describe("index.html", () => {
it("keeps browser zoom available", () => {
const html = readFileSync(resolve(process.cwd(), "index.html"), "utf8");
const viewport = html.match(/<meta\s+name="viewport"\s+content="([^"]+)"/i)?.[1];
expect(viewport).toContain("width=device-width");
expect(viewport).not.toContain("user-scalable=no");
expect(viewport).not.toMatch(/maximum-scale\s*=\s*1(?:\.0)?(?:,|$)/);
});
});
+4 -1
View File
@@ -1,3 +1,4 @@
import { waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const render = vi.fn();
@@ -37,6 +38,8 @@ describe("main entry crypto shim", () => {
expect(globalThis.crypto.randomUUID()).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);
expect(createRoot).toHaveBeenCalledWith(document.getElementById("root"));
await waitFor(() => {
expect(createRoot).toHaveBeenCalledWith(document.getElementById("root"));
});
});
});
+16 -4
View File
@@ -270,7 +270,11 @@ describe("Settings channels", () => {
expect(await screen.findByRole("button", { name: "View Feishu settings" })).toBeInTheDocument();
expect(screen.queryByText("nanobot channels login feishu")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "nanobot" }));
fireEvent.click(await screen.findByRole(
"button",
{ name: "nanobot" },
{ timeout: 3_000 },
));
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
await waitFor(() =>
@@ -324,7 +328,11 @@ describe("Settings channels", () => {
renderSettingsView({ initialSection: "channels" });
expect(await screen.findByRole("button", { name: "View Feishu settings" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "nanobot" }));
fireEvent.click(await screen.findByRole(
"button",
{ name: "nanobot" },
{ timeout: 3_000 },
));
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
await waitFor(() =>
@@ -1158,9 +1166,13 @@ describe("Settings channels", () => {
for (const [, displayName, guideLabel] of channels) {
fireEvent.click(await screen.findByRole("button", { name: `View ${displayName} settings` }));
if (displayName === "Feishu") {
fireEvent.click(screen.getByRole("button", { name: "nanobot" }));
fireEvent.click(await screen.findByRole(
"button",
{ name: "nanobot" },
{ timeout: 3_000 },
));
}
const guide = screen.getByRole("link", { name: guideLabel });
const guide = await screen.findByRole("link", { name: guideLabel });
expect(guide).toHaveAttribute("href", expect.stringMatching(/^https:\/\//));
expect(guide.querySelector("span[aria-hidden] img, span[aria-hidden] svg")).not.toBeNull();
}
+7 -2
View File
@@ -1,7 +1,7 @@
import "@testing-library/jest-dom/vitest";
import { beforeEach } from "vitest";
import { beforeAll, beforeEach } from "vitest";
import i18n from "@/i18n";
import i18n, { initializeI18n, loadAllLocaleResources } from "@/i18n";
function createTestStorage(): Storage {
const store = new Map<string, string>();
@@ -53,6 +53,11 @@ if (!("randomUUID" in globalThis.crypto)) {
});
}
beforeAll(async () => {
await initializeI18n();
await loadAllLocaleResources();
});
beforeEach(async () => {
await i18n.changeLanguage("en");
document.documentElement.lang = "en";
+45 -1
View File
@@ -126,6 +126,7 @@ describe("service worker", () => {
expect(sw.skipWaitingMock).toHaveBeenCalledTimes(1);
expect(sw.store.entries.has(`${ORIGIN}/`)).toBe(true);
expect(sw.store.entries.has(`${ORIGIN}/manifest.json`)).toBe(true);
expect(sw.store.entries.has(`${ORIGIN}/asset-manifest.json`)).toBe(true);
});
it("removes stale cache names and prunes unreferenced static entries on activate", async () => {
@@ -135,8 +136,16 @@ describe("service worker", () => {
await sw.store.put(`${ORIGIN}/`, indexHtml(["/assets/index-v2.js", "/assets/index-v2.css"]));
await sw.store.put(`${ORIGIN}/assets/index-v2.js`, new Response("v2"));
await sw.store.put(`${ORIGIN}/assets/index-v2.css`, new Response("v2 css"));
await sw.store.put(`${ORIGIN}/assets/lazy-v2.js`, new Response("lazy v2"));
await sw.store.put(`${ORIGIN}/assets/index-v1.js`, new Response("v1"));
await sw.store.put(`${ORIGIN}/assets/index-v1.css`, new Response("v1 css"));
await sw.store.put(`${ORIGIN}/asset-manifest.json`, new Response(JSON.stringify({
"src/main.tsx": {
file: "assets/index-v2.js",
css: ["assets/index-v2.css"],
},
"src/lazy.tsx": { file: "assets/lazy-v2.js", isDynamicEntry: true },
})));
await sw.fire("activate");
@@ -144,8 +153,10 @@ describe("service worker", () => {
expect(sw.claimMock).toHaveBeenCalledTimes(1);
expect(sw.store.entries.has(`${ORIGIN}/`)).toBe(true);
expect(sw.store.entries.has(`${ORIGIN}/manifest.json`)).toBe(true);
expect(sw.store.entries.has(`${ORIGIN}/asset-manifest.json`)).toBe(true);
expect(sw.store.entries.has(`${ORIGIN}/assets/index-v2.js`)).toBe(true);
expect(sw.store.entries.has(`${ORIGIN}/assets/index-v2.css`)).toBe(true);
expect(sw.store.entries.has(`${ORIGIN}/assets/lazy-v2.js`)).toBe(true);
expect(sw.store.entries.has(`${ORIGIN}/assets/index-v1.js`)).toBe(false);
expect(sw.store.entries.has(`${ORIGIN}/assets/index-v1.css`)).toBe(false);
});
@@ -235,6 +246,31 @@ describe("service worker", () => {
expect(sw.store.entries.has(iconUrl)).toBe(true);
});
it("clones network responses before yielding their body to the browser", async () => {
const sw = loadSw();
const request = new Request(`${ORIGIN}/brand/nanobot_icon_192.png`);
let browserOwnsBody = false;
let clonedBeforeBrowser = false;
const response = {
ok: true,
clone: vi.fn(() => {
clonedBeforeBrowser = !browserOwnsBody;
return new Response("cached png bytes");
}),
} as unknown as Response;
sw.fetchMock.mockResolvedValue(response);
const respondWith = vi.fn((pending: Promise<Response>) => {
void pending.then(() => {
browserOwnsBody = true;
});
});
await sw.fire("fetch", { request, respondWith });
expect(clonedBeforeBrowser).toBe(true);
expect(response.clone).toHaveBeenCalledTimes(1);
});
it("serves navigation network-first, prunes on shell refresh, and falls back when offline", async () => {
const sw = loadSw();
await sw.store.put(`${ORIGIN}/`, indexHtml(["/assets/index-v2.js"]));
@@ -255,7 +291,15 @@ describe("service worker", () => {
// Online: network response wins, refreshes the cached shell, and prunes
// entries the new index.html no longer references.
const freshShell = indexHtml(["/assets/index-v3.js"]);
sw.fetchMock.mockResolvedValue(freshShell);
sw.fetchMock.mockImplementation((input: Request | string) => {
const url = new URL(typeof input === "string" ? input : input.url, ORIGIN);
if (url.pathname === "/asset-manifest.json") {
return Promise.resolve(new Response(JSON.stringify({
"src/main.tsx": { file: "assets/index-v3.js" },
})));
}
return Promise.resolve(freshShell.clone());
});
const onlineEvent = {
request: new Request(`${ORIGIN}/`),
respondWith: vi.fn(),
@@ -0,0 +1,52 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ThinkingReasoningShell } from "@/components/thread/activity/ThinkingReasoningShell";
function renderShell(expanded: boolean) {
return render(
<ThinkingReasoningShell
active={false}
expanded={expanded}
label="Thought"
viewportRef={() => undefined}
contentRef={() => undefined}
fadeTop={false}
fadeBottom={false}
onToggle={vi.fn()}
onScroll={vi.fn()}
>
<button type="button">Hidden action</button>
</ThinkingReasoningShell>,
);
}
describe("ThinkingReasoningShell", () => {
it("makes collapsed descendants inert as well as visually hidden", () => {
const { rerender } = renderShell(false);
const disclosure = screen.getByRole("button", { name: "Thought" });
const collapsible = disclosure.nextElementSibling;
expect(collapsible).toHaveAttribute("inert");
expect(collapsible).toHaveAttribute("aria-hidden", "true");
rerender(
<ThinkingReasoningShell
active={false}
expanded
label="Thought"
viewportRef={() => undefined}
contentRef={() => undefined}
fadeTop={false}
fadeBottom={false}
onToggle={vi.fn()}
onScroll={vi.fn()}
>
<button type="button">Hidden action</button>
</ThinkingReasoningShell>,
);
expect(disclosure.nextElementSibling).not.toHaveAttribute("inert");
expect(disclosure.nextElementSibling).toHaveAttribute("aria-hidden", "false");
});
});