Q181: a render error stays inside its page and says what failed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fd91832bc4
commit
3039835644
5 files changed
+98
-3
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import { ErrorBoundary } from "../shared/ErrorBoundary";
|
||||
import { StartupScreen } from "./StartupScreen";
|
||||
import type { Focus } from "../pages/types";
|
||||
import { Fragment, useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Shell, type RouteId } from "../shared/Shell";
|
||||
import { AvatarDirectoryContext, type AvatarLook } from "../shared/ui";
|
||||
import { Onboarding } from "./Onboarding";
|
||||
@@ -105,7 +106,7 @@ export function App() {
|
||||
onDeleteOrg={(orgId) => void actions.run(() => window.agentCompany.deleteOrganization({ orgId }))}
|
||||
onNewOrg={() => setCreatingOrg(true)}
|
||||
>
|
||||
<Fragment key={pageKey}>{page}</Fragment>
|
||||
<ErrorBoundary key={pageKey} scope="page">{page}</ErrorBoundary>
|
||||
</Shell>
|
||||
</AvatarDirectoryContext.Provider>
|
||||
);
|
||||
|
||||
@@ -695,6 +695,12 @@ export const zh: Record<string, string> = {
|
||||
"Unsaved": "未保存",
|
||||
"Working language": "工作语言",
|
||||
"That did not work": "操作未成功",
|
||||
"This page could not be shown.": "此页面无法显示。",
|
||||
"AgentCompany hit an error it could not recover from here.": "AgentCompany 遇到了无法在此恢复的错误。",
|
||||
"The rest of the app still works; go to another page or try this one again.": "应用的其他部分仍可使用;可以前往其他页面,或重试此页面。",
|
||||
"Reloading the window starts it again; your data is kept.": "重新加载窗口即可重新开始;你的数据会保留。",
|
||||
"Try again": "重试",
|
||||
"Reload": "重新加载",
|
||||
"AgentCompany could not load its data.": "AgentCompany 无法加载数据。",
|
||||
"Retrying every few seconds (attempt {count}).": "每隔几秒重试一次(第 {count} 次)。",
|
||||
"Retry now": "立即重试",
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { App } from "./app/App";
|
||||
import { FailureToasts } from "./shared/FailureToasts";
|
||||
import { ErrorBoundary } from "./shared/ErrorBoundary";
|
||||
import "./styles/fonts.css";
|
||||
import "./styles.css";
|
||||
import "./styles/components.css";
|
||||
@@ -9,7 +10,9 @@ import "./overlays.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<ErrorBoundary scope="app">
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
<FailureToasts />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
import { Button } from "./ui";
|
||||
import { t } from "../i18n/i18n";
|
||||
|
||||
/** Contains a render error to the part of the app that threw, says what failed, and offers a way back; the error is logged with its component stack. */
|
||||
export class ErrorBoundary extends Component<{ scope: "page" | "app"; children: ReactNode }, { error: Error | null }> {
|
||||
state: { error: Error | null } = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): { error: Error } {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
console.error(`[render] ${this.props.scope} failed: ${error.message}`, info.componentStack);
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
const { error } = this.state;
|
||||
if (!error) return this.props.children;
|
||||
const page = this.props.scope === "page";
|
||||
return (
|
||||
<div role="alert" style={{ padding: 24, display: "grid", gap: 10, maxWidth: 560 }}>
|
||||
<strong>{page ? t("This page could not be shown.") : t("AgentCompany hit an error it could not recover from here.")}</strong>
|
||||
<div className="mono" style={{ fontSize: 12, color: "var(--text-2)", wordBreak: "break-word" }}>{error.message}</div>
|
||||
<div style={{ fontSize: 13, color: "var(--text-3)" }}>
|
||||
{page ? t("The rest of the app still works; go to another page or try this one again.") : t("Reloading the window starts it again; your data is kept.")}
|
||||
</div>
|
||||
<div>
|
||||
{page
|
||||
? <Button onClick={() => this.setState({ error: null })}>{t("Try again")}</Button>
|
||||
: <Button onClick={() => window.location.reload()}>{t("Reload")}</Button>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { _electron as electron, expect, test } from "@playwright/test";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { seedBuiltinSkillsFixture } from "./builtin-skills-fixture";
|
||||
|
||||
test("a record that breaks one page leaves the rest of the app working", async () => {
|
||||
const profileDir = join(tmpdir(), `agentcompany-new-e2e-render-${randomUUID()}`);
|
||||
mkdirSync(profileDir, { recursive: true });
|
||||
const builtinDir = seedBuiltinSkillsFixture(join(tmpdir(), `agentcompany-new-e2e-render-builtin-${randomUUID()}`));
|
||||
const app = await electron.launch({
|
||||
args: [resolve("out/main/index.js")],
|
||||
env: { ...process.env, AGENTCOMPANY_NEW_PROFILE_DIR: profileDir, AGENTCOMPANY_NEW_AUTORUN: "0", AGENTCOMPANY_NEW_BUILTIN_SKILLS_DIR: builtinDir },
|
||||
});
|
||||
try {
|
||||
const page = await app.firstWindow();
|
||||
await page.getByRole("button", { name: "Get started" }).click();
|
||||
await page.getByLabel("API key").fill("e2e-secret");
|
||||
await page.getByLabel("Model").fill("gpt-5.4");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await page.getByLabel("Company name").fill("Render Co");
|
||||
await page.getByLabel("Your name").fill("Dana Lee");
|
||||
await page.getByLabel("Description").fill("d");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await page.getByLabel("Goal").fill("Ship.");
|
||||
await page.getByLabel("Acceptance criteria").fill("It ships.");
|
||||
await page.getByRole("button", { name: "Launch" }).click();
|
||||
await page.getByRole("button", { name: "Finish" }).click();
|
||||
|
||||
// A pending request with a risk level the Approvals page cannot draw.
|
||||
const db = new DatabaseSync(join(profileDir, "agentcompany.db"));
|
||||
const orgId = (db.prepare("select id from organizations").get() as { id: string }).id;
|
||||
db.prepare("insert into approvals(id, org_id, kind, title, details, risk, requested_by_type, state, created_at) values ('app_bad', ?, 'task_risk', 'Broken request', 'd', 'unknown', 'system', 'pending', ?)")
|
||||
.run(orgId, new Date().toISOString());
|
||||
db.close();
|
||||
|
||||
await page.getByRole("button", { name: "Approvals" }).click();
|
||||
const failure = page.getByRole("alert").filter({ hasText: "This page could not be shown." });
|
||||
await expect(failure).toContainText("Approval risk tone is missing.");
|
||||
await page.getByRole("button", { name: "Home" }).click();
|
||||
await expect(failure).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Approvals" })).toBeVisible();
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user