build / build (push) Successful in 1m31s
在 c93313c 之上增量落地原型与前端规范,保留既有流程图入口/失败提示/指派预览/线级停发:
- 审批/抄送抽屉按原型重构(422→1026 / 239→742 行):科室树+成员多选、SYSTEM/CUSTOM 分支、
CUSTOM 分类导航自动定位、跨节点导航重置、保存后重开回显
- 新增授权管理页 /admin/usage-grants(UsageGrants.vue,417 行):校域清单、按流程线筛选、
授予/撤销后回读,失败不宣称成功
- 路由与 API:新增 usage-grant(schoolList/summary/grant/revoke)、department(groups/options/resolve/members)、
instance/resubmit;TaskHandle 增加「退回待修改」入口(仅 status=50 显示)
- 错误处理沿用 flowError 统一出口,不透出后端原文
- 组件/契约测试:flow-error、flow2-contract、department-roundtrip、task-result
不含构建产物(dist-verify)与依赖目录
289 lines
13 KiB
TypeScript
289 lines
13 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()!.src;
|
||
expect(src.split("#").length - 1).toBe(1);
|
||
expect(src).toContain("#/assign-preview");
|
||
expect(src).toContain(`${APP_ORIGIN}/#/assign-preview?`);
|
||
expect((wrapper.vm as any).contextId).toBeTruthy();
|
||
expect(src).toContain("flowGuid=g-embed");
|
||
expect(src).toContain(`contextId=${(wrapper.vm as any).contextId}`);
|
||
});
|
||
|
||
it("缺少 flowGuid 时不开框、不监听", async () => {
|
||
const addSpy = vi.spyOn(window, "addEventListener");
|
||
const wrapper = mountHost({ flowGuid: "" });
|
||
(wrapper.vm as any).open();
|
||
await nextTick();
|
||
await flushPromises();
|
||
expect(frameEl()).toBeNull();
|
||
expect(addSpy.mock.calls.filter((c) => c[0] === "message")).toHaveLength(0);
|
||
addSpy.mockRestore();
|
||
});
|
||
});
|
||
|
||
describe("FlowAssignEmbed 消息校验", () => {
|
||
it("换线立即结束旧会话,旧 iframe 不能借新 props 回传选择", async () => {
|
||
const wrapper = mountHost(); await openHost(wrapper);
|
||
const contextId = (wrapper.vm as any).contextId;
|
||
await wrapper.setProps({ flowGuid: 'different-flow' });
|
||
await post(wrapper, baseData({ contextId, flowGuid: 'different-flow' }));
|
||
expect(wrapper.emitted('confirmed')).toBeUndefined();
|
||
expect(wrapper.emitted('cancelled')).toHaveLength(1);
|
||
});
|
||
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);
|
||
const payload = events![0][0] as any;
|
||
expect(payload.flowGuid).toBe("g-embed");
|
||
expect(payload.defVersion).toBe(3);
|
||
expect(payload.assigns).toEqual([{ nodeId: "u2", userIds: [101] }]);
|
||
expect(wrapper.emitted("cancelled")).toBeUndefined();
|
||
});
|
||
|
||
it("异源、异窗口、异 contextId、畸形信封一律丢弃,不影响宿主", async () => {
|
||
const wrapper = mountHost();
|
||
await openHost(wrapper);
|
||
const cid = (wrapper.vm as any).contextId;
|
||
await post(wrapper, baseData({ contextId: cid }), { origin: "https://evil.example" });
|
||
await post(wrapper, baseData({ contextId: cid }), { source: { __other: true } });
|
||
await post(wrapper, baseData({ contextId: "stale-ctx" }));
|
||
await post(wrapper, { proto: "fc", v: 1, type: "assign-confirmed", contextId: cid });
|
||
await post(wrapper, baseData({ contextId: cid, v: 2 }));
|
||
await post(wrapper, baseData({ contextId: cid, type: "assign-mystery" }));
|
||
await post(wrapper, { nope: true });
|
||
await flushPromises();
|
||
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 }));
|
||
await post(wrapper, baseData({ contextId: cid, defVersion: undefined }));
|
||
await post(wrapper, baseData({ contextId: cid, flowGuid: undefined }));
|
||
await flushPromises();
|
||
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: [999] }] }));
|
||
const events = wrapper.emitted("confirmed");
|
||
expect(events).toHaveLength(1);
|
||
expect((events![0][0] as any).assigns[0].userIds).toEqual([101]);
|
||
});
|
||
|
||
it("宿主自己关框(未答复):按取消处理并摘监听", async () => {
|
||
const removeSpy = vi.spyOn(window, "removeEventListener");
|
||
const wrapper = mountHost();
|
||
await openHost(wrapper);
|
||
(wrapper.vm as any).close();
|
||
await nextTick();
|
||
expect(wrapper.emitted("cancelled")).toHaveLength(1);
|
||
expect(removeSpy.mock.calls.filter((c) => c[0] === "message").length).toBeGreaterThan(0);
|
||
removeSpy.mockRestore();
|
||
});
|
||
|
||
it("重开换新 contextId:旧会话残消息不影响新一轮,确认以最近一次为准(B 替换 A)", async () => {
|
||
const wrapper = mountHost();
|
||
await openHost(wrapper);
|
||
const cid1 = (wrapper.vm as any).contextId;
|
||
await post(wrapper, baseData({ contextId: cid1 }));
|
||
expect((wrapper.emitted("confirmed")![0][0] as any).assigns[0].userIds).toEqual([101]);
|
||
|
||
await openHost(wrapper);
|
||
const cid2 = (wrapper.vm as any).contextId;
|
||
expect(cid2).not.toEqual(cid1);
|
||
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();
|
||
});
|
||
});
|
||
|
||
describe("FlowAssignEmbed 载荷逐字段校验与真实关闭(F03/F07)", () => {
|
||
/** F03:宿主必须按打开时锁定的流程与字段类型核载荷,畸形载荷不得覆盖节点配置 */
|
||
it.each([
|
||
["异流程回传", () => ({ flowGuid: "g-other" })],
|
||
["字符串 defVersion", () => ({ defVersion: "3" })],
|
||
["零 defVersion", () => ({ defVersion: 0 })],
|
||
["小数 defVersion", () => ({ defVersion: 1.5 })],
|
||
["空 nodeId", () => ({ assigns: [{ nodeId: "", userIds: [101] }] })],
|
||
["字符串型 userIds", () => ({ assigns: [{ nodeId: "u2", userIds: ["101"] }] })],
|
||
["空 userIds", () => ({ assigns: [{ nodeId: "u2", userIds: [] }] })],
|
||
["assigns 不是数组", () => ({ assigns: "u2=101" })],
|
||
])("畸形/异源载荷被丢弃:%s", async (_label, mutate) => {
|
||
const wrapper = mountHost();
|
||
await openHost(wrapper);
|
||
const cid = (wrapper.vm as any).contextId;
|
||
await post(wrapper, baseData({ contextId: cid, ...(mutate() as object) }));
|
||
expect(wrapper.emitted("confirmed")).toBeUndefined();
|
||
expect(wrapper.emitted("cancelled")).toBeUndefined();
|
||
});
|
||
|
||
it("缺少可核验的窗口引用时不处理消息(不再是「缺条件就跳过校验」)", async () => {
|
||
const wrapper = mountHost();
|
||
await openHost(wrapper);
|
||
const cid = (wrapper.vm as any).contextId;
|
||
await post(wrapper, baseData({ contextId: cid }), { source: {} as Window });
|
||
expect(wrapper.emitted("confirmed")).toBeUndefined();
|
||
});
|
||
|
||
/** F07:点 X 是真实用户路径,必须走同一 settle/close 并通知宿主取消 */
|
||
it("点 Modal 的 X:宿主收到一次 cancelled,原有选择未被覆盖,监听器已摘", async () => {
|
||
const wrapper = mountHost();
|
||
await openHost(wrapper);
|
||
const closeBtn = document.querySelector(".ivu-modal-close");
|
||
expect(closeBtn).not.toBeNull();
|
||
(closeBtn as HTMLElement).click();
|
||
await nextTick();
|
||
await flushPromises();
|
||
const cancelled = wrapper.emitted("cancelled");
|
||
expect(cancelled).toHaveLength(1);
|
||
expect(wrapper.emitted("confirmed")).toBeUndefined();
|
||
|
||
const cid = (wrapper.vm as any).contextId;
|
||
await post(wrapper, baseData({ contextId: cid }));
|
||
expect(wrapper.emitted("confirmed")).toBeUndefined();
|
||
expect(wrapper.emitted("cancelled")).toHaveLength(1);
|
||
});
|
||
|
||
it("程序化 close 与 X 走同一路径:重复关闭只 emit 一次 cancelled", async () => {
|
||
const wrapper = mountHost();
|
||
await openHost(wrapper);
|
||
(wrapper.vm as any).close();
|
||
(wrapper.vm as any).close();
|
||
const closeBtn = document.querySelector(".ivu-modal-close");
|
||
(closeBtn as HTMLElement)?.click();
|
||
await nextTick();
|
||
await flushPromises();
|
||
expect(wrapper.emitted("cancelled")).toHaveLength(1);
|
||
});
|
||
|
||
it("确认后不得再补发 cancelled(iframe 已答复,Modal 收起只是收尾)", async () => {
|
||
const wrapper = mountHost();
|
||
await openHost(wrapper);
|
||
const cid = (wrapper.vm as any).contextId;
|
||
await post(wrapper, baseData({ contextId: cid }));
|
||
expect(wrapper.emitted("confirmed")).toHaveLength(1);
|
||
const closeBtn = document.querySelector(".ivu-modal-close");
|
||
(closeBtn as HTMLElement)?.click();
|
||
await nextTick();
|
||
await flushPromises();
|
||
expect(wrapper.emitted("cancelled")).toBeUndefined();
|
||
expect(wrapper.emitted("confirmed")).toHaveLength(1);
|
||
});
|
||
});
|