diff --git a/frontend/src/app/RootErrorBoundary.test.tsx b/frontend/src/app/RootErrorBoundary.test.tsx new file mode 100644 index 0000000..55a6de4 --- /dev/null +++ b/frontend/src/app/RootErrorBoundary.test.tsx @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; + +import { RootErrorBoundary, installGlobalErrorLogging } from "./RootErrorBoundary"; + +function ThrowingView(): JSX.Element { + throw new Error("boom from render"); +} + +describe("RootErrorBoundary", () => { + it("renders a visible fallback when the app render tree throws", () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + render( + + + , + ); + + expect(screen.getByRole("alert")).toBeTruthy(); + expect(screen.getByText("IdeA a rencontré une erreur d'affichage.")).toBeTruthy(); + expect(screen.getByText(/boom from render/)).toBeTruthy(); + } finally { + consoleError.mockRestore(); + } + }); + + it("offers a reload action from the fallback", () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const reload = vi.fn(); + const originalLocation = window.location; + try { + Object.defineProperty(window, "location", { + configurable: true, + value: { ...originalLocation, reload }, + }); + + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Recharger" })); + expect(reload).toHaveBeenCalledOnce(); + } finally { + Object.defineProperty(window, "location", { + configurable: true, + value: originalLocation, + }); + consoleError.mockRestore(); + } + }); +}); + +describe("installGlobalErrorLogging", () => { + it("installs global error logging only once", () => { + const addEventListener = vi.spyOn(window, "addEventListener"); + const previousFlag = window.__IDEA_GLOBAL_ERROR_LOGGING__; + try { + window.__IDEA_GLOBAL_ERROR_LOGGING__ = undefined; + installGlobalErrorLogging(); + installGlobalErrorLogging(); + + expect( + addEventListener.mock.calls.filter(([event]) => event === "error"), + ).toHaveLength(1); + expect( + addEventListener.mock.calls.filter(([event]) => event === "unhandledrejection"), + ).toHaveLength(1); + } finally { + window.__IDEA_GLOBAL_ERROR_LOGGING__ = previousFlag; + addEventListener.mockRestore(); + } + }); +}); diff --git a/frontend/src/app/RootErrorBoundary.tsx b/frontend/src/app/RootErrorBoundary.tsx new file mode 100644 index 0000000..802405b --- /dev/null +++ b/frontend/src/app/RootErrorBoundary.tsx @@ -0,0 +1,102 @@ +import { Component, type ErrorInfo, type ReactNode } from "react"; + +const UNKNOWN_ERROR = "Erreur frontend inconnue"; + +export function installGlobalErrorLogging(): void { + const globalWindow = globalThis.window; + if (!globalWindow || globalWindow.__IDEA_GLOBAL_ERROR_LOGGING__) return; + globalWindow.__IDEA_GLOBAL_ERROR_LOGGING__ = true; + + globalWindow.addEventListener("error", (event) => { + console.error("[idea-ui] uncaught error", { + message: event.message, + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + error: event.error, + }); + }); + + globalWindow.addEventListener("unhandledrejection", (event) => { + console.error("[idea-ui] unhandled promise rejection", event.reason); + }); +} + +interface RootErrorBoundaryProps { + children: ReactNode; +} + +interface RootErrorBoundaryState { + error: Error | null; + componentStack: string | null; +} + +export class RootErrorBoundary extends Component< + RootErrorBoundaryProps, + RootErrorBoundaryState +> { + state: RootErrorBoundaryState = { + error: null, + componentStack: null, + }; + + static getDerivedStateFromError(error: Error): RootErrorBoundaryState { + return { error, componentStack: null }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + console.error("[idea-ui] root render failed", error, errorInfo); + this.setState({ componentStack: errorInfo.componentStack ?? null }); + } + + render(): ReactNode { + const { error, componentStack } = this.state; + if (!error) return this.props.children; + + const message = error.message || UNKNOWN_ERROR; + const details = [error.stack, componentStack].filter(Boolean).join("\n\n"); + + return ( +
+
+
+

+ Interface interrompue +

+

+ IdeA a rencontré une erreur d'affichage. +

+

+ L'application reste ouverte. Rechargez la fenêtre après avoir copié + le diagnostic si le problème doit être investigué. +

+
+ +
+            {details || message}
+          
+ +
+ +
+
+
+ ); + } +} + +declare global { + interface Window { + __IDEA_GLOBAL_ERROR_LOGGING__?: boolean; + } +} diff --git a/frontend/src/app/main.tsx b/frontend/src/app/main.tsx index d2985d8..ef62dd4 100644 --- a/frontend/src/app/main.tsx +++ b/frontend/src/app/main.tsx @@ -7,6 +7,7 @@ import { App } from "./App"; import { ViewWindow, parseViewWindowParams } from "./ViewWindow"; import { WebApp } from "@/features/web"; import { DIProvider, resolveTransport } from "./di"; +import { RootErrorBoundary, installGlobalErrorLogging } from "./RootErrorBoundary"; import "@/shared/styles/theme.css"; const root = document.getElementById("root"); @@ -32,16 +33,20 @@ const viewParams = parseViewWindowParams(window.location.search); // the full `App` (or a detached view window). Detached windows are desktop-only. const isWeb = resolveTransport() === "http"; +installGlobalErrorLogging(); + ReactDOM.createRoot(root).render( - - {isWeb ? ( - - ) : viewParams ? ( - - ) : ( - - )} - + + + {isWeb ? ( + + ) : viewParams ? ( + + ) : ( + + )} + + , ); diff --git a/frontend/src/plugins/runtime/loader.test.ts b/frontend/src/plugins/runtime/loader.test.ts index 79329d6..5485782 100644 --- a/frontend/src/plugins/runtime/loader.test.ts +++ b/frontend/src/plugins/runtime/loader.test.ts @@ -305,6 +305,28 @@ describe("loadPlugins", () => { ); }); + it("collects a failure when plugin activation does not settle", async () => { + const bundle = dataUrl(` + export function activate() { + return new Promise(() => {}); + } + `); + + const { registry, failures } = await loadPlugins( + [entry({ id: "dev.acme.hung", displayName: "Hung", bundleUrl: bundle })], + gateways, + { timeoutMs: 10 }, + ); + + expect(registry.list()).toEqual([]); + expect(failures).toEqual([ + { + pluginId: "dev.acme.hung", + reason: "activating plugin timed out after 10 ms", + }, + ]); + }); + it("only loads what the catalog contains — a disabled/absent plugin is simply never in it", async () => { // The backend contract (carnet §1.3) filters the catalog to // `enabled && !pendingUninstall` before the loader ever sees it; the diff --git a/frontend/src/plugins/runtime/loader.ts b/frontend/src/plugins/runtime/loader.ts index 77d4803..788aa36 100644 --- a/frontend/src/plugins/runtime/loader.ts +++ b/frontend/src/plugins/runtime/loader.ts @@ -72,6 +72,32 @@ export interface PluginLoadResult { failures: PluginLoadFailure[]; } +export interface PluginLoadOptions { + timeoutMs?: number; +} + +const DEFAULT_PLUGIN_LOAD_TIMEOUT_MS = 10_000; + +async function withTimeout( + promise: Promise, + timeoutMs: number, + label: string, +): Promise { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timeout = setTimeout(() => { + reject(new Error(`${label} timed out after ${timeoutMs} ms`)); + }, timeoutMs); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } +} + function isIdeaPluginModule(mod: unknown): mod is IdeaPluginModule { return ( typeof mod === "object" && @@ -170,6 +196,7 @@ async function disposeAll(disposables: Disposable[], activation?: void | PluginA async function loadOne( entry: PluginRuntimePlugin, gateways: PluginGatewaySet, + options: Required, ): Promise<{ plugin: LoadedPlugin } | { failure: PluginLoadFailure }> { const entryObject = objectOrEmpty(entry); const pluginId = safePluginId(entry); @@ -186,7 +213,13 @@ async function loadOne( throw new Error("missing plugin bundle URL"); } - const mod = resolveIdeaPluginModule(await import(/* @vite-ignore */ bundleUrl)); + const mod = resolveIdeaPluginModule( + await withTimeout( + import(/* @vite-ignore */ bundleUrl), + options.timeoutMs, + "importing plugin bundle", + ), + ); if (!mod) { return { failure: { @@ -215,7 +248,11 @@ async function loadOne( ...gateways, }; - activation = await mod.activate(ctx); + activation = await withTimeout( + Promise.resolve(mod.activate(ctx)), + options.timeoutMs, + "activating plugin", + ); const plugin: LoadedPlugin = { pluginId, @@ -251,12 +288,18 @@ async function loadOne( export async function loadPlugins( catalogPlugins: PluginRuntimePlugin[], gateways: PluginGatewaySet, + options: PluginLoadOptions = {}, ): Promise { const registry = new PluginRuntimeRegistry(); const failures: PluginLoadFailure[] = []; + const resolvedOptions: Required = { + timeoutMs: options.timeoutMs ?? DEFAULT_PLUGIN_LOAD_TIMEOUT_MS, + }; const entries = Array.isArray(catalogPlugins) ? catalogPlugins : []; - const results = await Promise.all(entries.map((entry) => loadOne(entry, gateways))); + const results = await Promise.all( + entries.map((entry) => loadOne(entry, gateways, resolvedOptions)), + ); for (const result of results) { if ("failure" in result) failures.push(result.failure); else registry.add(result.plugin);