fix(webui): restore session drag and review findings
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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)?(?:,|$)/);
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user