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