From 6bf1794ed672a5c60c543e647ab35ccdf95fbfe8 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 8 Nov 2026 23:27:01 +0800 Subject: [PATCH] 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 --- e2e/boot.spec.ts | 87 +++++++++++++++++ package.json | 2 +- public/boot.js | 113 ++++++++++++++++++++++- public/splash.css | 64 ++++++++++++- scripts/check-webkit.test.ts | 43 +++++++++ scripts/check-webkit.ts | 89 ++++++++++++++++++ src/main.tsx | 41 +++++--- src/platform/boot.test.ts | 109 ++++++++++++++++++++++ src/platform/locales/text/en.json | 10 +- src/platform/locales/text/zh.json | 9 +- src/platform/locales/textCatalog.test.ts | 7 +- src/platform/splash.ts | 7 +- 12 files changed, 558 insertions(+), 23 deletions(-) create mode 100644 e2e/boot.spec.ts create mode 100644 scripts/check-webkit.test.ts create mode 100644 scripts/check-webkit.ts create mode 100644 src/platform/boot.test.ts diff --git a/e2e/boot.spec.ts b/e2e/boot.spec.ts new file mode 100644 index 000000000..a7018b2d2 --- /dev/null +++ b/e2e/boot.spec.ts @@ -0,0 +1,87 @@ +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/, + ); +}); + +test("a rejected boot shows Chinese diagnostics without application modules", async ({ browser }) => { + const context = await browser.newContext({ locale: "zh-CN" }); + const page = await context.newPage(); + try { + await context.route("https://github.com/**", (route) => + route.fulfill({ contentType: "text/html", body: "Issue report destination" }), + ); + await page.route("**/src/main.tsx", (route) => + route.fulfill({ contentType: "text/javascript", body: 'await Promise.reject(new Error("Boot rejected"));' }), + ); + await page.goto("/"); + await expect(page.getByRole("heading", { name: "OpenIDEA 无法启动" })).toBeVisible(); + await expect(page.getByRole("textbox", { name: "启动错误详情" })).toHaveValue(/Boot rejected/); + await page.evaluate(() => { + Object.defineProperty(navigator, "clipboard", { + value: { writeText: async (value: string) => sessionStorage.setItem("copied-diagnostics", value) }, + }); + }); + await page.getByRole("button", { name: "复制详情" }).click(); + await expect(page.getByRole("status")).toHaveText("已复制"); + expect(await page.evaluate(() => sessionStorage.getItem("copied-diagnostics"))).toContain("Boot rejected"); + const popup = page.waitForEvent("popup"); + await page.getByRole("link", { name: "报告问题" }).click(); + await expect(await popup).toHaveURL("https://github.com/l0ng-ai/openidea/issues/new?template=bug_report.yml"); + } finally { + await context.close(); + } +}); + +test("a slow module load succeeds before the deadline and cancels the startup timer", async ({ page }) => { + await page.clock.install(); + await page.addInitScript(() => { + new MutationObserver((records) => { + if ( + records.some((record) => + [...record.addedNodes].some((node) => node instanceof Element && node.id === "boot-error"), + ) + ) + sessionStorage.setItem("boot-error-seen", "true"); + }).observe(document, { childList: true, subtree: true }); + }); + let release!: () => void; + const gate = new Promise((resolve) => (release = resolve)); + await page.route("**/src/main.tsx", async (route) => { + const response = await route.fetch(); + await gate; + await route.fulfill({ response }); + }); + await page.goto("/", { waitUntil: "commit" }); + await page.locator("#splash").waitFor({ state: "attached" }); + await page.clock.runFor(25_000); + await expect(page.locator("#boot-error")).toHaveCount(0); + release(); + await expect(page.locator("#root")).not.toBeEmpty(); + await page.clock.runFor(4000); + await expect(page.locator("#splash")).toHaveCount(0); + await page.clock.runFor(35_000); + await expect(page.locator("#boot-error")).toHaveCount(0); + expect(await page.evaluate(() => sessionStorage.getItem("boot-error-seen"))).toBeNull(); +}); diff --git a/package.json b/package.json index f851c9ca6..be6f3f58f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/boot.js b/public/boot.js index 6f81c4e8b..81e9f6506 100644 --- a/public/boot.js +++ b/public/boot.js @@ -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. diff --git a/public/splash.css b/public/splash.css index ecb40b616..349fcd0c9 100644 --- a/public/splash.css +++ b/public/splash.css @@ -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; +} diff --git a/scripts/check-webkit.test.ts b/scripts/check-webkit.test.ts new file mode 100644 index 000000000..8c7480f0c --- /dev/null +++ b/scripts/check-webkit.test.ts @@ -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", + "/(? { + 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 = /(?a)/u;', true)).toEqual([]); + expect(checkSource("const regex = /(\\(\\?<=)|(\\(\\? { + const dir = mkdtempSync(join(tmpdir(), "openidea-webkit-")); + try { + mkdirSync(join(dir, "assets")); + writeFileSync(join(dir, "index.html"), ''); + 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 }); + } +}); diff --git a/scripts/check-webkit.ts b/scripts/check-webkit.ts new file mode 100644 index 000000000..a3f43d7cc --- /dev/null +++ b/scripts/check-webkit.ts @@ -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(); + 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(/" })); + document.querySelector("button")?.click(); + await vi.waitFor(() => expect(document.querySelector('[role="status"]')?.textContent).toContain("Select")); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining("")); + 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); +}); diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 97b61d824..07eff2586 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -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." } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index eb1a6bcc2..a9e88ecc2 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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 秒内完成。" } diff --git a/src/platform/locales/textCatalog.test.ts b/src/platform/locales/textCatalog.test.ts index f7b04bbf3..c6c7ce7a2 100644 --- a/src/platform/locales/textCatalog.test.ts +++ b/src/platform/locales/textCatalog.test.ts @@ -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(); - 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"); diff --git a/src/platform/splash.ts b/src/platform/splash.ts index 6a5b50fbe..431b22603 100644 --- a/src/platform/splash.ts +++ b/src/platform/splash.ts @@ -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, );