дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
fix(app): surface startup failures before the workbench loads (#2030)
Failures while the app loads left only the splash, with nothing to report. Handlers in public/boot.js now load before the module graph and replace the splash with an error page with copyable diagnostics and a report link, for module load errors, rejected startup, initial render errors and a 30-second timeout. A successful start cancels the timer. The production build also rejects selected newer startup APIs and unsupported regex and import syntax. The original macOS 11 trigger remains unconfirmed. Refs #2008
Цей коміт міститься в:
1 джерело
152b71f5e0
коміт
6bf1794ed6
12 files changed
+558
-23
No files matched your search
+26
-15
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import "./styles/global.css";
|
||||
import { Workbench } from "./workbench/Workbench";
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "./features";
|
||||
import { auxiliaryWindow } from "./platform/auxWindow";
|
||||
import { installRepeatedDoubleClick } from "./platform/doubleClick";
|
||||
import { installErrorLogging } from "./platform/errorLog";
|
||||
import { installErrorLogging, logError } from "./platform/errorLog";
|
||||
import { guessOs } from "./platform/platformInfo";
|
||||
import { installReorderedTapClick } from "./platform/reordered-tap-click";
|
||||
import { installPageSwipeTracking } from "./platform/pageSwipe";
|
||||
@@ -74,19 +74,30 @@ if (aux?.kind === "settings") {
|
||||
installKeyboardDispatcher();
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
function App() {
|
||||
useEffect(() => {
|
||||
hideSplash();
|
||||
// Views split out of the start-up bundle load in the background once the UI is up.
|
||||
if (!aux) return prefetchLazyComponents();
|
||||
}, []);
|
||||
return aux?.kind === "settings" ? (
|
||||
<SettingsWindow />
|
||||
) : aux?.kind === "comparison" ? (
|
||||
<ComparisonWindow />
|
||||
) : aux?.kind === "deployment" ? (
|
||||
<DeploymentWindow />
|
||||
) : (
|
||||
<Workbench />
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement, {
|
||||
onUncaughtError: (error) => {
|
||||
logError("Uncaught render error", error);
|
||||
window.dispatchEvent(new CustomEvent("openidea:boot-error", { detail: error }));
|
||||
},
|
||||
}).render(
|
||||
<React.StrictMode>
|
||||
{aux?.kind === "settings" ? (
|
||||
<SettingsWindow />
|
||||
) : aux?.kind === "comparison" ? (
|
||||
<ComparisonWindow />
|
||||
) : aux?.kind === "deployment" ? (
|
||||
<DeploymentWindow />
|
||||
) : (
|
||||
<Workbench />
|
||||
)}
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
hideSplash();
|
||||
// Views split out of the start-up bundle load in the background once the UI is up.
|
||||
if (!aux) prefetchLazyComponents();
|
||||
@@ -0,0 +1,109 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const boot = readFileSync("public/boot.js", "utf8");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
document.body.innerHTML = '<div id="root"></div><div id="splash"></div>';
|
||||
delete document.documentElement.dataset.boot;
|
||||
new Function(boot)();
|
||||
});
|
||||
afterEach(() => {
|
||||
window.dispatchEvent(new Event("openidea:boot-ready"));
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("surfaces module evaluation errors before React is available", () => {
|
||||
window.dispatchEvent(new ErrorEvent("error", { error: new SyntaxError("Invalid regular expression") }));
|
||||
expect(document.getElementById("splash")).toBeNull();
|
||||
expect(document.querySelector('[role="alert"]')?.textContent).toContain("OpenIDEA could not start");
|
||||
expect(document.querySelector("textarea")?.value).toContain("Invalid regular expression");
|
||||
expect(document.querySelector("textarea")?.value).toContain(navigator.userAgent);
|
||||
});
|
||||
|
||||
it("handles startup rejections and preserves the first failure", () => {
|
||||
const event = new Event("unhandledrejection");
|
||||
Object.defineProperty(event, "reason", { value: { message: "startup rejected" } });
|
||||
window.dispatchEvent(event);
|
||||
window.dispatchEvent(new ErrorEvent("error", { message: "later failure" }));
|
||||
expect(document.querySelector("textarea")?.value).toContain("startup rejected");
|
||||
expect(document.querySelector("textarea")?.value).not.toContain("later failure");
|
||||
});
|
||||
|
||||
it("handles script load failures but ignores image failures", () => {
|
||||
const image = document.createElement("img");
|
||||
document.body.append(image);
|
||||
image.dispatchEvent(new Event("error"));
|
||||
expect(document.getElementById("splash")).not.toBeNull();
|
||||
const script = document.createElement("script");
|
||||
script.type = "module";
|
||||
script.src = "/assets/entry.js";
|
||||
document.body.append(script);
|
||||
script.dispatchEvent(new Event("error"));
|
||||
expect(document.querySelector("textarea")?.value).toContain("/assets/entry.js");
|
||||
});
|
||||
|
||||
it("surfaces initial React render failures through the boot error event", () => {
|
||||
window.dispatchEvent(new CustomEvent("openidea:boot-error", { detail: new Error("render failed") }));
|
||||
expect(document.querySelector("textarea")?.value).toContain("render failed");
|
||||
});
|
||||
|
||||
it("reports an unresolved startup instead of waiting forever", () => {
|
||||
vi.advanceTimersByTime(30_000);
|
||||
expect(document.querySelector("textarea")?.value).toContain("Startup did not finish within 30 seconds.");
|
||||
});
|
||||
|
||||
it("stops intercepting failures after a successful startup", () => {
|
||||
window.dispatchEvent(new Event("openidea:boot-ready"));
|
||||
window.dispatchEvent(new ErrorEvent("error", { message: "runtime failure" }));
|
||||
vi.advanceTimersByTime(30_000);
|
||||
expect(document.getElementById("boot-error")).toBeNull();
|
||||
});
|
||||
|
||||
it("copies diagnostics and offers manual copying when clipboard access fails", async () => {
|
||||
const writeText = vi.fn().mockRejectedValue(new Error("denied"));
|
||||
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
|
||||
window.dispatchEvent(new ErrorEvent("error", { message: "<script>unsafe</script>" }));
|
||||
document.querySelector("button")?.click();
|
||||
await vi.waitFor(() => expect(document.querySelector('[role="status"]')?.textContent).toContain("Select"));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining("<script>unsafe</script>"));
|
||||
expect(document.querySelector("#boot-error script")).toBeNull();
|
||||
expect(document.activeElement).toBe(document.querySelector("textarea"));
|
||||
});
|
||||
|
||||
it("uses Chinese boot text without loading the application catalog", () => {
|
||||
window.dispatchEvent(new Event("openidea:boot-ready"));
|
||||
vi.spyOn(navigator, "language", "get").mockReturnValue("zh-CN");
|
||||
new Function(boot)();
|
||||
window.dispatchEvent(new ErrorEvent("error", { message: "failure" }));
|
||||
expect(document.querySelector('[role="alert"]')?.textContent).toContain("OpenIDEA 无法启动");
|
||||
});
|
||||
|
||||
it("confirms a successful copy", async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
|
||||
window.dispatchEvent(new ErrorEvent("error", { message: "startup failed" }));
|
||||
document.querySelector("button")?.click();
|
||||
await vi.waitFor(() => expect(document.querySelector('[role="status"]')?.textContent).toBe("Copied"));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining("startup failed"));
|
||||
});
|
||||
|
||||
it("defers an early script failure until the document body exists", () => {
|
||||
const body = document.body;
|
||||
body.remove();
|
||||
window.dispatchEvent(new ErrorEvent("error", { message: "early failure" }));
|
||||
document.documentElement.append(body);
|
||||
document.dispatchEvent(new Event("DOMContentLoaded"));
|
||||
expect(document.querySelector("textarea")?.value).toContain("early failure");
|
||||
});
|
||||
|
||||
it("preserves Safari error messages when its stack only lists locations", () => {
|
||||
const error = new SyntaxError("Unsupported regular expression");
|
||||
error.stack = "global code@tauri://localhost/assets/entry.js:1:20";
|
||||
window.dispatchEvent(new ErrorEvent("error", { error }));
|
||||
expect(document.querySelector("textarea")?.value).toContain("SyntaxError: Unsupported regular expression");
|
||||
expect(document.querySelector("textarea")?.value).toContain(error.stack);
|
||||
});
|
||||
@@ -484,5 +484,13 @@
|
||||
"Could not send the answer: {0}": "Could not send the answer: {0}",
|
||||
"Allow": "Allow",
|
||||
"Deny": "Deny",
|
||||
"Agents can read, edit, and run projects open in OpenIDEA through its built-in tools. Run tools ask for confirmation. The server listens on 127.0.0.1 only and requires a token.": "Agents can read, edit, and run projects open in OpenIDEA through its built-in tools. Run tools ask for confirmation. The server listens on 127.0.0.1 only and requires a token."
|
||||
"Agents can read, edit, and run projects open in OpenIDEA through its built-in tools. Run tools ask for confirmation. The server listens on 127.0.0.1 only and requires a token.": "Agents can read, edit, and run projects open in OpenIDEA through its built-in tools. Run tools ask for confirmation. The server listens on 127.0.0.1 only and requires a token.",
|
||||
"OpenIDEA could not start": "OpenIDEA could not start",
|
||||
"Copy the details below and include them in a GitHub issue.": "Copy the details below and include them in a GitHub issue.",
|
||||
"Report an issue": "Report an issue",
|
||||
"Copy details": "Copy details",
|
||||
"Copied": "Copied",
|
||||
"Could not copy. Select the details below and copy them.": "Could not copy. Select the details below and copy them.",
|
||||
"Startup error details": "Startup error details",
|
||||
"Startup did not finish within 30 seconds.": "Startup did not finish within 30 seconds."
|
||||
}
|
||||
@@ -6827,5 +6827,12 @@
|
||||
"Tool: {0}": "工具:{0}",
|
||||
"Could not send the answer: {0}": "无法发送回复:{0}",
|
||||
"Deny": "拒绝",
|
||||
"Agents can read, edit, and run projects open in OpenIDEA through its built-in tools. Run tools ask for confirmation. The server listens on 127.0.0.1 only and requires a token.": "智能体可通过内置工具读取、编辑和运行 OpenIDEA 中打开的项目。运行工具会请求确认。服务器仅监听 127.0.0.1,并需要令牌。"
|
||||
"Agents can read, edit, and run projects open in OpenIDEA through its built-in tools. Run tools ask for confirmation. The server listens on 127.0.0.1 only and requires a token.": "智能体可通过内置工具读取、编辑和运行 OpenIDEA 中打开的项目。运行工具会请求确认。服务器仅监听 127.0.0.1,并需要令牌。",
|
||||
"OpenIDEA could not start": "OpenIDEA 无法启动",
|
||||
"Copy the details below and include them in a GitHub issue.": "请复制下方详情,并附在 GitHub 问题中。",
|
||||
"Report an issue": "报告问题",
|
||||
"Copy details": "复制详情",
|
||||
"Could not copy. Select the details below and copy them.": "无法复制。请选择下方详情并手动复制。",
|
||||
"Startup error details": "启动错误详情",
|
||||
"Startup did not finish within 30 seconds.": "启动未在 30 秒内完成。"
|
||||
}
|
||||
@@ -70,6 +70,8 @@ const NOTIFY = new Set(["notify"]);
|
||||
|
||||
/** Code templates shown as written, and the in-memory backends of plain-browser development. */
|
||||
const UNCHECKED_FILES = new Set([
|
||||
// Pre-module failure UI carries its own English/Chinese text when catalogs cannot load.
|
||||
"public/boot.js",
|
||||
"src/editor/core/backend.ts",
|
||||
"src/features/plugins/backend.ts",
|
||||
"src/features/tools/backend.ts",
|
||||
@@ -490,7 +492,10 @@ function scanTypeScript(): Scan {
|
||||
const spliced: Use[] = [];
|
||||
const keys: Use[] = [];
|
||||
const literals = new Set<string>();
|
||||
const files = walk(`${ROOT}src`, (p) => /\.tsx?$/.test(p) && !isTest(p) && !p.endsWith("/bindings.ts"));
|
||||
const files = [
|
||||
...walk(`${ROOT}src`, (p) => /\.tsx?$/.test(p) && !isTest(p) && !p.endsWith("/bindings.ts")),
|
||||
`${ROOT}public/boot.js`,
|
||||
];
|
||||
for (const file of files) {
|
||||
const path = file.slice(ROOT.length);
|
||||
const source = readFileSync(file, "utf8");
|
||||
|
||||
@@ -36,8 +36,13 @@ export function hideSplash(): void {
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(
|
||||
() => {
|
||||
if (document.documentElement.dataset.boot === "failed") return;
|
||||
el.classList.add("splash-out");
|
||||
setTimeout(() => el.remove(), 200);
|
||||
setTimeout(() => {
|
||||
if (document.documentElement.dataset.boot === "failed") return;
|
||||
el.remove();
|
||||
window.dispatchEvent(new Event("openidea:boot-ready"));
|
||||
}, 200);
|
||||
},
|
||||
completeBar(el) ? FILL_MS : 0,
|
||||
);
|
||||
|
||||
Reference in new issue
Заблокувати користувача