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:
SpecialX
2026-07-17 16:49:00 +08:00
parent 9cedf0c437
commit f586a0b19e
46 changed files with 2415 additions and 559 deletions

View File

@@ -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();
}
});
});