diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 376f26399..586cbafe9 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -97,7 +97,7 @@ jobs: run: > pnpm exec vitest run --coverage - --silent + --silent=passed-only --reporter=blob --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} @@ -158,6 +158,7 @@ jobs: pnpm exec vitest --merge-reports --coverage + --silent=passed-only --reporter=default --reporter.default.summary=false diff --git a/packages/message/custom_event_message.test.ts b/packages/message/custom_event_message.test.ts new file mode 100644 index 000000000..190d7f3cb --- /dev/null +++ b/packages/message/custom_event_message.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest"; +import { CustomEventMessage } from "./custom_event_message"; +import { createMouseEvent, pageDispatchEvent } from "@Packages/message/common"; + +let flagCounter = 0; + +function createMessagePair() { + const eventFlag = `custom-event-message-test-${++flagCounter}`; + const sender = new CustomEventMessage(eventFlag, false, ""); + const receiver = new CustomEventMessage(eventFlag, true, ""); + + expect(sender.readyWrap.isReady).toBe(true); + expect(receiver.readyWrap.isReady).toBe(true); + expect(sender.relatedTarget).toHaveProperty("size", 0); + expect(receiver.relatedTarget).toHaveProperty("size", 0); + return { sender, receiver }; +} + +describe("CustomEventMessage relatedTarget lifecycle", () => { + it("stores a received target on the receiving message until it is consumed", () => { + const { sender, receiver } = createMessagePair(); + const target = document.createElement("div"); + const id = sender.sendRelatedTarget(target); + + try { + expect(receiver.relatedTarget.get(id)).toBe(target); + expect(receiver.relatedTarget).toHaveProperty("size", 1); + + expect(receiver.getAndDelRelatedTarget(id)).toBe(target); + expect(receiver.relatedTarget.has(id)).toBe(false); + } finally { + receiver.getAndDelRelatedTarget(id); + } + }); + + it("releases multiple targets after out-of-order consumption", () => { + const { sender, receiver } = createMessagePair(); + const targets = [document.createElement("div"), document.createElement("span"), document.createElement("p")]; + const ids = targets.map((target) => sender.sendRelatedTarget(target)); + + try { + expect(receiver.relatedTarget).toHaveProperty("size", targets.length); + expect(receiver.getAndDelRelatedTarget(ids[1])).toBe(targets[1]); + expect(receiver.relatedTarget).toHaveProperty("size", 2); + expect(receiver.getAndDelRelatedTarget(ids[1])).toBeUndefined(); + expect(receiver.relatedTarget).toHaveProperty("size", 2); + expect(receiver.getAndDelRelatedTarget(ids[2])).toBe(targets[2]); + expect(receiver.getAndDelRelatedTarget(ids[0])).toBe(targets[0]); + expect(receiver.relatedTarget).toHaveProperty("size", 0); + } finally { + ids.forEach((id) => receiver.getAndDelRelatedTarget(id)); + } + }); + + it("does not let an unknown target id affect queued targets", () => { + const { sender, receiver } = createMessagePair(); + const target = document.createElement("div"); + const id = sender.sendRelatedTarget(target); + + try { + expect(receiver.getAndDelRelatedTarget(id + 1)).toBeUndefined(); + expect(receiver.relatedTarget).toHaveProperty("size", 1); + expect(receiver.getAndDelRelatedTarget(id)).toBe(target); + expect(receiver.getAndDelRelatedTarget(id)).toBeUndefined(); + expect(receiver.relatedTarget).toHaveProperty("size", 0); + } finally { + receiver.getAndDelRelatedTarget(id); + } + }); + + it("keeps targets from opposite message directions independently owned", () => { + const { sender, receiver } = createMessagePair(); + const senderTarget = document.createElement("div"); + const receiverTarget = document.createElement("span"); + const senderTargetId = sender.sendRelatedTarget(senderTarget); + const receiverTargetId = receiver.sendRelatedTarget(receiverTarget); + + try { + expect(receiver.relatedTarget.get(senderTargetId)).toBe(senderTarget); + expect(sender.relatedTarget.get(receiverTargetId)).toBe(receiverTarget); + expect(sender.relatedTarget.get(senderTargetId)).toBeUndefined(); + expect(receiver.relatedTarget.get(receiverTargetId)).toBeUndefined(); + expect(sender.getAndDelRelatedTarget(senderTargetId)).toBeUndefined(); + expect(receiver.getAndDelRelatedTarget(receiverTargetId)).toBeUndefined(); + expect(receiver.getAndDelRelatedTarget(senderTargetId)).toBe(senderTarget); + expect(sender.getAndDelRelatedTarget(receiverTargetId)).toBe(receiverTarget); + expect(sender.relatedTarget.has(senderTargetId)).toBe(false); + expect(receiver.relatedTarget.has(receiverTargetId)).toBe(false); + } finally { + sender.getAndDelRelatedTarget(senderTargetId); + receiver.getAndDelRelatedTarget(receiverTargetId); + } + }); + + it("keeps targets from different event channels independently owned", () => { + const first = createMessagePair(); + const second = createMessagePair(); + const firstTarget = document.createElement("div"); + const secondTarget = document.createElement("span"); + const firstId = first.sender.sendRelatedTarget(firstTarget); + const secondId = second.sender.sendRelatedTarget(secondTarget); + + try { + expect(first.receiver.getAndDelRelatedTarget(secondId)).toBeUndefined(); + expect(second.receiver.getAndDelRelatedTarget(firstId)).toBeUndefined(); + expect(first.receiver.getAndDelRelatedTarget(firstId)).toBe(firstTarget); + expect(second.receiver.getAndDelRelatedTarget(secondId)).toBe(secondTarget); + expect(first.receiver.relatedTarget).toHaveProperty("size", 0); + expect(second.receiver.relatedTarget).toHaveProperty("size", 0); + } finally { + first.receiver.getAndDelRelatedTarget(firstId); + second.receiver.getAndDelRelatedTarget(secondId); + } + }); + + it("does not retain entries for ordinary messages or unrelated mouse events", async () => { + const { sender, receiver } = createMessagePair(); + receiver.onMessage((_data, sendResponse) => sendResponse({ code: 0, data: "ok" })); + + expect( + await sender.sendMessage({ + action: "custom-event-message-test/ordinary-async", + data: {}, + }) + ).toEqual({ code: 0, data: "ok" }); + expect(sender.syncSendMessage({ action: "custom-event-message-test/ordinary-sync", data: {} })).toEqual({ + code: 0, + data: "ok", + }); + + pageDispatchEvent( + createMouseEvent(receiver.receiveFlag, { + movementX: 0, + relatedTarget: document.createElement("div"), + cancelable: true, + }) + ); + pageDispatchEvent(createMouseEvent(receiver.receiveFlag, { movementX: 1, cancelable: true })); + + expect(sender.relatedTarget).toHaveProperty("size", 0); + expect(receiver.relatedTarget).toHaveProperty("size", 0); + }); + + it("does not retain entries when sending before the channel is ready", () => { + const sender = new CustomEventMessage(`custom-event-message-test-${++flagCounter}`, false, ""); + const target = document.createElement("div"); + + expect(sender.readyWrap.isReady).toBe(false); + expect(() => sender.sendRelatedTarget(target)).toThrow("custom_event_message is not ready."); + expect(sender.relatedTarget).toHaveProperty("size", 0); + }); +}); diff --git a/packages/message/custom_event_message.ts b/packages/message/custom_event_message.ts index 5d09f5afa..63b33038a 100644 --- a/packages/message/custom_event_message.ts +++ b/packages/message/custom_event_message.ts @@ -15,10 +15,19 @@ import { ReadyWrap } from "@App/pkg/utils/ready-wrap"; import type { ScriptEnvTag } from "@Packages/message/consts"; // 避免页面载入后改动 Map.prototype 导致消息传递失败 -const relatedTargetMap = new Map(); -relatedTargetMap.set = Map.prototype.set; -relatedTargetMap.get = Map.prototype.get; -relatedTargetMap.delete = Map.prototype.delete; +const relatedTargetMapMethods = { + set: Map.prototype.set, + get: Map.prototype.get, + delete: Map.prototype.delete, +}; + +function createRelatedTargetMap() { + const map = new Map(); + map.set = relatedTargetMapMethods.set; + map.get = relatedTargetMapMethods.get; + map.delete = relatedTargetMapMethods.delete; + return map; +} let relateId = 0; const maxInteger = Number.MAX_SAFE_INTEGER; @@ -38,7 +47,7 @@ export class CustomEventMessage implements Message { readonly sendFlag: string; // 关联dom目标 - relatedTarget: Map = new Map(); + relatedTarget: Map = createRelatedTargetMap(); readyWrap: ReadyWrap = new ReadyWrap(); constructor( @@ -55,7 +64,7 @@ export class CustomEventMessage implements Message { this.readyWrap.setReady(); // 两端已准备好,则 setReady() } else if (event instanceof MouseEventClone && event.movementX && event.relatedTarget) { if (event.cancelable) event.preventDefault(); // 告知另一端 - relatedTargetMap.set(event.movementX, event.relatedTarget); + this.relatedTarget.set(event.movementX, event.relatedTarget); } else if (event instanceof CustomEventClone) { this.messageHandle(event.detail, new CustomEventPostMessage(this)); } @@ -185,8 +194,8 @@ export class CustomEventMessage implements Message { } getAndDelRelatedTarget(id: number) { - const target = relatedTargetMap.get(id); - relatedTargetMap.delete(id); + const target = this.relatedTarget.get(id); + this.relatedTarget.delete(id); return target; } } diff --git a/src/app/service/content/gm_api/related_target_lifecycle.test.ts b/src/app/service/content/gm_api/related_target_lifecycle.test.ts new file mode 100644 index 000000000..722111552 --- /dev/null +++ b/src/app/service/content/gm_api/related_target_lifecycle.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { ScriptEnvTag } from "@Packages/message/consts"; +import { CustomEventMessage } from "@Packages/message/custom_event_message"; +import { Server } from "@Packages/message/server"; +import type { ScriptRunResource } from "@App/app/repo/scripts"; +import { ScriptExecutor } from "../script_executor"; +import { ScriptRuntime } from "../script_runtime"; +import GMApi from "./gm_api"; + +let flagCounter = 0; + +function createApiWithContentRuntime() { + const eventFlag = `related-target-lifecycle-test-${++flagCounter}`; + const sender = new CustomEventMessage(eventFlag, false, ""); + const receiver = new CustomEventMessage(eventFlag, true, ""); + const server = new Server("content", receiver); + const runtime = new ScriptRuntime( + ScriptEnvTag.content, + server, + receiver, + new ScriptExecutor(receiver, receiver), + undefined + ); + runtime.contentInit(); + + const scriptRes: ScriptRunResource = { + uuid: "related-target-lifecycle-test", + name: "related-target-lifecycle-test", + namespace: "test", + metadata: {}, + sort: 0, + type: 1, + status: 1, + runStatus: "complete", + createtime: 0, + checktime: 0, + code: "", + value: {}, + flag: "related-target-lifecycle-test", + resource: {}, + originalMetadata: {}, + }; + const api = new GMApi("test", sender, sender, scriptRes); + return { api, sender, receiver }; +} + +describe("relatedTarget lifecycle across content runtime callers", () => { + it("consumes returned elements and parent nodes for GM DOM operations", () => { + const { api, sender, receiver } = createApiWithContentRuntime(); + const parent = document.createElement("section"); + + try { + const style = api.GM_addStyle("body { color: red; }"); + expect(style?.tagName).toBe("STYLE"); + expect(style?.textContent).toBe("body { color: red; }"); + expect(sender.relatedTarget).toHaveProperty("size", 0); + expect(receiver.relatedTarget).toHaveProperty("size", 0); + + const child = api.GM_addElement(parent, "span", { id: "child" }); + expect(child?.parentNode).toBe(parent); + expect(child?.id).toBe("child"); + expect(sender.relatedTarget).toHaveProperty("size", 0); + expect(receiver.relatedTarget).toHaveProperty("size", 0); + + const root = api.GM_addElement("div", { id: "root" }); + expect(root?.tagName).toBe("DIV"); + expect(root?.id).toBe("root"); + expect(sender.relatedTarget).toHaveProperty("size", 0); + expect(receiver.relatedTarget).toHaveProperty("size", 0); + } finally { + sender.relatedTarget.clear(); + receiver.relatedTarget.clear(); + } + }); +});