| 1 | import { app, dialog } from "electron"; |
| 2 | import { mkdirSync, writeFileSync } from "node:fs"; |
| 3 | import { join } from "node:path"; |
| 4 | import { consumeGraphicsRecoveryArg, GraphicsSettingsStore, loadGraphicsBootstrap } from "../../src/main/graphics.js"; |
| 5 | import { confirmRecoveryDraftLoss, installGraphicsRecovery } from "../../src/main/graphicsRecoveryHost.js"; |
| 6 | import { QuitSequencer } from "../../src/main/lifecycle.js"; |
| 7 | import { MainWindow, DEFAULT_GEOMETRY } from "../../src/main/window.js"; |
| 8 | import { AppZoomStore } from "../../src/main/zoomStore.js"; |
| 9 | |
| 10 | const profile = process.env.REASONIX_RECOVERY_TEST_HOME!; |
| 11 | mkdirSync(profile, { recursive: true }); |
| 12 | app.setPath("userData", profile); |
| 13 | const boot = loadGraphicsBootstrap(profile, process.env, process.argv); |
| 14 | if (boot.shouldDisable) app.disableHardwareAcceleration(); |
| 15 | const temporary = consumeGraphicsRecoveryArg(process.argv); |
| 16 | const graphics = new GraphicsSettingsStore(boot.configPath, boot); |
| 17 | const events: string[] = [], prompts: Electron.MessageBoxOptions[] = [], responses: number[] = []; |
| 18 | let relaunchArgs: string[] = [], flushHangs = false, reloads = 0; |
| 19 | let duringDialog: (() => void) | undefined; |
| 20 | if (process.env.REASONIX_RECOVERY_NATIVE_DIALOG !== "1") { |
| 21 | dialog.showMessageBox = (async (...args: unknown[]) => { |
| 22 | const options = args[args.length - 1] as Electron.MessageBoxOptions; |
| 23 | prompts.push(options); |
| 24 | duringDialog?.(); |
| 25 | return { response: responses.shift() ?? options.cancelId ?? 0, checkboxChecked: false }; |
| 26 | }) as typeof dialog.showMessageBox; |
| 27 | } |
| 28 | const log = { info: (s: string) => events.push(s), warn: (s: string) => events.push(s), error: (s: string) => events.push(s) }; |
| 29 | let main: MainWindow; |
| 30 | const lifecycle = new QuitSequencer({ |
| 31 | app: { quit: () => { if (!lifecycle.onBeforeQuit()) return; events.push("quit-complete"); }, relaunch: args => { relaunchArgs = args; } }, |
| 32 | service: { beforeClose: async () => false, shutdown: async () => { events.push("service-shutdown"); } }, |
| 33 | flushRenderer: () => flushHangs ? new Promise(() => {}) : main.flushSessionDraft(), |
| 34 | recoveryDraftTimeoutMs: 20, confirmRecoveryDraftLoss, |
| 35 | onCloseAllowed: () => { events.push("close-allowed"); }, log, |
| 36 | }); |
| 37 | const host = installGraphicsRecovery({ graphics, lifecycle, log, logsDir: profile, build: "test", temporary }); |
| 38 | main = new MainWindow({ |
| 39 | preloadPath: join(profile, "unused-preload.cjs"), |
| 40 | appURL: "data:text/html,<title>Reasonix recovery test</title><h1>Recovery test</h1>", |
| 41 | platform: process.platform, log, zoomStore: new AppZoomStore(join(profile, "zoom.json")), |
| 42 | isQuitting: () => lifecycle.isQuitting, |
| 43 | onRendererFailure: (details, canReload) => host.recovery.fault({ role: "renderer", ...details }, canReload), |
| 44 | onUnresponsive: () => host.recovery.unresponsive(), onResponsive: () => host.recovery.responsive(), |
| 45 | onAppDomReady: () => { reloads++; }, onCloseRequested: async () => {}, onShellAction: () => {}, |
| 46 | }); |
| 47 | app.on("before-quit", () => main.allowClose()); |
| 48 | const snapshot = () => ({ reloads, relaunchArgs, phase: lifecycle.currentPhase, temporary, argv: process.argv, startup: graphics.current.startupEnabled, actual: app.isHardwareAccelerationEnabled(), events, prompts }); |
| 49 | async function until(check: () => boolean, timeout = 10_000): Promise<void> { |
| 50 | const end = Date.now() + timeout; |
| 51 | while (Date.now() < end) { if (check()) return; await new Promise(resolve => setTimeout(resolve, 25)); } |
| 52 | throw new Error("Recovery fixture condition timed out"); |
| 53 | } |
| 54 | void app.whenReady().then(async () => { |
| 55 | main.create(DEFAULT_GEOMETRY); await main.loadApp(); main.show("test"); |
| 56 | const mode = process.env.REASONIX_RECOVERY_TEST_MODE; |
| 57 | if (mode === "gpu" || mode === "native") { |
| 58 | flushHangs = true; responses.push(0, 0); |
| 59 | app.emit("child-process-gone", {}, { type: "GPU", reason: "crashed", exitCode: 1 }); |
| 60 | app.emit("child-process-gone", {}, { type: "GPU", reason: "crashed", exitCode: 1 }); |
| 61 | if (mode === "native") console.log("Native recovery dialog ready; choose restart and confirm disposable draft loss."); |
| 62 | await until(() => lifecycle.currentPhase === "completed", mode === "native" ? 180_000 : 10_000); |
| 63 | } else if (mode === "renderer") { |
| 64 | main.browserWindow!.webContents.forcefullyCrashRenderer(); |
| 65 | await until(() => reloads === 2); |
| 66 | main.browserWindow!.webContents.forcefullyCrashRenderer(); |
| 67 | await until(() => prompts.length === 1); |
| 68 | } else if (mode === "software" || mode === "keep") { |
| 69 | responses.push(mode === "keep" ? 0 : 1); host.healthy(); |
| 70 | await until(() => prompts.length === 1 && !host.recovery.isPrompting); |
| 71 | } else if (mode === "record") { |
| 72 | app.emit("child-process-gone", {}, { type: "GPU", reason: "crashed", exitCode: 1 }); |
| 73 | } else if (mode === "previous") { |
| 74 | await until(() => prompts.length === 1 && !host.recovery.isPrompting); |
| 75 | } else if (mode === "during-dialog" || mode === "during-keep") { |
| 76 | const fail = () => app.emit("child-process-gone", {}, { type: "GPU", reason: "crashed", exitCode: 1 }); |
| 77 | duringDialog = () => { duringDialog = undefined; fail(); }; |
| 78 | if (mode === "during-keep") { responses.push(1); host.healthy(); } |
| 79 | else { fail(); fail(); } |
| 80 | await until(() => prompts.length === 1 && !host.recovery.isPrompting); |
| 81 | } |
| 82 | writeFileSync(join(profile, "result.json"), JSON.stringify(snapshot())); |
| 83 | app.exit(0); |
| 84 | }).catch(error => { console.error(error, snapshot()); app.exit(1); }); |
| 85 |