diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 3b0577d66..f7785c480 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -5,6 +5,7 @@ on: branches: - main - release/* + - feature/* - dev paths-ignore: - ".github/**" diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index e224d9d78..3537dad73 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -5,6 +5,7 @@ on: branches: - main - release/* + - feature/* - dev - develop/* pull_request: diff --git a/.gitignore b/.gitignore index 143763ebb..aabce4a3c 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ test-results playwright-report superpowers +.omc diff --git a/.prettierignore b/.prettierignore index 4a499ff0a..b6f0a56a2 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,3 +5,7 @@ yarn.lock # Claude Code .claude + +# Docs & examples +*.md +example/ diff --git a/packages/message/server.test.ts b/packages/message/server.test.ts index f59b00abb..b58037491 100644 --- a/packages/message/server.test.ts +++ b/packages/message/server.test.ts @@ -540,6 +540,32 @@ describe("Server", () => { const extSender = capturedSender!.getExtMessageSender(); expect(extSender.tabId).toBe(-1); }); + + it.concurrent("sender 为 null/undefined 时不崩溃并返回默认值", async () => { + // postMessage 通道(如 Offscreen→SW)传入空对象作为 sender, + // SenderRuntime.getExtMessageSender() 应该返回默认兜底值 + const senderNull = new SenderRuntime(null as unknown as RuntimeMessageSender); + const extNull = senderNull.getExtMessageSender(); + expect(extNull.windowId).toBe(-1); + expect(extNull.tabId).toBe(-1); + expect(extNull.frameId).toBeUndefined(); + expect(extNull.documentId).toBeUndefined(); + + const senderUndefined = new SenderRuntime(undefined as unknown as RuntimeMessageSender); + const extUndefined = senderUndefined.getExtMessageSender(); + expect(extUndefined.windowId).toBe(-1); + expect(extUndefined.tabId).toBe(-1); + expect(extUndefined.frameId).toBeUndefined(); + expect(extUndefined.documentId).toBeUndefined(); + + // 空对象(ServiceWorkerMessageSend 实际传入的值)也应正常处理 + const senderEmpty = new SenderRuntime({} as RuntimeMessageSender); + const extEmpty = senderEmpty.getExtMessageSender(); + expect(extEmpty.windowId).toBe(-1); + expect(extEmpty.tabId).toBe(-1); + expect(extEmpty.frameId).toBeUndefined(); + expect(extEmpty.documentId).toBeUndefined(); + }); }); describe("Connect 功能测试", () => { diff --git a/packages/message/server.ts b/packages/message/server.ts index 75e98081a..3df109f80 100644 --- a/packages/message/server.ts +++ b/packages/message/server.ts @@ -87,6 +87,15 @@ export class SenderRuntime { getExtMessageSender(): ExtMessageSender { const sender = this.sender as RuntimeMessageSender; + if (!sender) { + // postMessage 通道(如 Offscreen→SW)没有 RuntimeMessageSender + return { + windowId: -1, + tabId: -1, + frameId: undefined, + documentId: undefined, + }; + } return { windowId: sender.tab?.windowId || -1, // -1表示后台脚本 tabId: sender.tab?.id || -1, // -1表示后台脚本 diff --git a/packages/message/window_message.test.ts b/packages/message/window_message.test.ts index fcfed77f5..358e7f33f 100644 --- a/packages/message/window_message.test.ts +++ b/packages/message/window_message.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; -import { ServiceWorkerMessageSend, ServiceWorkerClientMessage, type WindowMessageBody } from "./window_message"; +import { + ServiceWorkerMessageSend, + ServiceWorkerClientMessage, + WindowMessage, + type WindowMessageBody, +} from "./window_message"; import { Server } from "./server"; import type { MessageConnect } from "./types"; @@ -160,6 +165,38 @@ describe("ServiceWorkerClientMessage", () => { }); }); +describe("WindowMessage.connect", () => { + it("connect 返回的连接 sendMessage 应带 '*' targetOrigin", async () => { + // 模拟 target window,验证 postMessage 被调用时带 "*" + const targetPostMessage = vi.fn(); + const sourceWindow = { + addEventListener: vi.fn(), + } as unknown as Window; + const targetWindow = { + postMessage: targetPostMessage, + } as unknown as Window; + + const wm = new WindowMessage(sourceWindow, targetWindow); + + const con = await wm.connect({ action: "test/connect", data: "init" }); + + // connect() 本身会调用一次 postMessage(发送 connect 消息) + expect(targetPostMessage).toHaveBeenCalledTimes(1); + expect(targetPostMessage).toHaveBeenCalledWith(expect.objectContaining({ type: "connect" }), "*"); + + targetPostMessage.mockClear(); + + // 通过返回的连接发送消息,也应该带 "*" + con.sendMessage({ action: "test/msg", data: "hello" }); + + expect(targetPostMessage).toHaveBeenCalledTimes(1); + expect(targetPostMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "connectMessage", data: { action: "test/msg", data: "hello" } }), + "*" + ); + }); +}); + describe("ServiceWorkerMessageSend ↔ ServiceWorkerClientMessage 双向通信", () => { // 辅助函数: 将两端连接起来,模拟 postMessage 通道 function createWiredPair() { diff --git a/packages/message/window_message.ts b/packages/message/window_message.ts index 44e89e863..c40f0d94f 100644 --- a/packages/message/window_message.ts +++ b/packages/message/window_message.ts @@ -100,7 +100,9 @@ export class WindowMessage implements Message { data, }; this.target.postMessage(body, "*"); - resolve(new WindowMessageConnect(body.messageId, this.EE, this.target)); + // 使用 WindowPostMessage 包装,确保后续 sendMessage 也带 "*" targetOrigin + // 否则沙箱(origin: null)→ offscreen(origin: chrome-extension://)的消息会被丢弃 + resolve(new WindowMessageConnect(body.messageId, this.EE, new WindowPostMessage(this.target))); }); } diff --git a/src/app/service/content/gm_api/navigation_handle.test.ts b/src/app/service/content/gm_api/navigation_handle.test.ts index c00740d47..29d7dfbdd 100644 --- a/src/app/service/content/gm_api/navigation_handle.test.ts +++ b/src/app/service/content/gm_api/navigation_handle.test.ts @@ -1,16 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { UrlChangeEvent } from "./navigation_handle.js"; - -// attachNavigateHandler 使用模块级 attached 单例,需要在每个测试前重置模块 -const importFresh = async () => { - vi.resetModules(); - // vi.resetModules() 会清空模块缓存,后续 import 得到全新的 LoggerCore 类, - // 需要重新初始化以免后续测试文件中 LoggerCore.getInstance() 返回 undefined - // @ts-expect-error 动态 import 路径别名在 tsc nodenext 下无法解析 - const { default: LC, EmptyWriter: EW } = await import("@App/app/logger/core"); - new LC({ level: "trace", consoleLevel: "trace", writer: new EW(), labels: { env: "test" } }); - return await import("./navigation_handle.js"); -}; +import { UrlChangeEvent, attachNavigateHandler, resetAttachedForTest } from "./navigation_handle"; describe("UrlChangeEvent", () => { it.concurrent("应包含 url 属性", () => { @@ -64,10 +53,10 @@ describe("attachNavigateHandler", () => { beforeEach(() => { vi.restoreAllMocks(); + resetAttachedForTest(); }); - it("不支持 Navigation API 时不应注册监听器", async () => { - const { attachNavigateHandler } = await importFresh(); + it("不支持 Navigation API 时不应注册监听器", () => { const win = { location: { href: "https://example.com/" } } as any; attachNavigateHandler(win); // 没有 navigation 属性,不应报错也不应标记为 attached @@ -77,16 +66,14 @@ describe("attachNavigateHandler", () => { expect(mock.win.navigation.addEventListener).toHaveBeenCalledWith("navigate", expect.any(Function), false); }); - it("应在 win.navigation 上注册 navigate 监听器", async () => { - const { attachNavigateHandler } = await importFresh(); + it("应在 win.navigation 上注册 navigate 监听器", () => { const mock = createMockWin(); attachNavigateHandler(mock.win); expect(mock.win.navigation.addEventListener).toHaveBeenCalledTimes(1); expect(mock.win.navigation.addEventListener).toHaveBeenCalledWith("navigate", expect.any(Function), false); }); - it("多次调用只注册一次", async () => { - const { attachNavigateHandler } = await importFresh(); + it("多次调用只注册一次", () => { const mock = createMockWin(); attachNavigateHandler(mock.win); attachNavigateHandler(mock.win); @@ -95,7 +82,6 @@ describe("attachNavigateHandler", () => { }); it("URL 变化时应派发 urlchange 事件", async () => { - const { attachNavigateHandler } = await importFresh(); const mock = createMockWin("https://example.com/"); attachNavigateHandler(mock.win); mock.fireNavigate("https://example.com/new"); @@ -109,7 +95,6 @@ describe("attachNavigateHandler", () => { }); it("URL 未变化时不应派发事件", async () => { - const { attachNavigateHandler } = await importFresh(); const mock = createMockWin("https://example.com/"); attachNavigateHandler(mock.win); // destination.url 与当前 href 相同 diff --git a/src/app/service/content/gm_api/navigation_handle.ts b/src/app/service/content/gm_api/navigation_handle.ts index 6cdcf8417..a536f6a70 100644 --- a/src/app/service/content/gm_api/navigation_handle.ts +++ b/src/app/service/content/gm_api/navigation_handle.ts @@ -10,6 +10,11 @@ export class UrlChangeEvent extends Event { let attached = false; +// 仅供测试使用,重置 attached 标记 +export const resetAttachedForTest = () => { + attached = false; +}; + const getPropGetter = (obj: T, key: keyof T) => { // 避免直接 obj[key] 读取。或会被 hack for (let t = obj; t; t = Native.objectGetPrototypeOf(t)) { diff --git a/src/index.css b/src/index.css index 6eb4944b4..6c177117a 100644 --- a/src/index.css +++ b/src/index.css @@ -9,6 +9,7 @@ body { } body[arco-theme='dark'] { + --un-default-border-color: var(--color-border-2); --color-scrollbar-thumb: #6b6b6b; --color-scrollbar-track: #2d2d2d; --color-scrollbar-thumb-hover: #8c8c8c; diff --git a/src/pages/confirm/App.tsx b/src/pages/confirm/App.tsx index b440a33ad..7ad154ccf 100644 --- a/src/pages/confirm/App.tsx +++ b/src/pages/confirm/App.tsx @@ -4,31 +4,40 @@ import React, { useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { permissionClient } from "../store/features/script"; -function App() { - const uuid = new URLSearchParams(location.search).get("uuid"); +// 权限确认组件 +function PermissionConfirmRequest({ uuid }: { uuid: string }) { const [confirm, setConfirm] = React.useState(); const [likeNum, setLikeNum] = React.useState(0); const [second, setSecond] = React.useState(30); const { t } = useTranslation(); - if (second === 0) { - window.close(); - } - - setTimeout(() => { - setSecond(second - 1); - }, 1000); + useEffect(() => { + const timer = setInterval(() => { + setSecond((s) => { + if (s <= 1) { + clearInterval(timer); + window.close(); + return 0; + } + return s - 1; + }); + }, 1000); + return () => clearInterval(timer); + }, []); useEffect(() => { - if (!uuid) return; - window.addEventListener("beforeunload", () => { + const handler = () => { permissionClient.confirm(uuid, { allow: false, type: 0, }); - }); + }; + window.addEventListener("beforeunload", handler, false); + return () => window.removeEventListener("beforeunload", handler, false); + }, [uuid]); + useEffect(() => { permissionClient .getPermissionInfo(uuid) .then((data) => { @@ -39,11 +48,10 @@ function App() { .catch((e: any) => { Message.error(e.message || t("get_confirm_error")); }); - }, []); + }, [uuid, t]); const handleConfirm = (allow: boolean, type: number) => { return async () => { - if (!uuid) return; try { await permissionClient.confirm(uuid, { allow, @@ -143,4 +151,15 @@ function App() { ); } +function App() { + const params = new URLSearchParams(location.search); + const uuid = params.get("uuid"); + + if (uuid) { + return ; + } + + return null; +} + export default App; diff --git a/src/pkg/utils/utils.ts b/src/pkg/utils/utils.ts index 71a891c14..40c3ba22c 100644 --- a/src/pkg/utils/utils.ts +++ b/src/pkg/utils/utils.ts @@ -456,7 +456,8 @@ export function cleanFileName(name: string): string { } export const sourceMapTo = (scriptName: string) => { - const url = chrome.runtime.getURL(`/${encodeURI(scriptName)}`); + // sandbox 环境中 chrome.runtime 不可用,使用脚本名作为 sourceURL + const url = chrome.runtime?.getURL ? chrome.runtime.getURL(`/${encodeURI(scriptName)}`) : encodeURI(scriptName); return `\n//# sourceURL=${url}`; }; diff --git a/vitest.config.ts b/vitest.config.ts index 303e19862..73f07cf8e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -25,7 +25,7 @@ export default defineConfig({ ], test: { environment: "jsdom", - exclude: ["e2e/**", "node_modules/**", ".claude/**"], + exclude: ["**/node_modules/**", "**/.claude/**", "e2e/**"], // List setup file setupFiles: ["./tests/vitest.setup.ts"], env: {