Files
flow-center-front/tests/components/assign-embed.component.test.ts
T
chenxing c93313cdb7
build / build (push) Successful in 1m28s
feat(flow): 流程图入口/失败提示/指派预览/线级停发闭环/配置分层/选人宿主
- 修复办理页「查看流程图」双 hash URL:改 router.resolve + toAppHashUrl 单 hash
- 修复管理端办件抽屉「查看大图」域名尾点(origin + BASE_URL("./") 拼接)
- 新增 flowError 口径层与办理页内联失败条,审批失败提示可读、不泄漏堆栈
- 审批节点抽屉新增「预览候选人」真入口 + AssigneePreviewModal(只读,不写回定义)
- 线级停发/恢复失败改为单条错误面,避免同错双 toast
- 审批抽屉核心区/高级设置分层;插入菜单常用/高级分组
- 新增 FlowAssignEmbed 选人 iframe 父侧宿主(origin/source/信封/context 四校验)
- 修复 /diagram、/assign-preview 面包屑误显示「流程编辑器」
2026-10-28 03:18:54 +08:00

205 lines
8.6 KiB
TypeScript

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