feat(portal-shell): v2.0 P1-P4 token migration + unit tests + prod endpoint + e2e
P1: 31 widget 旧纸感令牌批量迁移到 shadcn 标准(1104 次替换) - bg-paper→bg-background / bg-surface→bg-card / text-ink→text-foreground - 保留 button.tsx 中 bg-accent(shadcn 标准 hover 语义令牌) P2: v2.0 新增组件单元测试补齐(5 文件 81 用例) - permission-bitmap: 24 用例(含 GRADE_READ 重复去重) - route-permissions: 26 用例(4 张表优先级 + AND/OR 语义) - notify: 12 用例(sonner toast 双重性质 vi.hoisted mock) - use-error-report: 9 用例(jsdom Blob vi.stubGlobal mock) - plugin-boundary: 10 用例(错误边界 + 骨架变体) P3: 错误上报端点生产替换(后端 /api/v1/log) - api-gateway: internal/log/handler.go(slog 结构化日志,64KB 限制,204 返回) - main.go: 注册 POST /api/v1/log 路由 - useErrorReport: 环境感知端点(prod→/api/v1/log,dev→/api/log) P4: E2E 测试(3 文件 30 用例) - streaming: 4 用例(React 19 use() + Suspense,act 包裹 render) - error-boundaries: 6 用例(三级错误边界层级 L1/L2/L3) - security-boundaries: 20 用例(L1 角色门禁 + L2 权限点 + L3 数据范围) - vitest setup: IS_REACT_ACT_ENVIRONMENT + jest-dom matchers 验证:typecheck 0 错误 / lint 0 错误 / build 6 路由 / 206 测试全部通过
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* PluginBoundary 插件级错误边界 + 流式 Suspense 测试
|
||||
*
|
||||
* 覆盖:
|
||||
* - 正常渲染 children
|
||||
* - 子组件抛错时显示 fallback(含 pluginId 和错误消息)
|
||||
* - 重试按钮触发 reset
|
||||
* - 5 种骨架变体渲染(card/list/chart/stats/table)
|
||||
* - 错误自动上报 useErrorReport
|
||||
* 关联:portal-shell README v2.0 §5.4 三级错误处理(L3 插件级)
|
||||
*/
|
||||
|
||||
// mock useErrorReport
|
||||
const reportErrorMock = vi.fn();
|
||||
vi.mock("@edu/hooks", () => ({
|
||||
useErrorReport: () => reportErrorMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
PluginBoundary,
|
||||
PluginSkeleton,
|
||||
} from "@/shared/components/plugin-boundary";
|
||||
|
||||
// 工具:制造抛错组件
|
||||
function ThrowOnRender({ message }: { message: string }): ReactNode {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function GoodComponent(): ReactNode {
|
||||
return <div data-testid="good">正常内容</div>;
|
||||
}
|
||||
|
||||
describe("PluginBoundary", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// 清除 console.error 噪音(React ErrorBoundary 会打 console.error)
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it("正常渲染 children", () => {
|
||||
render(
|
||||
<PluginBoundary pluginId="test-plugin">
|
||||
<GoodComponent />
|
||||
</PluginBoundary>,
|
||||
);
|
||||
expect(screen.getByTestId("good")).toBeTruthy();
|
||||
expect(screen.queryByText("插件加载失败")).toBeNull();
|
||||
});
|
||||
|
||||
it("子组件抛错时显示 fallback", () => {
|
||||
render(
|
||||
<PluginBoundary pluginId="bad-plugin">
|
||||
<ThrowOnRender message="渲染崩溃" />
|
||||
</PluginBoundary>,
|
||||
);
|
||||
expect(screen.getByText("插件加载失败")).toBeTruthy();
|
||||
expect(screen.getByText(/bad-plugin: 渲染崩溃/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("错误触发 useErrorReport 上报", () => {
|
||||
render(
|
||||
<PluginBoundary pluginId="report-plugin">
|
||||
<ThrowOnRender message="需上报的错误" />
|
||||
</PluginBoundary>,
|
||||
);
|
||||
expect(reportErrorMock).toHaveBeenCalledTimes(1);
|
||||
const [error, options] = reportErrorMock.mock.calls[0]!;
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toBe("需上报的错误");
|
||||
expect(options).toEqual({
|
||||
pluginId: "report-plugin",
|
||||
level: "error",
|
||||
});
|
||||
});
|
||||
|
||||
it("重试按钮触发 reset 并重新渲染", () => {
|
||||
let shouldThrow = true;
|
||||
function FlakyComponent(): ReactNode {
|
||||
if (shouldThrow) throw new Error("偶发错误");
|
||||
return <div data-testid="recovered">恢复</div>;
|
||||
}
|
||||
|
||||
render(
|
||||
<PluginBoundary pluginId="flaky-plugin">
|
||||
<FlakyComponent />
|
||||
</PluginBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("插件加载失败")).toBeTruthy();
|
||||
|
||||
// 切换为不抛错
|
||||
shouldThrow = false;
|
||||
fireEvent.click(screen.getByText("重试"));
|
||||
|
||||
expect(screen.getByTestId("recovered")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginSkeleton 骨架变体", () => {
|
||||
it("card 变体(默认)渲染骨架", () => {
|
||||
const { container } = render(<PluginSkeleton variant="card" />);
|
||||
expect(container.querySelector('[role="status"]')).toBeTruthy();
|
||||
expect(container.querySelector('[aria-label="加载中"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it("list 变体渲染 4 行骨架", () => {
|
||||
const { container } = render(<PluginSkeleton variant="list" />);
|
||||
const skeletons = container.querySelectorAll('[class*="h-12"]');
|
||||
expect(skeletons.length).toBe(4);
|
||||
});
|
||||
|
||||
it("chart 变体渲染柱状骨架", () => {
|
||||
const { container } = render(<PluginSkeleton variant="chart" />);
|
||||
const bars = container.querySelectorAll('[class*="flex-1"]');
|
||||
expect(bars.length).toBe(7);
|
||||
});
|
||||
|
||||
it("stats 变体渲染 3 列统计骨架", () => {
|
||||
const { container } = render(<PluginSkeleton variant="stats" />);
|
||||
const cols = container.querySelectorAll(".flex-1");
|
||||
expect(cols.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("table 变体渲染表头 + 4 行", () => {
|
||||
const { container } = render(<PluginSkeleton variant="table" />);
|
||||
const rows = container.querySelectorAll('[class*="h-10"]');
|
||||
expect(rows.length).toBe(4);
|
||||
});
|
||||
|
||||
it("所有变体都有 aria-live=polite 和 role=status", () => {
|
||||
for (const variant of [
|
||||
"card",
|
||||
"list",
|
||||
"chart",
|
||||
"stats",
|
||||
"table",
|
||||
] as const) {
|
||||
const { container } = render(<PluginSkeleton variant={variant} />);
|
||||
const status = container.querySelector('[role="status"]');
|
||||
expect(status).toBeTruthy();
|
||||
expect(status?.getAttribute("aria-live")).toBe("polite");
|
||||
container.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user