mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 17:02:17 +00: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
This commit is contained in:
1 parent
152b71f5e0
commit
6bf1794ed6
12 files changed
+558
-23
No files matched your search
@@ -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<void>((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();
|
||||
});
|
||||
+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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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 { 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
Block a user