From d52c4f238f5ea665a2e4bf4bccb4158bf3c87325 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:01:46 +0800 Subject: [PATCH] fix(desktop): contain renderer crashes (#3643) * fix(desktop): contain renderer crashes Co-authored-by: multica-agent * fix(desktop): filter renderer exit prompts Co-authored-by: multica-agent * refactor(desktop): drop redundant page-level ErrorBoundary on issue detail The whole-page wrapper duplicated the new route-level errorElement (DesktopRouteErrorPage). Let render errors bubble to the root route boundary so all detail routes are contained the same way. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(desktop): add Close tab escape to route error page Reload tab recreates the same crashing path and Go to issues is a dead end when the issues route itself crashed. Add a Close tab action that destroys the crashing router entirely and falls back to a sibling tab (or a reseeded default), the only always-safe escape regardless of which route crashed. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: multica-agent Co-authored-by: Claude Opus 4.8 (1M context) --- apps/desktop/src/main/index.ts | 27 ++-- .../src/main/renderer-recovery.test.ts | 112 ++++++++++++++ apps/desktop/src/main/renderer-recovery.ts | 135 +++++++++++++++++ .../src/components/route-error-page.test.tsx | 98 ++++++++++++ .../src/components/route-error-page.tsx | 140 ++++++++++++++++++ .../renderer/src/pages/issue-detail-page.tsx | 10 +- apps/desktop/src/renderer/src/routes.tsx | 9 +- .../src/renderer/src/stores/tab-store.ts | 42 ++++++ packages/views/modals/feedback.test.tsx | 96 ++++++++++++ packages/views/modals/feedback.tsx | 30 +++- packages/views/modals/registry.tsx | 2 +- 11 files changed, 671 insertions(+), 30 deletions(-) create mode 100644 apps/desktop/src/main/renderer-recovery.test.ts create mode 100644 apps/desktop/src/main/renderer-recovery.ts create mode 100644 apps/desktop/src/renderer/src/components/route-error-page.test.tsx create mode 100644 apps/desktop/src/renderer/src/components/route-error-page.tsx create mode 100644 packages/views/modals/feedback.test.tsx diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 3edd88fa4..c5dc326a9 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, ipcMain, nativeImage, Notification } from "electron"; +import { app, BrowserWindow, dialog, ipcMain, nativeImage, Notification } from "electron"; import { homedir } from "os"; import { join } from "path"; import { electronApp, optimizer, is } from "@electron-toolkit/utils"; @@ -13,6 +13,11 @@ import { installNavigationGestures } from "./navigation-gestures"; import { getAppVersion } from "./app-version"; import { loadRuntimeConfig } from "./runtime-config-loader"; import type { RuntimeConfigResult } from "../shared/runtime-config"; +import { + createElectronReloadPrompt, + installRendererRecoveryHandlers, + type RendererRecoveryWindow, +} from "./renderer-recovery"; // Bundled icon used for dock/taskbar branding. macOS/Windows production // builds let the OS pick up the icon from the .app bundle / .exe resources, @@ -224,13 +229,6 @@ function createWindow(): void { log(level, `${message} (${sourceId}:${lineNumber})`); }); - // Fires when the renderer process dies for any reason (OOM, crash, - // killed). `details.reason` is the discriminator: "crashed", "oom", - // "killed", "abnormal-exit", "launch-failed", etc. - mainWindow.webContents.on("render-process-gone", (_event, details) => { - log("process-gone", JSON.stringify(details)); - }); - // Fires when loadURL / loadFile can't reach its target (dev server // not up yet, network blip, file missing). errorCode is a Chromium // net error number; -3 = ABORTED is normal during HMR and skipped. @@ -245,14 +243,15 @@ function createWindow(): void { }, ); - // Fires when the preload script throws before the renderer can boot. - // This is the one error class that NEVER reaches DevTools (preload - // runs before any window) — without this listener it's invisible. - mainWindow.webContents.on("preload-error", (_event, preloadPath, error) => { - log("preload-error", `path=${preloadPath} err=${error?.stack ?? error}`); - }); } + installRendererRecoveryHandlers(mainWindow as unknown as RendererRecoveryWindow, { + isDev: is.dev, + showReloadPrompt: createElectronReloadPrompt((options) => + dialog.showMessageBox(mainWindow!, options), + ), + }); + installContextMenu(mainWindow.webContents); installNavigationGestures(mainWindow); diff --git a/apps/desktop/src/main/renderer-recovery.test.ts b/apps/desktop/src/main/renderer-recovery.test.ts new file mode 100644 index 000000000..afacaaeb4 --- /dev/null +++ b/apps/desktop/src/main/renderer-recovery.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { installRendererRecoveryHandlers } from "./renderer-recovery"; + +type Handler = (...args: unknown[]) => void; + +function makeWindow() { + const windowHandlers = new Map(); + const webContentsHandlers = new Map(); + const reload = vi.fn(); + return { + window: { + on: vi.fn((event: string, handler: Handler) => windowHandlers.set(event, handler)), + isDestroyed: vi.fn(() => false), + webContents: { + on: vi.fn((event: string, handler: Handler) => webContentsHandlers.set(event, handler)), + reload, + }, + }, + windowHandlers, + webContentsHandlers, + reload, + }; +} + +describe("installRendererRecoveryHandlers", () => { + beforeEach(() => vi.clearAllMocks()); + afterEach(() => vi.useRealTimers()); + + it("registers production reload prompts for renderer death and preload failure without auto reloading", async () => { + const fixture = makeWindow(); + const showReloadPrompt = vi.fn(async () => "reload" as const); + + installRendererRecoveryHandlers(fixture.window, { isDev: false, showReloadPrompt }); + + expect(fixture.webContentsHandlers.has("render-process-gone")).toBe(true); + expect(fixture.webContentsHandlers.has("preload-error")).toBe(true); + expect(fixture.windowHandlers.has("unresponsive")).toBe(true); + expect(fixture.windowHandlers.has("responsive")).toBe(true); + + fixture.webContentsHandlers.get("render-process-gone")?.({}, { reason: "crashed" }); + fixture.webContentsHandlers.get("preload-error")?.({}, "/preload.js", new Error("boom")); + + expect(fixture.reload).not.toHaveBeenCalled(); + await Promise.resolve(); + + expect(showReloadPrompt).toHaveBeenCalledTimes(2); + expect(fixture.reload).toHaveBeenCalledTimes(2); + }); + + it("does not prompt when the renderer exits cleanly", async () => { + const fixture = makeWindow(); + const showReloadPrompt = vi.fn(async () => "reload" as const); + + installRendererRecoveryHandlers(fixture.window, { isDev: false, showReloadPrompt }); + + fixture.webContentsHandlers.get("render-process-gone")?.({}, { reason: "clean-exit" }); + await Promise.resolve(); + + expect(showReloadPrompt).not.toHaveBeenCalled(); + expect(fixture.reload).not.toHaveBeenCalled(); + }); + + it("cancels an unresponsive prompt when the window becomes responsive again", async () => { + vi.useFakeTimers(); + const fixture = makeWindow(); + const showReloadPrompt = vi.fn(async () => "reload" as const); + + installRendererRecoveryHandlers(fixture.window, { + isDev: false, + showReloadPrompt, + unresponsivePromptDelayMs: 100, + }); + + fixture.windowHandlers.get("unresponsive")?.(); + fixture.windowHandlers.get("responsive")?.(); + await vi.advanceTimersByTimeAsync(100); + + expect(showReloadPrompt).not.toHaveBeenCalled(); + expect(fixture.reload).not.toHaveBeenCalled(); + }); + + it("prompts for sustained unresponsive windows and only reloads after user confirmation", async () => { + vi.useFakeTimers(); + const fixture = makeWindow(); + const showReloadPrompt = vi.fn(async () => "dismiss" as const); + + installRendererRecoveryHandlers(fixture.window, { + isDev: false, + showReloadPrompt, + unresponsivePromptDelayMs: 100, + }); + + fixture.windowHandlers.get("unresponsive")?.(); + await vi.advanceTimersByTimeAsync(100); + + expect(showReloadPrompt).toHaveBeenCalledWith({ kind: "unresponsive", context: {} }); + expect(fixture.reload).not.toHaveBeenCalled(); + }); + + it("keeps dev diagnostics non-prompting", async () => { + const fixture = makeWindow(); + const showReloadPrompt = vi.fn(async () => "reload" as const); + + installRendererRecoveryHandlers(fixture.window, { isDev: true, showReloadPrompt, log: vi.fn() }); + + fixture.webContentsHandlers.get("render-process-gone")?.({}, { reason: "crashed" }); + await Promise.resolve(); + + expect(showReloadPrompt).not.toHaveBeenCalled(); + expect(fixture.reload).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/main/renderer-recovery.ts b/apps/desktop/src/main/renderer-recovery.ts new file mode 100644 index 000000000..e6df0cfb0 --- /dev/null +++ b/apps/desktop/src/main/renderer-recovery.ts @@ -0,0 +1,135 @@ +export type RendererRecoveryWindow = { + isDestroyed: () => boolean; + on: (event: "unresponsive" | "responsive", handler: () => void) => unknown; + webContents: { + on: (event: string, handler: (...args: any[]) => void) => unknown; + reload: () => void; + }; +}; + +type ReloadPromptPayload = { + kind: "render-process-gone" | "preload-error" | "unresponsive"; + context: Record; +}; + +type ReloadPromptResult = "reload" | "dismiss"; + +type RendererRecoveryOptions = { + isDev: boolean; + showReloadPrompt: (payload: ReloadPromptPayload) => Promise; + log?: (tag: string, ...args: unknown[]) => void; + unresponsivePromptDelayMs?: number; +}; + +export function installRendererRecoveryHandlers( + window: RendererRecoveryWindow, + { + isDev, + showReloadPrompt, + log = defaultDevLog, + unresponsivePromptDelayMs = 1500, + }: RendererRecoveryOptions, +) { + let unresponsivePromptTimer: ReturnType | null = null; + const maybePromptReload = (payload: ReloadPromptPayload) => { + if (isDev) return; + void showReloadPrompt(payload).then((result) => { + if (result === "reload" && !window.isDestroyed()) { + window.webContents.reload(); + } + }); + }; + + window.webContents.on("render-process-gone", (_event, details) => { + if (isDev) log("process-gone", JSON.stringify(details)); + if (!isRecoverableRendererExit(details)) return; + maybePromptReload({ kind: "render-process-gone", context: { details } }); + }); + + window.webContents.on("preload-error", (_event, preloadPath, error) => { + if (isDev) log("preload-error", `path=${preloadPath} err=${formatError(error)}`); + maybePromptReload({ + kind: "preload-error", + context: { preloadPath, error: formatError(error) }, + }); + }); + + window.on("unresponsive", () => { + if (isDev || unresponsivePromptTimer) return; + unresponsivePromptTimer = setTimeout(() => { + unresponsivePromptTimer = null; + maybePromptReload({ kind: "unresponsive", context: {} }); + }, unresponsivePromptDelayMs); + }); + + window.on("responsive", () => { + if (!unresponsivePromptTimer) return; + clearTimeout(unresponsivePromptTimer); + unresponsivePromptTimer = null; + }); +} + +export function createElectronReloadPrompt( + showMessageBox: (options: { + type: "warning"; + buttons: string[]; + defaultId: number; + cancelId: number; + title: string; + message: string; + detail: string; + }) => Promise<{ response: number }>, +) { + return async (payload: ReloadPromptPayload): Promise => { + const result = await showMessageBox({ + type: "warning", + buttons: ["Reload", "Dismiss"], + defaultId: 0, + cancelId: 1, + title: "Multica needs to reload", + message: rendererRecoveryMessage(payload.kind), + detail: rendererRecoveryDetail(payload), + }); + return result.response === 0 ? "reload" : "dismiss"; + }; +} + +function isRecoverableRendererExit(details: unknown) { + if (!details || typeof details !== "object") return false; + const reason = (details as { reason?: unknown }).reason; + return ( + reason === "crashed" || + reason === "oom" || + reason === "abnormal-exit" || + reason === "launch-failed" || + reason === "integrity-failure" + ); +} + +function rendererRecoveryMessage(kind: ReloadPromptPayload["kind"]) { + switch (kind) { + case "render-process-gone": + return "The desktop renderer process stopped responding or crashed."; + case "preload-error": + return "The desktop preload script failed before the app could start."; + case "unresponsive": + return "The desktop window is not responding."; + } +} + +function rendererRecoveryDetail(payload: ReloadPromptPayload) { + return [ + "Reloading is the safest recovery path for this window.", + "", + `kind: ${payload.kind}`, + `context: ${JSON.stringify(payload.context)}`, + ].join("\n"); +} + +function defaultDevLog(tag: string, ...args: unknown[]) { + process.stderr.write(`[renderer ${tag}] ${args.map(String).join(" ")}\n`); +} + +function formatError(error: unknown) { + return error instanceof Error ? (error.stack ?? error.message) : String(error); +} \ No newline at end of file diff --git a/apps/desktop/src/renderer/src/components/route-error-page.test.tsx b/apps/desktop/src/renderer/src/components/route-error-page.test.tsx new file mode 100644 index 000000000..cb231fd1b --- /dev/null +++ b/apps/desktop/src/renderer/src/components/route-error-page.test.tsx @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { createMemoryRouter, RouterProvider } from "react-router-dom"; + +const openModal = vi.fn(); +const reloadActiveTab = vi.fn(); +const closeActiveTab = vi.fn(); + +vi.mock("@multica/core/modals", () => ({ + useModalStore: { + getState: () => ({ open: openModal }), + }, +})); + +vi.mock("@/stores/tab-store", () => ({ + useTabStore: { + getState: () => ({ reloadActiveTab, closeActiveTab }), + }, +})); + +import { DesktopRouteErrorPage, formatRouteErrorReport } from "./route-error-page"; + +function Boom(): null { + throw new Error("route render exploded"); + return null; +} + +describe("DesktopRouteErrorPage", () => { + beforeEach(() => { + openModal.mockReset(); + reloadActiveTab.mockReset(); + closeActiveTab.mockReset(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("brands React Router route errors and offers tab reload", async () => { + const router = createMemoryRouter( + [{ path: "/", element: , errorElement: }], + { initialEntries: ["/"] }, + ); + + render(); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "Something went wrong in this tab", + ); + fireEvent.click(screen.getByRole("button", { name: /reload tab/i })); + expect(reloadActiveTab).toHaveBeenCalledTimes(1); + }); + + it("offers Close tab as the always-safe escape from a crashing route", async () => { + const router = createMemoryRouter( + [{ path: "/acme/issues/1", element: , errorElement: }], + { initialEntries: ["/acme/issues/1"] }, + ); + + render(); + + fireEvent.click(await screen.findByRole("button", { name: /close tab/i })); + expect(closeActiveTab).toHaveBeenCalledTimes(1); + }); + + it("opens the existing feedback modal with a structured markdown report only after click", async () => { + const router = createMemoryRouter( + [{ path: "/acme/issues", element: , errorElement: }], + { initialEntries: ["/acme/issues"] }, + ); + + render(); + + expect(openModal).not.toHaveBeenCalled(); + fireEvent.click(await screen.findByRole("button", { name: /report error/i })); + + expect(openModal).toHaveBeenCalledWith( + "feedback", + expect.objectContaining({ + initialMessage: expect.stringContaining("kind: desktop_route_error"), + }), + ); + }); + + it("documents the structured kind/context follow-up debt in the report template", () => { + const report = formatRouteErrorReport({ + error: new Error("bad route"), + url: "app://desktop/acme/issues", + appInfo: { version: "1.2.3", os: "macos" }, + trigger: "route-errorElement", + }); + + expect(report).toContain("kind: desktop_route_error"); + expect(report).toContain("trigger: route-errorElement"); + expect(report).toContain("TODO: promote kind/context to structured feedback fields"); + }); +}); \ No newline at end of file diff --git a/apps/desktop/src/renderer/src/components/route-error-page.tsx b/apps/desktop/src/renderer/src/components/route-error-page.tsx new file mode 100644 index 000000000..d9bb75b60 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/route-error-page.tsx @@ -0,0 +1,140 @@ +import { useMemo } from "react"; +import { useLocation, useNavigate, useRouteError } from "react-router-dom"; +import { AlertTriangle, RotateCw, Send, X } from "lucide-react"; +import { Button } from "@multica/ui/components/ui/button"; +import { useModalStore } from "@multica/core/modals"; +import { useTabStore } from "@/stores/tab-store"; + +type DesktopAppInfo = { + version?: string; + os?: string; +}; + +export function formatRouteErrorReport({ + error, + url, + appInfo, + trigger, +}: { + error: unknown; + url: string; + appInfo?: DesktopAppInfo; + trigger: string; +}) { + const normalized = normalizeError(error); + return [ + "kind: desktop_route_error", + `trigger: ${trigger}`, + `url: ${url}`, + `app_version: ${appInfo?.version ?? "unknown"}`, + `runtime_os: ${appInfo?.os ?? "unknown"}`, + "", + "context:", + `- name: ${normalized.name}`, + `- message: ${normalized.message}`, + "", + "stack:", + "```", + normalized.stack ?? "", + "```", + "", + "TODO: promote kind/context to structured feedback fields once the feedback API supports them.", + ].join("\n"); +} + +export function DesktopRouteErrorPage() { + const error = useRouteError(); + const location = useLocation(); + const navigate = useNavigate(); + const workspaceSlug = location.pathname.split("/").filter(Boolean)[0]; + const safeRoute = workspaceSlug ? `/${workspaceSlug}/issues` : null; + const report = useMemo( + () => + formatRouteErrorReport({ + error, + url: + typeof window !== "undefined" + ? `${window.location.origin}${location.pathname}${location.search}${location.hash}` + : location.pathname, + appInfo: typeof window !== "undefined" ? window.desktopAPI?.appInfo : undefined, + trigger: "route-errorElement", + }), + [error, location.hash, location.pathname, location.search], + ); + const message = normalizeError(error).message; + + return ( +
+
+
+
+

Something went wrong in this tab

+

+ A route-level renderer error was contained before it could take down the + desktop shell. Reload this tab, or send the report if it keeps happening. +

+

{message}

+
+
+ + {safeRoute ? ( + + ) : null} + + +
+
+ ); +} + +function normalizeError(error: unknown): { name: string; message: string; stack?: string } { + if (error instanceof Error) { + return { + name: error.name || "Error", + message: error.message || "Unknown route error", + stack: error.stack, + }; + } + if (typeof error === "string") { + return { name: "Error", message: error }; + } + return { name: "Error", message: "Unknown route error", stack: safeJson(error) }; +} + +function safeJson(value: unknown) { + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} \ No newline at end of file diff --git a/apps/desktop/src/renderer/src/pages/issue-detail-page.tsx b/apps/desktop/src/renderer/src/pages/issue-detail-page.tsx index 31d1d5a83..abfdcdd9e 100644 --- a/apps/desktop/src/renderer/src/pages/issue-detail-page.tsx +++ b/apps/desktop/src/renderer/src/pages/issue-detail-page.tsx @@ -1,7 +1,6 @@ import { useParams } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { IssueDetail } from "@multica/views/issues/components"; -import { ErrorBoundary } from "@multica/ui/components/common/error-boundary"; import { useWorkspaceId } from "@multica/core/hooks"; import { issueDetailOptions } from "@multica/core/issues/queries"; import { useDocumentTitle } from "@/hooks/use-document-title"; @@ -14,9 +13,8 @@ export function IssueDetailPage() { useDocumentTitle(issue ? `${issue.identifier}: ${issue.title}` : "Issue"); if (!id) return null; - return ( - - - - ); + // Render errors bubble to the root route errorElement (DesktopRouteErrorPage), + // which contains the crash inside the tab content pane. No page-level boundary + // here — a whole-page wrapper duplicates the route-level error UI. + return ; } diff --git a/apps/desktop/src/renderer/src/routes.tsx b/apps/desktop/src/renderer/src/routes.tsx index e8130b6b5..c39b1648b 100644 --- a/apps/desktop/src/renderer/src/routes.tsx +++ b/apps/desktop/src/renderer/src/routes.tsx @@ -26,11 +26,11 @@ import { SquadsPage, SquadDetailPage as SquadDetailPageView } from "@multica/vie import { InboxPage } from "@multica/views/inbox"; import { SettingsPage } from "@multica/views/settings"; import { useT } from "@multica/views/i18n"; -import { ErrorBoundary } from "@multica/ui/components/common/error-boundary"; import { Download, Server } from "lucide-react"; import { DaemonSettingsTab } from "./components/daemon-settings-tab"; import { UpdatesSettingsTab } from "./components/updates-settings-tab"; import { WorkspaceRouteLayout } from "./components/workspace-route-layout"; +import { DesktopRouteErrorPage } from "./components/route-error-page"; /** * Wraps `SettingsPage` so the desktop-only extra tabs can pull their labels @@ -109,6 +109,7 @@ function PageShell() { export const appRoutes: RouteObject[] = [ { element: , + errorElement: , children: [ { index: true, element: null }, { @@ -118,11 +119,7 @@ export const appRoutes: RouteObject[] = [ { index: true, element: }, { path: "issues", - element: ( - - - - ), + element: , handle: { title: "Issues" }, }, { diff --git a/apps/desktop/src/renderer/src/stores/tab-store.ts b/apps/desktop/src/renderer/src/stores/tab-store.ts index 07b9dd708..33613429e 100644 --- a/apps/desktop/src/renderer/src/stores/tab-store.ts +++ b/apps/desktop/src/renderer/src/stores/tab-store.ts @@ -86,6 +86,16 @@ interface TabStore { updateTab: (tabId: string, patch: Partial>) => void; /** Patch history tracking of a tab. Finds across groups. */ updateTabHistory: (tabId: string, historyIndex: number, historyLength: number) => void; + /** Recreate the active tab's router at the same path after a route-level crash. */ + reloadActiveTab: () => void; + /** + * Close the active tab. The always-safe escape from a route-level crash: + * unlike reloadActiveTab (recreates the same crashing path) or navigating + * to a "safe" route (which may itself be the route that crashed), closing + * destroys the crashing router entirely and falls back to a sibling tab + * (or a reseeded default if it was the last tab). + */ + closeActiveTab: () => void; /** * Reorder within the active workspace's group only. Clamped so a tab can * never cross the pinned / unpinned boundary — a drag that would move a @@ -475,6 +485,38 @@ export const useTabStore = create()( }); }, + reloadActiveTab() { + const { activeWorkspaceSlug, byWorkspace } = get(); + if (!activeWorkspaceSlug) return; + const group = byWorkspace[activeWorkspaceSlug]; + if (!group) return; + const index = group.tabs.findIndex((t) => t.id === group.activeTabId); + if (index < 0) return; + const current = group.tabs[index]; + const nextTabs = [...group.tabs]; + nextTabs[index] = { + ...current, + router: createTabRouter(current.path), + historyIndex: 0, + historyLength: 1, + }; + set({ + byWorkspace: { + ...byWorkspace, + [activeWorkspaceSlug]: { ...group, tabs: nextTabs }, + }, + }); + window.setTimeout(() => current.router.dispose(), 0); + }, + + closeActiveTab() { + const { activeWorkspaceSlug, byWorkspace, closeTab } = get(); + if (!activeWorkspaceSlug) return; + const group = byWorkspace[activeWorkspaceSlug]; + if (!group) return; + closeTab(group.activeTabId); + }, + moveTab(fromIndex, toIndex) { if (fromIndex === toIndex) return; const { activeWorkspaceSlug, byWorkspace } = get(); diff --git a/packages/views/modals/feedback.test.tsx b/packages/views/modals/feedback.test.tsx new file mode 100644 index 000000000..4a46799f8 --- /dev/null +++ b/packages/views/modals/feedback.test.tsx @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { forwardRef, useImperativeHandle } from "react"; + +let storedDraftMessage = "saved draft"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key, i18n: { changeLanguage: vi.fn() } }), + Trans: ({ children }: { children: any }) => children, + initReactI18next: { type: "3rdParty", init: vi.fn() }, +})); + +vi.mock("../i18n", () => ({ + useT: () => ({ + t: (selector: (resources: any) => string) => + selector({ + feedback: { + title: "Feedback", + github_hint_prefix: "Prefer GitHub? ", + github_hint_link: "Open an issue", + placeholder: "Tell us what happened", + toast_uploading: "Uploading", + toast_too_long: "Too long", + toast_sent: "Sent", + toast_failed: "Failed", + sending: "Sending", + send: "Send", + }, + }), + }), +})); + +vi.mock("@multica/core/paths", () => ({ useCurrentWorkspace: () => ({ id: "ws1" }) })); +vi.mock("@multica/core/hooks/use-file-upload", () => ({ + useFileUpload: () => ({ uploadWithToast: vi.fn() }), +})); +vi.mock("@multica/core/api", () => ({ api: {} })); +vi.mock("@multica/core/analytics", () => ({ captureFeedbackOpened: vi.fn() })); +vi.mock("sonner", () => ({ toast: { info: vi.fn(), error: vi.fn(), success: vi.fn() } })); +vi.mock("@multica/core/platform", () => ({ + formatShortcut: () => "⌘↵", + modKey: "mod", + enterKey: "enter", +})); +vi.mock("@multica/core/feedback", () => ({ + useCreateFeedback: () => ({ isPending: false, mutateAsync: vi.fn() }), + useFeedbackDraftStore: (selector: any) => + selector({ draft: { message: storedDraftMessage }, setDraft: vi.fn(), clearDraft: vi.fn() }), +})); +vi.mock("../editor", () => { + const ContentEditor = forwardRef(({ defaultValue }: any, ref) => { + useImperativeHandle(ref, () => ({ + hasActiveUploads: () => false, + getMarkdown: () => defaultValue, + uploadFile: vi.fn(), + })); + return