From 0a9aa607e57622a4aaa1b1f2af24f17b27f7a3d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Fri, 30 Oct 2026 11:56:23 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=85=20=E5=90=88=E5=B9=B6=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E9=A1=B5=E6=A1=8C=E9=9D=A2/=E7=A7=BB=E5=8A=A8?= =?UTF-8?q?=E7=AB=AF=E6=B5=8B=E8=AF=95=EF=BC=8C=E4=BF=AE=E5=A4=8D=20isolat?= =?UTF-8?q?e:false=20=E4=B8=8B=20mock=20=E4=B8=B2=E7=94=A8=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=E7=9A=84=E9=9A=8F=E6=9C=BA=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ui 测试项目 isolate:false,index.test.tsx 与 LoggerMobile.test.tsx 各自 mock 同一个 ./index 的依赖(./hooks、toast、use-is-mobile),先加载的文件的 mock 会被另一个文件复用,按目录运行时随机失败 1~2 条。合并为一个文件,useIsMobile 改为按 describe 切换的 vi.fn()。 --- .../routes/Logger/LoggerMobile.test.tsx | 102 ------------------ .../options/routes/Logger/index.test.tsx | 43 +++++++- 2 files changed, 41 insertions(+), 104 deletions(-) delete mode 100644 src/pages/options/routes/Logger/LoggerMobile.test.tsx diff --git a/src/pages/options/routes/Logger/LoggerMobile.test.tsx b/src/pages/options/routes/Logger/LoggerMobile.test.tsx deleted file mode 100644 index 6dc7661f0..000000000 --- a/src/pages/options/routes/Logger/LoggerMobile.test.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest"; -import { render, cleanup, screen, fireEvent } from "@testing-library/react"; -import { MemoryRouter } from "react-router-dom"; -import { t } from "@App/locales/locales"; -import { initTestLanguage } from "@Tests/initTestLanguage"; -import { useIsMobile } from "@App/pages/components/use-is-mobile"; -import type { Logger as LoggerEntry } from "@App/app/repo/logger"; - -// 移动端重壳:整页在 useIsMobile=true 时切换为竖向堆叠布局(图标按钮 + 可横向滚动级别 chip + 消息换行)。 -vi.mock("@App/pages/components/use-is-mobile", () => ({ - useIsMobile: vi.fn(), - MOBILE_BREAKPOINT: 768, -})); - -const sampleLogs: LoggerEntry[] = [ - { id: 1, level: "error", message: "GM_xhr connect failed", label: { component: "GM_xhr" }, createtime: 3 }, - { id: 2, level: "warn", message: "retry timeout reached", label: { component: "GM_log" }, createtime: 2 }, - { id: 3, level: "info", message: "userscript started ok", label: { component: "GM_log" }, createtime: 1 }, -]; - -const { mockLoggerData } = vi.hoisted(() => ({ - mockLoggerData: { - logs: [] as LoggerEntry[], - loading: false, - reload: vi.fn(), - clearLogs: vi.fn(() => Promise.resolve()), - deleteLogs: vi.fn(() => Promise.resolve()), - cleanCycle: 7, - setCleanCycle: vi.fn(), - refreshInterval: "off" as const, - setRefreshInterval: vi.fn(), - preset: "24h" as const, - setPreset: vi.fn(), - isNow: true, - setIsNow: vi.fn(), - range: { start: 0, end: 1 }, - setRange: vi.fn(), - initialQueries: [], - }, -})); -vi.mock("./hooks", () => ({ useLogger: () => mockLoggerData })); -vi.mock("@App/pages/components/ui/toast", () => ({ - notify: { - success: vi.fn(), - error: vi.fn(), - info: vi.fn(), - warning: vi.fn(), - loading: vi.fn(), - promise: vi.fn(), - undo: vi.fn(), - dismiss: vi.fn(), - }, -})); - -import Logger from "./index"; - -const mockedUseIsMobile = vi.mocked(useIsMobile); -const renderPage = () => render(, { wrapper: MemoryRouter }); - -beforeAll(() => initTestLanguage("zh-CN")); - -beforeEach(() => { - mockLoggerData.logs = sampleLogs; - mockedUseIsMobile.mockReturnValue(true); - vi.clearAllMocks(); - mockedUseIsMobile.mockReturnValue(true); -}); - -afterEach(() => cleanup()); - -describe("日志页面 - 移动端", () => { - it("移动端顶栏的删除/清空按钮以图标按钮呈现(带无障碍名,不显示文字标签)", () => { - renderPage(); - const del = screen.getByLabelText(t("logs:delete_current_logs")); - const clear = screen.getByLabelText(t("logs:clear_logs")); - // 图标按钮:可见文本里不再包含完整中文标签 - expect(del.textContent).not.toContain(t("logs:delete_current_logs")); - expect(clear.textContent).not.toContain(t("logs:clear_logs")); - }); - - it("移动端日志行的消息允许换行而非截断", () => { - renderPage(); - const msg = screen.getByText("GM_xhr connect failed"); - expect(msg.className).not.toContain("truncate"); - }); - - it("移动端范围/统计条竖向堆叠,统计与自动清理各自独占整行", () => { - renderPage(); - const bar = screen.getByTestId("logger-stats-bar"); - expect(bar.className).toContain("flex-col"); - // 两组必须撑满整行:否则长文本会溢出自身盒子压到相邻分组上(窄屏文字重叠) - expect(screen.getByTestId("logger-stats-summary").className).toContain("w-full"); - expect(screen.getByTestId("logger-clean-schedule").className).toContain("w-full"); - }); - - it("仍渲染全部日志且筛选交互可用", () => { - renderPage(); - expect(screen.getByText("retry timeout reached")).toBeTruthy(); - fireEvent.click(screen.getByTestId("level-chip-warn")); - expect(screen.queryByText("retry timeout reached")).toBeNull(); - }); -}); diff --git a/src/pages/options/routes/Logger/index.test.tsx b/src/pages/options/routes/Logger/index.test.tsx index e41e66401..0c77a36eb 100644 --- a/src/pages/options/routes/Logger/index.test.tsx +++ b/src/pages/options/routes/Logger/index.test.tsx @@ -45,11 +45,14 @@ vi.mock("@App/pages/components/ui/toast", () => ({ dismiss: vi.fn(), }, })); -// 桌面视图用例:固定为非移动端(移动端重壳另在 LoggerMobile.test.tsx 覆盖) -vi.mock("@App/pages/components/use-is-mobile", () => ({ useIsMobile: () => false, MOBILE_BREAKPOINT: 768 })); +// 桌面与移动端用例放在同一文件:ui 项目 isolate:false 下两个文件各自 mock 同一个 ./index 的依赖会互相串用 +vi.mock("@App/pages/components/use-is-mobile", () => ({ useIsMobile: vi.fn(), MOBILE_BREAKPOINT: 768 })); +import { useIsMobile } from "@App/pages/components/use-is-mobile"; import Logger from "./index"; +const mockedUseIsMobile = vi.mocked(useIsMobile); + const renderPage = () => render(, { wrapper: MemoryRouter }); beforeAll(() => initTestLanguage("zh-CN")); @@ -58,6 +61,7 @@ beforeEach(() => { mockLoggerData.logs = sampleLogs; mockLoggerData.clearLogs = vi.fn(() => Promise.resolve()); vi.clearAllMocks(); + mockedUseIsMobile.mockReturnValue(false); }); afterEach(() => cleanup()); @@ -101,3 +105,38 @@ describe("日志页面", () => { expect(mockLoggerData.clearLogs).not.toHaveBeenCalled(); }); }); + +describe("日志页面 - 移动端", () => { + beforeEach(() => mockedUseIsMobile.mockReturnValue(true)); + + it("移动端顶栏的删除/清空按钮以图标按钮呈现(带无障碍名,不显示文字标签)", () => { + renderPage(); + const del = screen.getByLabelText(t("logs:delete_current_logs")); + const clear = screen.getByLabelText(t("logs:clear_logs")); + // 图标按钮:可见文本里不再包含完整中文标签 + expect(del.textContent).not.toContain(t("logs:delete_current_logs")); + expect(clear.textContent).not.toContain(t("logs:clear_logs")); + }); + + it("移动端日志行的消息允许换行而非截断", () => { + renderPage(); + const msg = screen.getByText("GM_xhr connect failed"); + expect(msg.className).not.toContain("truncate"); + }); + + it("移动端范围/统计条竖向堆叠,统计与自动清理各自独占整行", () => { + renderPage(); + const bar = screen.getByTestId("logger-stats-bar"); + expect(bar.className).toContain("flex-col"); + // 两组必须撑满整行:否则长文本会溢出自身盒子压到相邻分组上(窄屏文字重叠) + expect(screen.getByTestId("logger-stats-summary").className).toContain("w-full"); + expect(screen.getByTestId("logger-clean-schedule").className).toContain("w-full"); + }); + + it("仍渲染全部日志且筛选交互可用", () => { + renderPage(); + expect(screen.getByText("retry timeout reached")).toBeTruthy(); + fireEvent.click(screen.getByTestId("level-chip-warn")); + expect(screen.queryByText("retry timeout reached")).toBeNull(); + }); +}); From 1460555956714356794b5b4f6dc085ab81f66980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Fri, 30 Oct 2026 11:56:58 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=A8=20=E6=97=A5=E5=BF=97=E9=A1=B5?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=A4=8D=E5=88=B6=E6=97=A5=E5=BF=97=E8=A1=8C?= =?UTF-8?q?=E4=B8=8E=E5=AF=BC=E5=87=BA=E6=97=A5=E5=BF=97=20(#1810)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 日志行不再是 + + + ); + return ( -
- +
{expanded && (
diff --git a/src/pages/options/routes/Logger/index.test.tsx b/src/pages/options/routes/Logger/index.test.tsx
index 0c77a36eb..2a7a4d31f 100644
--- a/src/pages/options/routes/Logger/index.test.tsx
+++ b/src/pages/options/routes/Logger/index.test.tsx
@@ -1,9 +1,11 @@
 import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest";
-import { render, cleanup, screen, fireEvent } from "@testing-library/react";
+import { render, cleanup, screen, fireEvent, act } from "@testing-library/react";
 import { MemoryRouter } from "react-router-dom";
 import { t } from "@App/locales/locales";
 import { initTestLanguage } from "@Tests/initTestLanguage";
 import type { Logger as LoggerEntry } from "@App/app/repo/logger";
+import { notify } from "@App/pages/components/ui/toast";
+import { formatLogLine, formatLogsJson, formatLogsText } from "./logic";
 
 // 数据 Hook 涉及 IndexedDB 读取,测试中整体打桩;返回稳定引用避免无限重渲染。
 const sampleLogs: LoggerEntry[] = [
@@ -64,7 +66,32 @@ beforeEach(() => {
   mockedUseIsMobile.mockReturnValue(false);
 });
 
-afterEach(() => cleanup());
+afterEach(() => {
+  cleanup();
+  vi.restoreAllMocks();
+});
+
+const mockClipboard = (writeText: (text: string) => Promise) => {
+  const fn = vi.fn(writeText);
+  Object.defineProperty(navigator, "clipboard", { value: { writeText: fn }, configurable: true });
+  return fn;
+};
+
+const openExportMenu = () => {
+  const trigger = screen.getByRole("button", { name: t("export") });
+  fireEvent.pointerDown(trigger, { button: 0 });
+  fireEvent.click(trigger);
+};
+
+// 捕获交给 chrome.downloads 的文件名与 Blob 内容
+const captureDownload = () => {
+  const createObjectURL = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:logs");
+  const download = vi.spyOn(chrome.downloads, "download").mockResolvedValue(undefined);
+  return {
+    download,
+    blobText: () => (createObjectURL.mock.calls[0][0] as Blob).text(),
+  };
+};
 
 describe("日志页面", () => {
   it("渲染传入的全部日志消息", () => {
@@ -104,6 +131,83 @@ describe("日志页面", () => {
     expect(await screen.findByText(t("logs:clear_logs_confirm"))).toBeTruthy();
     expect(mockLoggerData.clearLogs).not.toHaveBeenCalled();
   });
+
+  it("日志消息文字不在按钮内,可以被拖选复制", () => {
+    renderPage();
+    expect(screen.getByText("GM_xhr connect failed").closest("button")).toBeNull();
+  });
+
+  it("拖选文字后松开鼠标不会展开该行", () => {
+    vi.spyOn(window, "getSelection").mockReturnValue({ toString: () => "connect failed" } as Selection);
+    renderPage();
+    fireEvent.click(screen.getByTestId("log-row-1"));
+    expect(screen.queryByTestId("log-detail-1")).toBeNull();
+  });
+
+  it("点击行内复制按钮把该条日志的文本行写入剪贴板,且不展开该行", async () => {
+    const writeText = mockClipboard(() => Promise.resolve());
+    renderPage();
+    await act(async () => fireEvent.click(screen.getByTestId("log-copy-1")));
+    expect(writeText).toHaveBeenCalledWith(formatLogLine(sampleLogs[0]));
+    expect(notify.success).toHaveBeenCalledWith(t("logs:copied_logs", { count: 1 }));
+    expect(screen.queryByTestId("log-detail-1")).toBeNull();
+  });
+
+  it("导出菜单「复制为文本」复制的是当前筛选结果", async () => {
+    const writeText = mockClipboard(() => Promise.resolve());
+    renderPage();
+    fireEvent.click(screen.getByTestId("level-chip-warn"));
+    openExportMenu();
+    await act(async () => fireEvent.click(screen.getByRole("menuitem", { name: t("logs:export_copy_text") })));
+    expect(writeText).toHaveBeenCalledWith(formatLogsText([sampleLogs[0], sampleLogs[2]]));
+    expect(notify.success).toHaveBeenCalledWith(t("logs:copied_logs", { count: 2 }));
+  });
+
+  it("写入剪贴板失败时提示错误而不是提示成功", async () => {
+    mockClipboard(() => Promise.reject(new Error("denied")));
+    renderPage();
+    openExportMenu();
+    await act(async () => fireEvent.click(screen.getByRole("menuitem", { name: t("logs:export_copy_text") })));
+    expect(notify.error).toHaveBeenCalled();
+    expect(notify.success).not.toHaveBeenCalled();
+  });
+
+  it("导出 .log 文件下载当前筛选结果的纯文本", async () => {
+    const { download, blobText } = captureDownload();
+    renderPage();
+    openExportMenu();
+    await act(async () => fireEvent.click(screen.getByRole("menuitem", { name: t("logs:export_download_log") })));
+    expect(download).toHaveBeenCalledWith(
+      expect.objectContaining({ saveAs: true, filename: expect.stringMatching(/^scriptcat-logs-\d{8}-\d{6}\.log$/) })
+    );
+    await expect(blobText()).resolves.toBe(formatLogsText(sampleLogs));
+  });
+
+  it("导出 .json 文件下载当前筛选结果的完整字段", async () => {
+    const { download, blobText } = captureDownload();
+    renderPage();
+    openExportMenu();
+    await act(async () => fireEvent.click(screen.getByRole("menuitem", { name: t("logs:export_download_json") })));
+    expect(download).toHaveBeenCalledWith(
+      expect.objectContaining({ saveAs: true, filename: expect.stringMatching(/^scriptcat-logs-\d{8}-\d{6}\.json$/) })
+    );
+    await expect(blobText()).resolves.toBe(formatLogsJson(sampleLogs));
+  });
+
+  it("下载失败时提示错误", async () => {
+    vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:logs");
+    vi.spyOn(chrome.downloads, "download").mockRejectedValue(new Error("download blocked"));
+    renderPage();
+    openExportMenu();
+    await act(async () => fireEvent.click(screen.getByRole("menuitem", { name: t("logs:export_download_log") })));
+    expect(notify.error).toHaveBeenCalledWith(expect.stringContaining("download blocked"));
+  });
+
+  it("筛选结果为空时导出按钮不可用", () => {
+    mockLoggerData.logs = [];
+    renderPage();
+    expect(screen.getByRole("button", { name: t("export") })).toBeDisabled();
+  });
 });
 
 describe("日志页面 - 移动端", () => {
diff --git a/src/pages/options/routes/Logger/index.tsx b/src/pages/options/routes/Logger/index.tsx
index ce41c7d9c..64add65c1 100644
--- a/src/pages/options/routes/Logger/index.tsx
+++ b/src/pages/options/routes/Logger/index.tsx
@@ -1,11 +1,30 @@
 import { useMemo, useRef, useState } from "react";
 import { useTranslation } from "react-i18next";
-import { ArrowUp, ChevronDown, Inbox, ScrollText, Trash, Trash2 } from "lucide-react";
+import {
+  ArrowUp,
+  ChevronDown,
+  Copy,
+  Download,
+  FileJson,
+  FileText,
+  Inbox,
+  ScrollText,
+  Trash,
+  Trash2,
+} from "lucide-react";
 import { notify } from "@App/pages/components/ui/toast";
 import { cn } from "@App/pkg/utils/cn";
-import { formatUnixTime } from "@App/pkg/utils/day_format";
+import { dayFormat, formatUnixTime } from "@App/pkg/utils/day_format";
+import { makeBlobURL } from "@App/pkg/utils/utils";
 import { useIsMobile } from "@App/pages/components/use-is-mobile";
 import { Button } from "@App/pages/components/ui/button";
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuLabel,
+  DropdownMenuTrigger,
+} from "@App/pages/components/ui/dropdown-menu";
 import { EmptyState } from "@App/pages/components/ui/empty-state";
 import { LoadingState } from "@App/pages/components/ui/loading-state";
 import { Popconfirm } from "@App/pages/components/ui/popconfirm";
@@ -15,6 +34,9 @@ import {
   aggregateLabels,
   countLevels,
   filterLogs,
+  formatLogLine,
+  formatLogsJson,
+  formatLogsText,
   presetRange,
   type LevelBucket,
   type LogQuery,
@@ -99,6 +121,25 @@ export default function Logger() {
     if (!ids.length) return;
     void deleteLogs(ids).then(() => notify.success(t("logs:delete_completed")));
   };
+  const copyText = (text: string, count: number) =>
+    navigator.clipboard.writeText(text).then(
+      () => notify.success(t("logs:copied_logs", { count })),
+      (e) => notify.error(`${t("logs:copy_failed")}: ${e instanceof Error ? e.message : e}`)
+    );
+  const handleDownload = async (ext: "log" | "json") => {
+    const content = ext === "log" ? formatLogsText(filtered) : formatLogsJson(filtered);
+    const type = ext === "log" ? "text/plain" : "application/json";
+    try {
+      const url = await makeBlobURL({ blob: new Blob([content], { type }), persistence: false });
+      await chrome.downloads.download({
+        url,
+        saveAs: true,
+        filename: `scriptcat-logs-${dayFormat(new Date(), "YYYYMMDD-HHmmss")}.${ext}`,
+      });
+    } catch (e) {
+      notify.error(`${t("logs:export_failed")}: ${e instanceof Error ? e.message : e}`);
+    }
+  };
   const handleClear = () => {
     void clearLogs().then(() => notify.success(t("logs:clear_completed")));
   };
@@ -117,6 +158,38 @@ export default function Logger() {
           
         
+ + + {isMobile ? ( + + ) : ( + + )} + + + + {t("logs:export_scope", { count: filtered.length })} + + void copyText(formatLogsText(filtered), filtered.length)}> + + {t("logs:export_copy_text")} + + void handleDownload("log")}> + + {t("logs:export_download_log")} + + void handleDownload("json")}> + + {t("logs:export_download_json")} + + + {isMobile ? (