Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
branches:
- main
- release/*
- feature/*
- dev
paths-ignore:
- ".github/**"
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
branches:
- main
- release/*
- feature/*
- dev
- develop/*
pull_request:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,4 @@ test-results
playwright-report

superpowers
.omc
4 changes: 4 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,7 @@ yarn.lock

# Claude Code
.claude

# Docs & examples
*.md
example/
26 changes: 26 additions & 0 deletions packages/message/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 功能测试", () => {
Expand Down
9 changes: 9 additions & 0 deletions packages/message/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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表示后台脚本
Expand Down
39 changes: 38 additions & 1 deletion packages/message/window_message.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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() {
Expand Down
4 changes: 3 additions & 1 deletion packages/message/window_message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
});
}

Expand Down
25 changes: 5 additions & 20 deletions src/app/service/content/gm_api/navigation_handle.test.ts
Original file line number Diff line number Diff line change
@@ -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 属性", () => {
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand All @@ -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");
Expand All @@ -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 相同
Expand Down
5 changes: 5 additions & 0 deletions src/app/service/content/gm_api/navigation_handle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ export class UrlChangeEvent extends Event {

let attached = false;

// 仅供测试使用,重置 attached 标记
export const resetAttachedForTest = () => {
attached = false;
};

const getPropGetter = <T>(obj: T, key: keyof T) => {
// 避免直接 obj[key] 读取。或会被 hack
for (let t = obj; t; t = Native.objectGetPrototypeOf(t)) {
Expand Down
1 change: 1 addition & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
47 changes: 33 additions & 14 deletions src/pages/confirm/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ConfirmParam>();
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) => {
Expand All @@ -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,
Expand Down Expand Up @@ -143,4 +151,15 @@ function App() {
);
}

function App() {
const params = new URLSearchParams(location.search);
const uuid = params.get("uuid");

if (uuid) {
return <PermissionConfirmRequest uuid={uuid} />;
}

return null;
}

export default App;
3 changes: 2 additions & 1 deletion src/pkg/utils/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
};

Expand Down
2 changes: 1 addition & 1 deletion vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Loading