- 修复办理页「查看流程图」双 hash URL:改 router.resolve + toAppHashUrl 单 hash
- 修复管理端办件抽屉「查看大图」域名尾点(origin + BASE_URL("./") 拼接)
- 新增 flowError 口径层与办理页内联失败条,审批失败提示可读、不泄漏堆栈
- 审批节点抽屉新增「预览候选人」真入口 + AssigneePreviewModal(只读,不写回定义)
- 线级停发/恢复失败改为单条错误面,避免同错双 toast
- 审批抽屉核心区/高级设置分层;插入菜单常用/高级分组
- 新增 FlowAssignEmbed 选人 iframe 父侧宿主(origin/source/信封/context 四校验)
- 修复 /diagram、/assign-preview 面包屑误显示「流程编辑器」
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { mount, flushPromises, enableAutoUnmount, type VueWrapper } from "@vue/test-utils";
|
||||
import ViewUIPlus from "view-ui-plus";
|
||||
import { nextTick } from "vue";
|
||||
|
||||
import FlowAssignEmbed from "@/components/FlowAssignEmbed.vue";
|
||||
|
||||
/**
|
||||
* FL 侧选人 iframe 宿主的协议测试(自动化测试宿主,不是产品入口)。
|
||||
* 覆盖:地址构造(单 hash)、四道校验、监听器生命周期、取消保留旧选择、确认后 B 替换 A。
|
||||
*/
|
||||
|
||||
enableAutoUnmount(afterEach);
|
||||
|
||||
const PROTO = "flow";
|
||||
const VERSION = 1;
|
||||
const APP_ORIGIN = window.location.origin;
|
||||
|
||||
function baseData(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
proto: PROTO,
|
||||
v: VERSION,
|
||||
type: "assign-confirmed",
|
||||
contextId: "",
|
||||
flowGuid: "g-embed",
|
||||
defVersion: 3,
|
||||
assigns: [{ nodeId: "u2", userIds: [101] }],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/** view-ui-plus Modal 默认把内容 teleport 到 body,组件子树里查不到——按真实 DOM 取 */
|
||||
function frameEl(): HTMLIFrameElement | null {
|
||||
return document.querySelector("iframe") as HTMLIFrameElement | null;
|
||||
}
|
||||
|
||||
/** 父页收到消息的唯一入口:直接派发 MessageEvent,绕开真实 iframe 加载 */
|
||||
function post(wrapper: VueWrapper, data: unknown, opts: { origin?: string; source?: unknown } = {}) {
|
||||
const frame = frameEl() as HTMLIFrameElement;
|
||||
const ev = new MessageEvent("message", {
|
||||
origin: opts.origin ?? APP_ORIGIN,
|
||||
source: (opts.source ?? frame.contentWindow) as Window | null,
|
||||
data,
|
||||
});
|
||||
window.dispatchEvent(ev);
|
||||
return nextTick();
|
||||
}
|
||||
|
||||
function mountHost(props: Record<string, unknown> = {}) {
|
||||
return mount(FlowAssignEmbed, {
|
||||
props: { flowGuid: "g-embed", ...props },
|
||||
global: { plugins: [ViewUIPlus] },
|
||||
attachTo: document.body,
|
||||
});
|
||||
}
|
||||
|
||||
/** 开框后 jsdom 仍不会真正加载子文档:给 iframe 挂一个显式窗口桩,父侧按它核 event.source */
|
||||
async function openHost(wrapper: VueWrapper) {
|
||||
(wrapper.vm as any).open();
|
||||
await nextTick();
|
||||
await flushPromises();
|
||||
const frame = frameEl() as HTMLIFrameElement;
|
||||
Object.defineProperty(frame, "contentWindow", { value: { __frame: true }, configurable: true });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("FlowAssignEmbed 地址与会话", () => {
|
||||
it("open 生成单 hash 的 iframe 地址,带 flowGuid 与本次 contextId", async () => {
|
||||
const wrapper = mountHost();
|
||||
await openHost(wrapper);
|
||||
const src = (frameEl() as HTMLIFrameElement).getAttribute("src") || "";
|
||||
expect(src.split("#").length - 1).toBe(1);
|
||||
expect(src).toContain(`${APP_ORIGIN}/#/assign-preview?`);
|
||||
expect(src).toContain("flowGuid=g-embed");
|
||||
expect(src).toContain("contextId=");
|
||||
expect((wrapper.vm as any).contextId).toBeTruthy();
|
||||
});
|
||||
|
||||
it("缺少 flowGuid 时不开框、不监听", async () => {
|
||||
const addSpy = vi.spyOn(window, "addEventListener");
|
||||
const wrapper = mountHost({ flowGuid: "" });
|
||||
(wrapper.vm as any).open();
|
||||
await nextTick();
|
||||
expect(frameEl()).toBeNull();
|
||||
expect(addSpy.mock.calls.filter((c) => c[0] === "message").length).toBe(0);
|
||||
addSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FlowAssignEmbed 消息校验", () => {
|
||||
it("协议相符的确认消息:回传最终选择与版本锚", async () => {
|
||||
const wrapper = mountHost();
|
||||
await openHost(wrapper);
|
||||
const cid = (wrapper.vm as any).contextId;
|
||||
await post(wrapper, baseData({ contextId: cid }));
|
||||
const events = wrapper.emitted("confirmed");
|
||||
expect(events).toHaveLength(1);
|
||||
expect((events[0][0] as any).assigns).toEqual([{ nodeId: "u2", userIds: [101] }]);
|
||||
expect((events[0][0] as any).defVersion).toBe(3);
|
||||
expect((events[0][0] as any).flowGuid).toBe("g-embed");
|
||||
});
|
||||
|
||||
it("异源、异窗口、异 contextId、畸形信封一律丢弃,不影响宿主", async () => {
|
||||
const wrapper = mountHost();
|
||||
await openHost(wrapper);
|
||||
const cid = (wrapper.vm as any).contextId;
|
||||
const fakeOtherWindow = { __other: true };
|
||||
|
||||
await post(wrapper, baseData({ contextId: cid }), { origin: "https://evil.example.com" });
|
||||
await post(wrapper, baseData({ contextId: cid }), { source: fakeOtherWindow });
|
||||
await post(wrapper, baseData({ contextId: "stale-from-prev-session" }));
|
||||
await post(wrapper, { proto: "fc", v: VERSION, type: "assign-confirmed", contextId: cid });
|
||||
await post(wrapper, { proto: PROTO, v: 99, type: "assign-confirmed", contextId: cid });
|
||||
await post(wrapper, { proto: PROTO, v: VERSION, type: "not-ours", contextId: cid });
|
||||
await post(wrapper, "plain-string-from-page-script");
|
||||
|
||||
expect(wrapper.emitted("confirmed")).toBeUndefined();
|
||||
expect(wrapper.emitted("cancelled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("确认消息缺 assigns 时形状校验拦下(不落成空选择)", async () => {
|
||||
const wrapper = mountHost();
|
||||
await openHost(wrapper);
|
||||
const cid = (wrapper.vm as any).contextId;
|
||||
await post(wrapper, baseData({ contextId: cid, assigns: undefined }));
|
||||
expect(wrapper.emitted("confirmed")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FlowAssignEmbed 生命周期与选择语义", () => {
|
||||
it("取消:只通知宿主,不产生确认,旧选择不被覆盖", async () => {
|
||||
const wrapper = mountHost();
|
||||
await openHost(wrapper);
|
||||
const cid = (wrapper.vm as any).contextId;
|
||||
await post(wrapper, baseData({ contextId: cid, type: "assign-cancelled" }));
|
||||
expect(wrapper.emitted("cancelled")).toHaveLength(1);
|
||||
expect(wrapper.emitted("confirmed")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("确认后同一会话的迟到消息不再触发(监听器已摘)", async () => {
|
||||
const wrapper = mountHost();
|
||||
await openHost(wrapper);
|
||||
const cid = (wrapper.vm as any).contextId;
|
||||
await post(wrapper, baseData({ contextId: cid }));
|
||||
await post(wrapper, baseData({ contextId: cid, assigns: [{ nodeId: "u2", userIds: [777] }] }));
|
||||
const events = wrapper.emitted("confirmed");
|
||||
expect(events).toHaveLength(1);
|
||||
expect((events[0][0] as any).assigns[0].userIds).toEqual([101]);
|
||||
});
|
||||
|
||||
it("宿主自己关框(未答复):按取消处理并摘监听", async () => {
|
||||
const wrapper = mountHost();
|
||||
await openHost(wrapper);
|
||||
const cid = (wrapper.vm as any).contextId;
|
||||
(wrapper.vm as any).close();
|
||||
await nextTick();
|
||||
expect(wrapper.emitted("cancelled")).toHaveLength(1);
|
||||
await post(wrapper, baseData({ contextId: cid }));
|
||||
expect(wrapper.emitted("confirmed")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("重开换新 contextId:旧会话残消息不影响新一轮,确认以最近一次为准(B 替换 A)", async () => {
|
||||
const wrapper = mountHost();
|
||||
await openHost(wrapper);
|
||||
const cid1 = (wrapper.vm as any).contextId;
|
||||
await post(wrapper, baseData({ contextId: cid1, assigns: [{ nodeId: "u2", userIds: [101] }] }));
|
||||
expect((wrapper.emitted("confirmed")![0][0] as any).assigns[0].userIds).toEqual([101]);
|
||||
|
||||
// 宿主把 A 记在内存里,重开选 B
|
||||
await openHost(wrapper);
|
||||
const cid2 = (wrapper.vm as any).contextId;
|
||||
expect(cid2).not.toEqual(cid1);
|
||||
|
||||
// 上一轮残帧补发一条:按 context 失效
|
||||
await post(wrapper, baseData({ contextId: cid1, assigns: [{ nodeId: "u2", userIds: [999] }] }));
|
||||
expect(wrapper.emitted("confirmed")).toHaveLength(1);
|
||||
|
||||
await post(wrapper, baseData({ contextId: cid2, assigns: [{ nodeId: "u2", userIds: [102, 103] }] }));
|
||||
const events = wrapper.emitted("confirmed");
|
||||
expect(events).toHaveLength(2);
|
||||
expect((events[1][0] as any).assigns[0].userIds).toEqual([102, 103]);
|
||||
});
|
||||
|
||||
it("卸载后监听器摘掉,后续消息不再回传", async () => {
|
||||
const removeSpy = vi.spyOn(window, "removeEventListener");
|
||||
const wrapper = mountHost();
|
||||
await openHost(wrapper);
|
||||
const cid = (wrapper.vm as any).contextId;
|
||||
const frame = frameEl() as HTMLIFrameElement;
|
||||
wrapper.unmount();
|
||||
expect(removeSpy.mock.calls.filter((c) => c[0] === "message").length).toBeGreaterThan(0);
|
||||
window.dispatchEvent(new MessageEvent("message", {
|
||||
origin: APP_ORIGIN,
|
||||
source: frame.contentWindow as Window,
|
||||
data: baseData({ contextId: cid }),
|
||||
}));
|
||||
await flushPromises();
|
||||
expect(wrapper.emitted("confirmed")).toBeUndefined();
|
||||
removeSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user