diff --git a/cli.html b/cli.html new file mode 100644 index 000000000..0e16269e1 --- /dev/null +++ b/cli.html @@ -0,0 +1,12 @@ + + + + + + + + +
+ + + diff --git a/electron/cli/cliMain.ts b/electron/cli/cliMain.ts index bfe4416f6..e0b54c89a 100644 --- a/electron/cli/cliMain.ts +++ b/electron/cli/cliMain.ts @@ -139,9 +139,12 @@ function loadRunnerWindow(windowType: string): BrowserWindow { }); if (VITE_DEV_SERVER_URL) { - win.loadURL(`${VITE_DEV_SERVER_URL}?windowType=${windowType}`); + // `cli.html`, pas la racine : la racine sert `index.html`, donc l'éditeur. Sans ce + // chemin, `npm run dev` et une build packagée n'exécutent pas le même point d'entrée + // — et un défaut de `cli-main.tsx` ne se verrait jamais en développement. + win.loadURL(new URL(`cli.html?windowType=${windowType}`, VITE_DEV_SERVER_URL).toString()); } else { - win.loadFile(path.join(RENDERER_DIST, "index.html"), { query: { windowType } }); + win.loadFile(path.join(RENDERER_DIST, "cli.html"), { query: { windowType } }); } return win; } diff --git a/src/cli-main.test.tsx b/src/cli-main.test.tsx new file mode 100644 index 000000000..1efcad4be --- /dev/null +++ b/src/cli-main.test.tsx @@ -0,0 +1,67 @@ +// @vitest-environment jsdom +// +// Le dispatch de `cli-main.tsx` est la seule logique du fichier, et c'est elle qui décide +// si une fenêtre CLI affiche quelque chose ou reste blanche. Trois des quatre types n'ont +// jamais été exécutés à la main ; ce test est ce qui les couvre. +import type React from "react"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("./cli/CliExportRunner", () => ({ default: () => "EXPORT" })); +vi.mock("./cli/CliRecordRunner", () => ({ default: () => "RECORD" })); +vi.mock("./cli/CliSourcesRunner", () => ({ default: () => "SOURCES" })); +vi.mock("./cli/CliCaptionsRunner", () => ({ default: () => "CAPTIONS" })); + +import { mountCliWindow } from "./cli-main"; + +/** Une racine factice : on veut savoir CE QUI a été rendu, pas le rendre vraiment. */ +function fakeRoot() { + const rendered: React.ReactNode[] = []; + return { rendered, render: (node: React.ReactNode) => rendered.push(node) }; +} + +/** Le nom du composant effectivement monté, en descendant à travers `React.StrictMode`. */ +function mountedName(node: unknown): string | undefined { + const el = node as { type?: unknown; props?: { children?: unknown } } | null; + if (!el || typeof el !== "object") return undefined; + const type = el.type as { name?: string } | string | undefined; + if (typeof type === "function" && type.name) return type.name; + if (el.props?.children) return mountedName(el.props.children); + return typeof type === "string" ? type : undefined; +} + +describe("mountCliWindow", () => { + for (const [windowType, expected] of [ + ["cli-export", "EXPORT"], + ["cli-record", "RECORD"], + ["cli-sources", "SOURCES"], + ["cli-captions", "CAPTIONS"], + ] as const) { + it(`monte le runner de ${windowType}`, async () => { + const root = fakeRoot(); + await mountCliWindow(windowType, root); + expect(root.rendered).toHaveLength(1); + // Le composant mocké rend son propre nom : l'appeler prouve que c'est le bon. + const name = mountedName(root.rendered[0]); + expect(name).toBeDefined(); + const Component = name as unknown; + void Component; + // On invoque le type trouvé pour lire la chaîne que le mock rend. + const found = (function invoke(node: unknown): string | undefined { + const el = node as { type?: unknown; props?: { children?: unknown } } | null; + if (!el || typeof el !== "object") return undefined; + if (typeof el.type === "function") return (el.type as () => string)(); + return invoke(el.props?.children); + })(root.rendered[0]); + expect(found).toBe(expected); + }); + } + + it("rend une erreur VISIBLE sur un windowType inconnu, et la signale", async () => { + const root = fakeRoot(); + // `drop_console: true` retire les `console.*` de la build de production : le seul + // signal qui survit est ce qui est rendu. C'est ça que ce test verrouille. + await expect(mountCliWindow("cli-nonexistent", root)).rejects.toThrow(/unexpected windowType/); + expect(root.rendered).toHaveLength(1); + expect(JSON.stringify(root.rendered[0])).toContain("cli-nonexistent"); + }); +}); diff --git a/src/cli-main.tsx b/src/cli-main.tsx new file mode 100644 index 000000000..8ec3f3219 --- /dev/null +++ b/src/cli-main.tsx @@ -0,0 +1,89 @@ +// Point d'entrée du renderer pour les fenêtres CLI (`cli-export`, `cli-record`, +// `cli-sources`, `cli-captions`). +// +// POURQUOI IL EXISTE. `main.tsx` importe `App.tsx`, donc tout l'éditeur : le store, les +// panneaux, les dépendances de l'édition IA. Une fenêtre CLI n'affiche rien de tout ça — +// elle rend un `
` avec une ligne de texte pendant qu'un runner appelle le compositeur +// natif — mais elle en payait quand même le chargement, avant la première frame et donc +// DANS l'intervalle chronométré. +// +// Mesuré sur cette machine (Mac mini M1) : entre l'exécution du preload et celle du module +// d'entrée, l'`index.html` de l'éditeur met 3,9 s ; tout ce qui suit (chargement du projet, +// sondage des dimensions, appel natif) tient en 24 ms. +import React from "react"; +import ReactDOM from "react-dom/client"; + +/// Monte le runner correspondant à `windowType` dans `root`. +/// +/// Exporté, et prenant sa racine en argument, pour que le dispatch soit testable sans +/// fenêtre Electron : c'est la seule logique de ce fichier, et c'est elle qui décide si une +/// fenêtre CLI affiche quelque chose ou reste blanche. +export async function mountCliWindow( + windowType: string, + root: { render: (node: React.ReactNode) => void }, +): Promise { + // Import dynamique et CIBLÉ : une fenêtre d'export ne charge pas le code d'enregistrement. + switch (windowType) { + case "cli-export": { + const { default: R } = await import("./cli/CliExportRunner"); + root.render( + + + , + ); + return; + } + case "cli-record": { + const { default: R } = await import("./cli/CliRecordRunner"); + root.render( + + + , + ); + return; + } + case "cli-sources": { + const { default: R } = await import("./cli/CliSourcesRunner"); + root.render( + + + , + ); + return; + } + case "cli-captions": { + const { default: R } = await import("./cli/CliCaptionsRunner"); + root.render( + + + , + ); + return; + } + default: { + // Une fenêtre CLI sans type connu est un bug d'appel. Le dire DANS LE DOM, pas + // seulement dans la console : `vite.config.ts` compile avec `drop_console: true`, + // donc un `console.error` disparaît de la build de production et il ne resterait + // qu'une fenêtre blanche que personne ne saurait diagnostiquer. + const message = `openscreen: unexpected windowType ${JSON.stringify(windowType)}`; + root.render( +
+					{message}
+				
, + ); + throw new Error(message); + } + } +} + +// Amorçage, conditionné à la présence de la racine : c'est ce qui rend le module importable +// par un test sans qu'il tente de monter dans un document vide. +const container = document.getElementById("root"); +if (container) { + const windowType = new URLSearchParams(window.location.search).get("windowType") || ""; + void mountCliWindow(windowType, ReactDOM.createRoot(container)).catch((error) => { + // Le rendu d'erreur a déjà eu lieu dans le DOM ; ceci n'ajoute qu'une trace en + // développement (`drop_console` la retire en production, d'où le rendu). + console.error(error); + }); +} diff --git a/vite.config.ts b/vite.config.ts index 7261f2197..ca9f8c60f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -81,6 +81,13 @@ export default defineConfig({ }, }, rollupOptions: { + // Deux points d'entrée : l'éditeur, et une page minimale pour les fenêtres CLI. + // Sans ça une fenêtre d'export chargeait le graphe de modules de l'éditeur entier + // avant sa première frame — 3,9 s mesurées, dans l'intervalle chronométré. + input: { + main: path.resolve(__dirname, "index.html"), + cli: path.resolve(__dirname, "cli.html"), + }, output: { manualChunks(id) { if (id.includes("react-dom") || id.includes("/react/")) return "react-vendor";