fix(app): surface startup failures before the workbench loads

Цей коміт міститься в:
l0ng-ai committed 2026-11-08 00:51:58 +08:00
1 джерело f407880204
коміт 30cc205aa4
12 files changed
+495 -21

No files matched your search

+26
Переглянути файл
@@ -0,0 +1,26 @@
import { expect, test } from "@playwright/test";
test("a module evaluation failure replaces the splash with copyable details", async ({ page }) => {
await page.route("**/src/main.tsx", (route) =>
route.fulfill({ contentType: "text/javascript", body: 'throw new Error("Simulated startup failure");' }),
);
await page.goto("/");
await expect(page.getByRole("heading", { name: "OpenIDEA could not start" })).toBeVisible();
await expect(page.locator("#splash")).toHaveCount(0);
await expect(page.getByRole("textbox", { name: "Startup error details" })).toHaveValue(/Simulated startup failure/);
await expect(page.getByRole("button", { name: "Copy details" })).toBeFocused();
await expect(page.getByRole("link", { name: "Report an issue" })).toHaveAttribute("href", /github.com/);
});
test("an unresolved module gets a startup timeout", async ({ page }) => {
await page.clock.install();
await page.route("**/src/main.tsx", (route) =>
route.fulfill({ contentType: "text/javascript", body: "await new Promise(() => {});" }),
);
await page.goto("/", { waitUntil: "commit" });
await page.locator("#splash").waitFor({ state: "attached" });
await page.clock.runFor(30_000);
await expect(page.getByRole("textbox", { name: "Startup error details" })).toHaveValue(
/Startup did not finish within 30 seconds/,
);
});
+1 -1
Переглянути файл
@@ -7,7 +7,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"build": "tsc --noEmit && vite build && node scripts/check-webkit.ts",
"preview": "vite preview",
"tauri": "node scripts/tauri.ts",
"test": "vitest run",
+112 -1
Переглянути файл
@@ -1,4 +1,115 @@
/* global document, localStorage, window */
/* global document, localStorage, navigator, window */
// Keep this independent of the module graph: a dependency can fail before main.tsx runs.
(() => {
const zh = navigator.language.toLowerCase().startsWith("zh");
const text = zh
? {
title: "OpenIDEA 无法启动",
report: "请复制下方详情,并附在 GitHub 问题中。",
link: "报告问题",
copy: "复制详情",
copied: "已复制",
manual: "无法复制。请选择下方详情并手动复制。",
details: "启动错误详情",
timeout: "启动未在 30 秒内完成。",
}
: {
title: "OpenIDEA could not start",
report: "Copy the details below and include them in a GitHub issue.",
link: "Report an issue",
copy: "Copy details",
copied: "Copied",
manual: "Could not copy. Select the details below and copy them.",
details: "Startup error details",
timeout: "Startup did not finish within 30 seconds.",
};
let failed = false;
let ready = false;
const describe = (reason) => {
try {
if (typeof reason?.stack === "string") {
const message = String(reason);
return reason.stack.includes(message) ? reason.stack : `${message}\n${reason.stack}`;
}
return (typeof reason === "string" ? reason : JSON.stringify(reason)) || String(reason);
} catch {
return "Unknown startup error";
}
};
const show = (reason) => {
if (failed || ready) return;
failed = true;
window.clearTimeout(timer);
document.documentElement.dataset.boot = "failed";
const render = () => {
const panel = document.createElement("section");
panel.id = "boot-error";
panel.className = "splash boot-error";
panel.setAttribute("role", "alert");
const title = document.createElement("h1");
title.textContent = text.title;
const hint = document.createElement("p");
hint.textContent = text.report;
const details = document.createElement("textarea");
details.readOnly = true;
details.setAttribute("aria-label", text.details);
details.value = `OpenIDEA startup failure\n${navigator.userAgent}\n\n${describe(reason)}`;
const actions = document.createElement("div");
actions.className = "boot-error-actions";
const copy = document.createElement("button");
copy.type = "button";
copy.textContent = text.copy;
const status = document.createElement("p");
status.setAttribute("role", "status");
copy.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(details.value);
status.textContent = text.copied;
} catch {
details.focus();
details.select();
status.textContent = text.manual;
}
});
const report = document.createElement("a");
report.href = "https://github.com/l0ng-ai/openidea/issues/new?template=bug_report.yml";
report.target = "_blank";
report.rel = "noreferrer";
report.textContent = text.link;
actions.append(copy, report);
panel.append(title, hint, details, actions, status);
document.getElementById("splash")?.remove();
document.body.appendChild(panel);
copy.focus();
};
if (document.body) render();
else document.addEventListener("DOMContentLoaded", render, { once: true });
};
const onError = (event) => {
const target = event.target;
if (target?.tagName === "SCRIPT" || (target?.tagName === "LINK" && target.rel === "modulepreload")) {
show(`Could not load startup script: ${target.src || target.href}`);
} else if (event.error || event.message) {
show(event.error || `${event.message}\n${event.filename || ""}:${event.lineno || 0}:${event.colno || 0}`);
}
};
const onRejection = (event) => show(event.reason);
const onBootError = (event) => show(event.detail);
const onReady = () => {
ready = true;
window.clearTimeout(timer);
window.removeEventListener("error", onError, true);
window.removeEventListener("unhandledrejection", onRejection);
window.removeEventListener("openidea:boot-error", onBootError);
window.removeEventListener("openidea:boot-ready", onReady);
};
const timer = window.setTimeout(() => show(text.timeout), 30_000);
window.addEventListener("error", onError, true);
window.addEventListener("unhandledrejection", onRejection);
window.addEventListener("openidea:boot-error", onBootError);
window.addEventListener("openidea:boot-ready", onReady);
})();
// Runs before the app's CSS loads: restores the last theme (theme.ts stores it)
// so a new window starts in it instead of flashing the light default.
// An external file because the CSP forbids inline scripts.
+62 -2
Переглянути файл
@@ -67,10 +67,10 @@ body,
var(--splash-progress-easing, cubic-bezier(0.1, 0.7, 0.3, 1)) both;
}
/* Settings window: a dialog, not a launch. Only the bar, and only if loading takes a while. */
:root[data-window="settings"] .splash {
:root[data-window="settings"] .splash:not(.boot-error) {
animation-delay: 400ms;
}
:root[data-window="settings"] .splash > :not(.splash-bar) {
:root[data-window="settings"] .splash:not(.boot-error) > :not(.splash-bar) {
display: none;
}
@keyframes splash-in {
@@ -92,3 +92,63 @@ body,
animation: none;
}
}
.splash.boot-error {
align-items: stretch;
justify-content: center;
gap: 12px;
padding: 32px max(24px, calc((100vw - 560px) / 2));
overflow: auto;
animation: none;
font: var(--fs-ui, 13px) / 1.5 var(--font-ui, system-ui, sans-serif);
-webkit-user-select: text;
user-select: text;
}
.boot-error h1 {
margin: 0;
color: var(--fg, inherit);
font-size: 20px;
font-weight: 600;
}
.boot-error p {
margin: 0;
}
.boot-error textarea {
box-sizing: border-box;
width: 100%;
min-height: 140px;
height: 180px;
padding: 12px;
resize: vertical;
border: 1px solid var(--border-strong, currentColor);
border-radius: var(--radius-control, 4px);
background: var(--bg-input, transparent);
color: inherit;
font: 12px / 1.5 var(--font-mono, monospace);
}
.boot-error-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 16px;
}
.boot-error button {
padding: 6px 12px;
border: 1px solid var(--border-strong, currentColor);
border-radius: var(--radius-control, 4px);
background: var(--bg-elev, transparent);
color: inherit;
font: inherit;
cursor: pointer;
}
.boot-error button:hover {
background: var(--bg-hover, transparent);
}
.boot-error a {
color: var(--link, inherit);
text-underline-offset: 3px;
}
.boot-error :is(button, a, textarea):focus-visible {
outline: 2px solid var(--focus-ring, var(--boot-accent, currentColor));
outline-offset: 2px;
}
+43
Переглянути файл
@@ -0,0 +1,43 @@
// @vitest-environment node
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, it } from "vitest";
import { checkBuild, checkSource } from "./check-webkit.ts";
it.each([
"Promise.withResolvers()",
"Object.groupBy(items, key)",
"Array.fromAsync(items)",
"text.toWellFormed()",
'new RegExp("[a-z]", "v")',
'RegExp("(?<=a)b")',
"/[a-z]/v",
"/(?<!a)b/",
'import data from "./data.json" with { type: "json" }',
])("rejects %s in the Safari 16 startup graph", (source) => {
expect(checkSource(source, true).length).toBeGreaterThan(0);
});
it("does not mistake regex detection strings or named groups for newer syntax", () => {
expect(checkSource('const message = "(?<=a)"; const regex = /(?<name>a)/u;', true)).toEqual([]);
expect(checkSource("const regex = /(\\(\\?<=)|(\\(\\?<!)/;", true)).toEqual([]);
});
it("checks the entry's static imports and all lazy chunks for syntax", () => {
const dir = mkdtempSync(join(tmpdir(), "openidea-webkit-"));
try {
mkdirSync(join(dir, "assets"));
writeFileSync(join(dir, "index.html"), '<script type="module" src="/assets/entry.js"></script>');
writeFileSync(join(dir, "boot.js"), "");
writeFileSync(join(dir, "assets/entry.js"), 'import "./dependency.js"; import("./lazy.js");');
writeFileSync(join(dir, "assets/dependency.js"), "Promise.withResolvers();");
writeFileSync(join(dir, "assets/lazy.js"), "Object.groupBy(items, key); const re = /[a-z]/v;");
expect(checkBuild(dir)).toEqual([
"assets/dependency.js: Promise.withResolvers requires newer WebKit",
"assets/lazy.js: RegExp v flag requires Safari 17",
]);
} finally {
rmSync(dir, { recursive: true });
}
});
+89
Переглянути файл
@@ -0,0 +1,89 @@
import { readFileSync, readdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import ts from "typescript";
// macOS 11 can update to Safari 16. Keep this floor in sync with vite.config.ts.
// Syntax lowering does not replace newer runtime APIs in dependencies.
const newerStatics = new Set([
"Promise.withResolvers",
"Object.groupBy",
"Map.groupBy",
"Array.fromAsync",
"Atomics.waitAsync",
"CSS.registerProperty",
"Uint8Array.fromBase64",
"Uint8Array.fromHex",
]);
const newerMethods = new Set(["isWellFormed", "toWellFormed"]);
export function checkSource(source: string, startup: boolean): string[] {
const tree = ts.createSourceFile("bundle.js", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
const errors = new Set<string>();
const checkRegex = (pattern: string, flags: string) => {
if (flags.includes("v")) errors.add("RegExp v flag requires Safari 17");
if (/\(\?<([=!])/.test(pattern)) errors.add("RegExp lookbehind requires Safari 16.4");
};
const walk = (node: ts.Node) => {
if (ts.isRegularExpressionLiteral(node)) {
const end = node.text.lastIndexOf("/");
checkRegex(node.text.slice(1, end), node.text.slice(end + 1));
}
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.attributes) {
errors.add("Import attributes require newer WebKit");
}
if (startup && ts.isPropertyAccessExpression(node)) {
const name = node.getText(tree);
if (newerStatics.has(name) || newerMethods.has(node.name.text)) errors.add(`${name} requires newer WebKit`);
}
if (
startup &&
(ts.isNewExpression(node) || ts.isCallExpression(node)) &&
node.expression.getText(tree) === "RegExp" &&
node.arguments?.[0] &&
ts.isStringLiteralLike(node.arguments[0])
) {
const flags = node.arguments[1];
checkRegex(node.arguments[0].text, flags && ts.isStringLiteralLike(flags) ? flags.text : "");
}
ts.forEachChild(node, walk);
};
walk(tree);
return [...errors];
}
export function checkBuild(dir: string): string[] {
const entry = readFileSync(resolve(dir, "index.html"), "utf8").match(/<script type="module"[^>]*src="([^"]+)"/);
if (!entry?.[1]) throw new Error("Production entry script was not found");
const startup = new Set<string>();
const visit = (file: string) => {
if (startup.has(file)) return;
startup.add(file);
const source = readFileSync(file, "utf8");
const tree = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
for (const node of tree.statements) {
if (
(ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) &&
node.moduleSpecifier &&
ts.isStringLiteral(node.moduleSpecifier)
) {
visit(resolve(dirname(file), node.moduleSpecifier.text));
}
}
};
visit(resolve(dir, entry[1].replace(/^\//, "")));
startup.add(resolve(dir, "boot.js"));
const errors: string[] = [];
for (const file of readdirSync(dir, { recursive: true, encoding: "utf8" })) {
if (!file.endsWith(".js")) continue;
const path = resolve(dir, file);
for (const error of checkSource(readFileSync(path, "utf8"), startup.has(path))) errors.push(`${file}: ${error}`);
}
return errors;
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
const errors = checkBuild(resolve("dist"));
if (errors.length) throw new Error(`Safari 16 compatibility check failed:\n${errors.join("\n")}`);
console.log("Safari 16 startup API and bundle syntax checks passed");
}
+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 { installKeyboardDispatcher } from "./platform/keymap";
@@ -72,19 +72,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();
+109
Переглянути файл
@@ -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);
});
+8
Переглянути файл
@@ -1,4 +1,12 @@
{
"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.",
"this data source does not list databases": "this data source does not list databases",
"commit or roll back before clearing the selected database": "commit or roll back before clearing the selected database",
"connect_timeout must be greater than zero": "connect_timeout must be greater than zero",
+7
Переглянути файл
@@ -1,4 +1,11 @@
{
"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 秒内完成。",
"this data source does not list databases": "此数据源不支持列出数据库",
"commit or roll back before clearing the selected database": "清除所选数据库前,请先提交或回滚事务",
"connect_timeout must be greater than zero": "connect_timeout 必须大于零",
+6 -1
Переглянути файл
@@ -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",
@@ -485,7 +487,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");
+6 -1
Переглянути файл
@@ -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,
);