Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
98 commits
Select commit Hold shift + click to select a range
c0444dc
🔒 harden MAIN-world GM RPC capability binding
cyfung1031 Sep 16, 2026
a6e69ed
🐛 allow async GM XHR page RPC alias
cyfung1031 Sep 16, 2026
ef945e8
🔒 harden userscript invocation against page substitution
cyfung1031 Sep 16, 2026
1eb1839
🔒 isolate USER_SCRIPT GM transport and binding rotation
cyfung1031 Sep 17, 2026
3037623
🐛 route USER_SCRIPT GM calls to service worker
cyfung1031 Sep 17, 2026
25714ff
🧹 format USER_SCRIPT transport regression test
cyfung1031 Sep 17, 2026
a46b042
🔒 harden page and USER_SCRIPT execution bindings
cyfung1031 Sep 17, 2026
d65bcf3
🔒 secure USER_SCRIPT bootstrap and page capability checks
cyfung1031 Sep 17, 2026
e1ccf07
🐛 preserve GM API registry enumeration
cyfung1031 Sep 17, 2026
9f98995
🐛 make GM API registry merge-compatible
cyfung1031 Sep 17, 2026
9868594
⚡ reduce native reflection overhead
cyfung1031 Sep 17, 2026
f49053a
⚡ reduce content callback overhead
cyfung1031 Sep 17, 2026
d56b7a8
Merge remote-tracking branch 'origin/main' into codex/main-world-secu…
cyfung1031 Sep 17, 2026
47c2704
⚡ avoid receiver binding for pure agent APIs
cyfung1031 Sep 17, 2026
8bcad36
⚡ capture native collection methods on subclasses
cyfung1031 Sep 17, 2026
f2aad91
⚡ remove GM API receiver binding
cyfung1031 Sep 17, 2026
f13f7e2
⚡️ reduce runtime function overhead
cyfung1031 Sep 18, 2026
edc3ab3
⚡️ remove redundant native collection factories
cyfung1031 Sep 18, 2026
b5007df
⚡️ simplify native set initialization
cyfung1031 Sep 18, 2026
4d194e1
📄 补充跨世界安全边界维护注释
cyfung1031 Sep 18, 2026
9d0de58
🔒 separate asynchronous page RPC transport
cyfung1031 Sep 18, 2026
3448728
🔒 hide GM broker state behind script facade
cyfung1031 Sep 18, 2026
6a014bf
🔒 reject executable values at GM clone boundaries
cyfung1031 Sep 18, 2026
eb66888
🐛 settle GM XHR aborts without callbacks
cyfung1031 Sep 18, 2026
1d0dd85
🐛 continue page script loading after early reconciliation
cyfung1031 Sep 18, 2026
7780934
🔒 clone DOM bridge payloads before validation
cyfung1031 Sep 18, 2026
2993bbc
🔒 validate Blob page RPC payloads
cyfung1031 Sep 18, 2026
9c0650d
🔒 reject executable GM values
cyfung1031 Sep 18, 2026
398c884
🔒 reject non-cloneable GM values
cyfung1031 Sep 18, 2026
06bbf29
🔒 harden page RPC intrinsic boundaries
cyfung1031 Sep 18, 2026
4a4a127
🐛 preserve zero-valued tab identities
cyfung1031 Sep 18, 2026
a5a9b84
🐛 delete falsy GM values consistently
cyfung1031 Sep 18, 2026
340fea9
🔒 avoid accessor execution in GM_setValues
cyfung1031 Sep 18, 2026
884642a
🔒 preserve none grant capability boundary
cyfung1031 Sep 18, 2026
3d45436
🔒 capture grant alias intrinsics
cyfung1031 Sep 18, 2026
764a550
🔒 isolate grant capability map lookups
cyfung1031 Sep 18, 2026
525b2ed
🔒 harden capability collection construction
cyfung1031 Sep 18, 2026
c37446a
🔒 seal page RPC collection iteration
cyfung1031 Sep 18, 2026
18255a0
🐛 honor early GM XHR aborts
cyfung1031 Sep 18, 2026
ad61560
🐛 settle GM XHR connection failures
cyfung1031 Sep 18, 2026
68f4d3c
🐛 settle GM XHR setup errors
cyfung1031 Sep 18, 2026
f4edd72
🔒 discard stale page load bindings
cyfung1031 Sep 18, 2026
842d5ee
🔒 harden GM value transport collection writes
cyfung1031 Sep 18, 2026
fa26a1b
🔒 bind page execution handles to navigation URL
cyfung1031 Sep 18, 2026
86193c0
🔒 hide CAT conversation state behind private fields
cyfung1031 Sep 18, 2026
0bc23a6
🔒 close GM XHR connections on setup failure
cyfung1031 Sep 18, 2026
35b6766
🔒 avoid accessor execution in GM DOM attributes
cyfung1031 Sep 18, 2026
cb49bf8
🔒 copy GM menu options without accessors
cyfung1031 Sep 18, 2026
ef3adf5
🔒 copy GM XHR headers without accessors
cyfung1031 Sep 18, 2026
5acba1b
🔒 copy GM tab options without accessors
cyfung1031 Sep 18, 2026
13d6645
🔒 copy notification details without accessors
cyfung1031 Sep 18, 2026
6bf76fd
⚡️ reduce USER_SCRIPT callback dispatch scans
cyfung1031 Sep 18, 2026
714008e
🔒 prune revoked USER_SCRIPT connections
cyfung1031 Sep 18, 2026
63b01b3
🔒 harden USER_SCRIPT reconnect sessions
cyfung1031 Sep 18, 2026
3a8c561
🔒 validate inject page bootstrap DTOs
cyfung1031 Sep 18, 2026
cf9ee7b
🔒 validate inject runtime callback DTOs
cyfung1031 Sep 18, 2026
d2b2d84
🔒 avoid duplicate USER_SCRIPT bootstrap execution
cyfung1031 Sep 18, 2026
a64a1cb
🔒 route MAIN privileged traffic over native channel
cyfung1031 Sep 18, 2026
281e449
🔒 redact early-start preload values
cyfung1031 Sep 18, 2026
50a08b2
🔒 settle XHR errors without broker loadend
cyfung1031 Sep 18, 2026
b84ccaf
🔒 isolate protected GM facade keys
cyfung1031 Sep 18, 2026
cfb698c
🔒 keep service worker bridge bundle DOM-free
cyfung1031 Sep 18, 2026
8df243d
🔒 validate USER_SCRIPT bridge DTOs
cyfung1031 Sep 18, 2026
beb742c
🔒 bind CAT tasks to script owners
cyfung1031 Sep 18, 2026
e2738c9
🔒 bind CAT conversations to script owners
cyfung1031 Sep 18, 2026
283edd2
🔒 bind USER_SCRIPT tokens to page URLs
cyfung1031 Sep 18, 2026
c8516c9
🔒 bind early-start metadata to injected wrappers
cyfung1031 Sep 18, 2026
20d39af
🔒 restore early-start manifest execution
cyfung1031 Sep 18, 2026
3372fbe
🔒 retire stale page bindings on navigation
cyfung1031 Sep 18, 2026
f371261
🔒 bind CAT agent service identities
cyfung1031 Sep 18, 2026
3ab6b2d
🔒 target private callbacks to frame zero
cyfung1031 Sep 18, 2026
2f5b1fb
🔒 bind DOM monitors to script owners
cyfung1031 Sep 18, 2026
1f769a6
🔒 gate MAIN page bootstrap transport
cyfung1031 Sep 18, 2026
2f8dcd6
🔒 preserve USER_SCRIPT native fallback
cyfung1031 Sep 18, 2026
901016d
🔒 queue USER_SCRIPT value updates across reconnects
cyfung1031 Sep 18, 2026
643af56
🔒 protect GM value stores from prototype keys
cyfung1031 Sep 18, 2026
8f4cf28
🔒 reject hostile page message envelopes
cyfung1031 Sep 18, 2026
5b25310
🔒 validate sandbox message envelopes
cyfung1031 Sep 18, 2026
898fcc4
🔒 validate custom event envelopes
cyfung1031 Sep 18, 2026
35ce581
🔒 guard server action dispatch inputs
cyfung1031 Sep 18, 2026
276196c
🔒 inspect collection payload entries before cloning
cyfung1031 Sep 18, 2026
c783a7a
🔒 validate collection RPC payloads without accessors
cyfung1031 Sep 18, 2026
671c445
🔒 remove live document references from CAT fetch
cyfung1031 Sep 18, 2026
284b968
🔒 bind early-start wrappers to document URLs
cyfung1031 Sep 18, 2026
3693b58
🔒 reject page RPC replay window overflow
cyfung1031 Sep 18, 2026
9cf16de
🔒 close service worker page RPC replay window
cyfung1031 Sep 18, 2026
9dd323e
🔒 validate page RPC identity before replay state
cyfung1031 Sep 18, 2026
2acb238
🔒 harden GM value result records
cyfung1031 Sep 18, 2026
39b695d
🔒 restrict script attachment reads
cyfung1031 Sep 18, 2026
c46dfe1
🔒 keep MAIN script payload off page bridge
cyfung1031 Sep 18, 2026
deea129
🔒 isolate service worker value records
cyfung1031 Sep 18, 2026
71022c7
🔒 serialize CDP monitor transitions
cyfung1031 Sep 18, 2026
8528137
🔒 allow long-lived page RPC bindings
cyfung1031 Sep 18, 2026
96c577c
🔒 preserve early wrappers across same-document navigation
cyfung1031 Sep 18, 2026
6ee8ba4
🔒 require a native MAIN page-load handshake
cyfung1031 Sep 18, 2026
babadbc
✅ bound E2E waits and replay invariant tests
cyfung1031 Sep 18, 2026
0541cdb
🔧 restore PR E2E timeout settings
cyfung1031 Sep 18, 2026
3217aca
🔒 route MAIN bootstrap over extension transport
cyfung1031 Sep 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ Each context is a separate bundle (see [Build pipeline & manifest](./references/
| Context | Entry | Realm / capabilities | Bootstraps |
|---|---|---|---|
| **Service Worker** | [`src/service_worker.ts`](../src/service_worker.ts) | No DOM. Owns `chrome.*` privileged APIs, storage, permissions, routing. | `ExtensionMessage(true)` → `Server("serviceWorker")` + `MessageQueue` → `ServiceWorkerManager` |
| **Content** | [`src/content.ts`](../src/content.ts) | Isolated content-script world. Bridges SW and the page. | `CustomEventMessage` channel to inject + `Server("content")` → `ScriptRuntime` |
| **Content** | [`src/content.ts`](../src/content.ts) | `USER_SCRIPT` world. Uses a native extension channel for bootstrap, GM RPC, value updates, and callbacks; dedicated USER_SCRIPT listeners are preferred, with a token-bound regular port fallback when they cannot be registered. Retains a narrow DOM channel for synchronous node helpers. | `ExtensionMessage` + native callback port → `Server("content")` → `ScriptRuntime`; `CustomEventMessage` only for DOM handles |
| **Inject** | [`src/inject.ts`](../src/inject.ts) | Page (`MAIN`) world. Has `unsafeWindow`; runs page userscripts. | `CustomEventMessage` to content + `Server("inject")` |
| **Offscreen** | [`src/offscreen.ts`](../src/offscreen.ts) | DOM-capable background page (Blobs, clipboard, DOM scraping, local storage). | `ExtensionMessage()` + `WindowMessage(window, sandbox)` → `OffscreenManager` |
| **Sandbox** | [`src/sandbox.ts`](../src/sandbox.ts) | `sandbox`ed iframe inside offscreen. Evaluates background/scheduled scripts; runs cron. | `WindowMessage(window, parent)` + `Server("sandbox")` → `SandboxManager` |
Expand Down Expand Up @@ -167,7 +167,7 @@ communication styles** over **several transports**.

| Class | File | Connects | Underlying API |
|---|---|---|---|
| `ExtensionMessage` | [`extension_message.ts`](../packages/message/extension_message.ts) | SW ↔ Content / Inject / Offscreen | `chrome.runtime.sendMessage` / `onConnect` (+ `onUserScript*` on Firefox) |
| `ExtensionMessage` | [`extension_message.ts`](../packages/message/extension_message.ts) | SW ↔ Content / Inject / Offscreen | `chrome.runtime.sendMessage` / `onConnect` (+ `onUserScript*` on Firefox; token-bound regular-port fallback when dedicated listeners are unavailable) |
| `CustomEventMessage` | [`custom_event_message.ts`](../packages/message/custom_event_message.ts) | Content ↔ Inject | DOM `CustomEvent` dispatch (bypasses page tampering) |
| `WindowMessage` | [`window_message.ts`](../packages/message/window_message.ts) | Offscreen ↔ Sandbox | `window.postMessage` |
| `ServiceWorkerMessageSend` | [`window_message.ts`](../packages/message/window_message.ts) | SW → Offscreen (Chrome) | `clients.matchAll()` + `postMessage` |
Expand Down
12 changes: 7 additions & 5 deletions docs/references/architecture-execution.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,12 @@ go through a controlled context object instead of the page's real globals:
Key points:

- `with(arguments[0]||this.$)` makes every bare identifier resolve against the GM context first. The context is
a `Proxy` that intercepts reads, so the script sees `unsafeWindow`, the granted `GM_*` functions, and a
controlled view of globals — not the raw page scope.
a descriptor-based pseudo-window that projects `unsafeWindow`, the granted `GM_*` functions, and a controlled
view of globals — not the raw page scope. It is a compatibility projection rather than a security membrane.
- Context and script name are passed as **unnamed `arguments`** (`arguments[0]`, `arguments[1]`) so user code
can't shadow them by declaring variables of the same name.
- `.call(this)` preserves `this` because `chrome.userScripts` invokes the function free-standing (an arrow
function would capture the wrong `this`).
- The wrapper installs the body as a temporary method and removes it in the same expression. This preserves the
userscript `this` without resolving mutable page `call`, `apply`, or `bind` properties.

### Path A — Page scripts → `chrome.userScripts`

Expand All @@ -38,7 +38,9 @@ patterns and registers the compiled payload (the `scripting` bundle) with `chrom
`MAIN` or `USER_SCRIPT` world as required. At document time the content/inject pair
([`script_runtime.ts`](../../src/app/service/content/script_runtime.ts),
[`exec_script.ts`](../../src/app/service/content/exec_script.ts)) evaluates the compiled function with the GM
context.
context. The `USER_SCRIPT` content path obtains its matched scripts directly from the service worker over
`ExtensionMessage`; the isolated `scripting` bundle keeps the page-observable event bridge for `MAIN` execution and
the synchronous DOM helper only.

### Path B — Background scripts → Offscreen → Sandbox

Expand Down
7 changes: 5 additions & 2 deletions docs/references/architecture-gm-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@ across contexts to a privileged handler, then streams the result back. The imple

- **Content side** ([`src/app/service/content/gm_api/`](../../src/app/service/content/gm_api)) — what runs *near*
the userscript. Synchronous-feeling APIs (`GM_getValue`, `GM_log`) and the client half of async ones
(`GM_xmlhttpRequest`, `GM_setValue`). Built on `GM_Base`, which owns the messaging plumbing.
(`GM_xmlhttpRequest`, `GM_setValue`). Built on `GM_Base`, which owns the request facade. `USER_SCRIPT` calls use
the native extension channel; the DOM helper remains a narrow synchronous `CustomEventMessage` path.
- **Service-worker side** ([`src/app/service/service_worker/gm_api/`](../../src/app/service/service_worker/gm_api))
— the privileged half: permission verification, cross-origin requests, DNR rule building.
- **Offscreen side** ([`src/app/service/offscreen/gm_api.ts`](../../src/app/service/offscreen/gm_api.ts)) —
DOM-dependent operations for background scripts (page-context XHR, `window.open`, clipboard).
- **Values** flow through `ValueService` and are broadcast so every tab running the same script sees updates.
- **Values** flow through `ValueService`. MAIN updates use the scripting broadcast, while USER_SCRIPT updates are
delivered over the native per-document callback port so privileged packets do not cross the page-observable DOM
channel.

### Registration: the `@GMContext.API` decorator

Expand Down
11 changes: 8 additions & 3 deletions packages/message/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,14 @@ export const CustomEventClone = CustomEvent;
const performanceClone = (process.env.VI_TESTING === "true" ? new EventTarget() : performance) as Performance;

// 避免页面载入后改动 EventTarget.prototype 的方法导致消息传递失败
export const pageDispatchEvent = performanceClone.dispatchEvent.bind(performanceClone);
export const pageAddEventListener = performanceClone.addEventListener.bind(performanceClone);
export const pageRemoveEventListener = performanceClone.removeEventListener.bind(performanceClone);
const nativeReflectApply = Reflect.apply;
const nativeFunctionBind = Function.prototype.bind;
const bindNative = <T extends (...args: any[]) => any>(fn: T, receiver: any): T =>
nativeReflectApply(nativeFunctionBind, fn, [receiver]) as T;

export const pageDispatchEvent = bindNative(performanceClone.dispatchEvent, performanceClone);
export const pageAddEventListener = bindNative(performanceClone.addEventListener, performanceClone);
export const pageRemoveEventListener = bindNative(performanceClone.removeEventListener, performanceClone);
const detailClone = typeof cloneInto === "function" ? cloneInto : null;
export const pageDispatchCustomEvent = <T = any>(eventType: string, detail: T) => {
if (detailClone && detail) detail = <T>detailClone(detail, performanceClone);
Expand Down
26 changes: 25 additions & 1 deletion packages/message/custom_event_message.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { CustomEventMessage } from "./custom_event_message";
import { createMouseEvent, pageDispatchEvent } from "@Packages/message/common";

Expand All @@ -17,6 +17,30 @@ function createMessagePair() {
}

describe("CustomEventMessage relatedTarget lifecycle", () => {
it("ignores accessor envelopes without executing their getters", () => {
const receiver = new CustomEventMessage(`custom-event-message-test-${++flagCounter}`, true, "");
const received = vi.fn();
receiver.onMessage(received);
const envelope: Record<string, unknown> = {
messageId: "hostile",
type: "sendMessage",
data: { action: "custom-event-message-test/hostile" },
};
let accessed = false;
Object.defineProperty(envelope, "data", {
configurable: true,
enumerable: true,
get() {
accessed = true;
throw new Error("page getter executed");
},
});

expect(() => receiver.messageHandle(envelope as any, { postMessage: vi.fn() })).not.toThrow();
expect(accessed).toBe(false);
expect(received).not.toHaveBeenCalled();
});

it("stores a received target on the receiving message until it is consumed", () => {
const { sender, receiver } = createMessagePair();
const target = document.createElement("div");
Expand Down
10 changes: 9 additions & 1 deletion packages/message/custom_event_message.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import type { Message, MessageConnect, RuntimeMessageSender, TMessage } from "./types";
import { uuidv4 } from "@App/pkg/utils/uuid";
import { type PostMessage, type WindowMessageBody, WindowMessageConnect } from "./window_message";
import {
parseWindowMessageBody,
type PostMessage,
type WindowMessageBody,
WindowMessageConnect,
} from "./window_message";
import EventEmitter from "eventemitter3";
import { DefinedFlags } from "@App/app/service/service_worker/runtime.consts";
import {
Expand Down Expand Up @@ -78,6 +83,9 @@ export class CustomEventMessage implements Message {
}

messageHandle(data: WindowMessageBody, target: PostMessage) {
const safeData = parseWindowMessageBody(data);
if (!safeData) return;
data = safeData;
// 处理消息
if (data.type === "sendMessage") {
// 接收到消息
Expand Down
123 changes: 123 additions & 0 deletions packages/message/extension_message.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { describe, expect, it, vi } from "vitest";
import { ExtensionContentMessageSend, ExtensionMessage, ExtensionMessageConnect } from "./extension_message";

describe("ExtensionMessage USER_SCRIPT compatibility", () => {
it("reports a failed dedicated listener registration so USER_SCRIPT can use the regular-port fallback", () => {
const runtime = chrome.runtime as unknown as {
onUserScriptConnect?: { addListener: (callback: (...args: any[]) => void) => void };
onUserScriptMessage?: { addListener: (callback: (...args: any[]) => void) => void };
messageListener?: Array<(message: any, sender: any, sendResponse: (response: any) => void) => void>;
connectListener?: Array<(port: chrome.runtime.Port) => void>;
};
const originalConnect = runtime.onUserScriptConnect;
const originalMessage = runtime.onUserScriptMessage;
const initialMessageListenerCount = runtime.messageListener?.length ?? 0;
const initialConnectListenerCount = runtime.connectListener?.length ?? 0;
try {
runtime.onUserScriptConnect = {
addListener: () => {
throw new Error("userScripts permission unavailable");
},
};
runtime.onUserScriptMessage = {
addListener: () => {
throw new Error("userScripts permission unavailable");
},
};
const message = new ExtensionMessage(true);
message.onConnect(() => undefined);
message.onMessage(() => undefined);

const response = vi.fn();
const listeners = runtime.messageListener ?? [];
listeners.at(-1)?.({ type: "userScripts.LISTEN_CONNECTIONS" }, {}, response);

expect(response).toHaveBeenCalledWith(false);
} finally {
if (runtime.messageListener) runtime.messageListener.length = initialMessageListenerCount;
if (runtime.connectListener) runtime.connectListener.length = initialConnectListenerCount;
runtime.onUserScriptConnect = originalConnect;
runtime.onUserScriptMessage = originalMessage;
}
});

it("does not require unavailable runtime event listeners", () => {
const runtime = chrome.runtime as unknown as {
onConnect?: typeof chrome.runtime.onConnect;
onMessage?: typeof chrome.runtime.onMessage;
};
const onConnect = runtime.onConnect;
const onMessage = runtime.onMessage;

try {
runtime.onConnect = undefined;
runtime.onMessage = undefined;
const message = new ExtensionMessage();

expect(() => message.onConnect(() => undefined)).not.toThrow();
expect(() => message.onMessage(() => undefined)).not.toThrow();
} finally {
runtime.onConnect = onConnect;
runtime.onMessage = onMessage;
}
});

it("keeps native sendMessage and connect bindings after runtime mutation", async () => {
const runtime = chrome.runtime as unknown as {
sendMessage: unknown;
connect: unknown;
};
const sendMessage = runtime.sendMessage;
const connect = runtime.connect;
const message = new ExtensionMessage();

try {
runtime.sendMessage = () => {
throw new Error("patched sendMessage");
};
runtime.connect = () => {
throw new Error("patched connect");
};

await expect(message.sendMessage({ action: "test" })).resolves.toMatchObject({ success: true });
await expect(message.connect({ action: "test" })).resolves.toBeDefined();
} finally {
runtime.sendMessage = sendMessage;
runtime.connect = connect;
}
});

it("keeps a native port postMessage binding after port mutation", () => {
const nativePostMessage = vi.fn();
const port = {
postMessage: nativePostMessage,
onMessage: { addListener: vi.fn(), removeListener: vi.fn() },
onDisconnect: { addListener: vi.fn(), removeListener: vi.fn() },
disconnect: vi.fn(),
} as unknown as chrome.runtime.Port;
const connection = new ExtensionMessageConnect(port);

port.postMessage = vi.fn();
connection.sendMessage({ action: "native" });

expect(nativePostMessage).toHaveBeenCalledWith({ action: "native" });
connection.disconnect(true);
});

it("preserves an explicit main-frame target when frameId is zero", async () => {
const sendMessage = vi
.spyOn(chrome.tabs, "sendMessage")
.mockImplementation((_tabId, _message, optionsOrCallback, callback) => {
const responseCallback = typeof optionsOrCallback === "function" ? optionsOrCallback : callback;
responseCallback?.({ success: true });
return Promise.resolve({ success: true });
});
try {
await new ExtensionContentMessageSend(7, { frameId: 0 }).sendMessage({ action: "private" });

expect(sendMessage).toHaveBeenCalledWith(7, { action: "private" }, { frameId: 0 }, expect.any(Function));
} finally {
sendMessage.mockRestore();
}
});
});
Loading
Loading