From b4d7b3cb4b9d7fcaf881d0b370b5de14afb5b125 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 29 Sep 2026 16:09:02 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=90=9B=20=E9=A1=B5=E9=9D=A2=E4=B8=8D?= =?UTF-8?q?=E5=B1=9E=E4=BA=8E=E4=BB=BB=E4=BD=95=E6=A0=87=E7=AD=BE=E9=A1=B5?= =?UTF-8?q?=E6=97=B6=E4=B8=8D=E5=86=8D=E8=AE=B0=E5=85=A5=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/service_worker/runtime.test.ts | 55 +++++++++++++++++++ src/app/service/service_worker/runtime.ts | 18 +++--- 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index 667c6643f..5ccd3ee8f 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -1121,6 +1121,61 @@ describe("pageLoad 按消息发送方标签页区分隐身上下文", () => { }); }); +// 弹窗把 tabId -1 当作「后台脚本」命名空间;不属于任何标签页的页面若也记到 -1, +// 普通脚本就会出现在「开启和运行的后台脚本」里(#1774)。 +describe("pageLoad 页面运行计数只记到真实标签页", () => { + const pageUrl = "https://www.example.com/page"; + const scriptsForTab = { + injectScriptList: [], + contentScriptList: [], + envInfo: {}, + scriptmenus: [], + } as unknown as Awaited>; + const popupPageLoadEmits = (emit: ReturnType) => + emit.mock.calls.filter(([topic]) => topic === "popupPageLoadUpdate"); + + it.each([ + ["没有 tab", {}], + ["tab.id 为 TAB_ID_NONE(-1)", { tab: { id: -1 } }], + ])("%s 时照常下发脚本,但不记入任何标签页的运行计数", async (_label, senderTab) => { + const { runtime, mockGroup } = _createRuntimeContext(); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue(scriptsForTab); + + const res = await runtime.pageLoad( + undefined, + new SenderRuntime({ + id: "scriptcat-test", + url: pageUrl, + frameId: 0, + ...senderTab, + } as chrome.runtime.MessageSender) + ); + + expect(res.ok).toBe(true); + expect(popupPageLoadEmits(mockGroup.emit)).toEqual([]); + }); + + it("真实标签页照常记入该标签页", async () => { + const tabId = 11; + const { runtime, mockGroup } = _createRuntimeContext(); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue(scriptsForTab); + + await runtime.pageLoad( + undefined, + new SenderRuntime({ + id: "scriptcat-test", + url: pageUrl, + frameId: 0, + tab: { id: tabId, incognito: false }, + } as chrome.runtime.MessageSender) + ); + + expect(popupPageLoadEmits(mockGroup.emit)).toEqual([ + ["popupPageLoadUpdate", { tabId, frameId: 0, url: pageUrl, scriptmenus: [] }], + ]); + }); +}); + describe("sandbox verified 初始化重放", () => { it("忽略 fallback 通知,并且真实握手与重复握手只初始化一次脚本和语言监听", async () => { const { runtime, mockSystemConfig, mockScriptDAO } = _createRuntimeContext(); diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 957d80e1e..5dfa33d01 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -1279,17 +1279,21 @@ export class RuntimeService { // 异常加载 return { ok: false }; } - const tabId = chromeSender.tab?.id || -1; + const tabId = chromeSender.tab?.id; const frameId = chromeSender.frameId; const incognito = chromeSender.tab?.incognito ?? false; const res = await this.getScriptsForTab({ url, tabId, frameId, incognito }); - this.mq.emit("popupPageLoadUpdate", { - tabId: tabId, - frameId: frameId, - url: url, - scriptmenus: res?.scriptmenus || [], // 对于 popup, resources那些不需要 - }); + // 页面可能不属于任何标签页(sender.tab 缺失或为 TAB_ID_NONE),脚本照常下发,但不能记运行计数: + // 弹窗把 tabId -1 当作后台脚本的命名空间,记进去普通脚本就会混进后台脚本列表(#1774)。 + if (tabId !== undefined && tabId > 0) { + this.mq.emit("popupPageLoadUpdate", { + tabId: tabId, + frameId: frameId, + url: url, + scriptmenus: res?.scriptmenus || [], // 对于 popup, resources那些不需要 + }); + } if (res) { // 返回脚本资料,在页面加载 From e773c1b4665586df5f9e5a7ecb809870c4adbcff Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:31:00 +0900 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20addScriptRunNumber?= =?UTF-8?q?=20=E6=8B=92=E7=BB=9D=E9=9D=9E=E7=9C=9F=E5=AE=9E=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E9=A1=B5=EF=BC=8C=E5=B9=B6=E8=A1=A5=20tabScript:-1=20?= =?UTF-8?q?=E5=91=BD=E5=90=8D=E7=A9=BA=E9=97=B4=E5=A5=91=E7=BA=A6=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pageLoad 已在生产者一侧过滤无 tab 的页面加载,这里在真正写 tabScript: 的 sink 再挡一层:tabId <= 0 直接返回。旧实现在 frameId 为 0 时会先清空列表, 一旦有别的发送路径带来 -1,后台脚本会被整批挤掉。 新增用例: - tabId 为 -1 / 0 时 addScriptRunNumber 不改动 tabScript:-1(撤掉守卫后失败)。 - 契约测试:无标签页的页面加载/iframe、菜单注册注销、普通脚本启停与运行状态事件之后, tabScript:-1 仍只含后台脚本。 Refs #1774 Co-Authored-By: Claude Sonnet 5.5 --- src/app/service/service_worker/popup.test.ts | 88 ++++++++++++++++++++ src/app/service/service_worker/popup.ts | 3 + 2 files changed, 91 insertions(+) diff --git a/src/app/service/service_worker/popup.test.ts b/src/app/service/service_worker/popup.test.ts index fdf4f5750..e1acb8ebd 100644 --- a/src/app/service/service_worker/popup.test.ts +++ b/src/app/service/service_worker/popup.test.ts @@ -273,6 +273,94 @@ describe("PopupService addScriptRunNumber 页面脚本执行计数", () => { expect(mA?.runNum).toBe(2); expect(mB?.runNum).toBe(4); }); + + // tabScript:-1 是后台脚本的命名空间。旧实现在 frameId 为 0 时会先清空列表,所以不止「多出一个普通脚本」, + // 还会把真正的后台脚本整批挤掉(#1774)。 + it.each([-1, 0])("tabId 为 %i(非真实标签页)时不得改动 tabScript:-1 缓存", async (tabId) => { + const backgroundMenu = createMenu("bg-script", { runNum: 1, runStatus: SCRIPT_RUN_STATUS_RUNNING }); + await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${-1}`, [backgroundMenu]); + const { service } = createService(); + + for (const frameId of [0, undefined, 3]) { + await service.addScriptRunNumber({ + tabId, + frameId, + url: "https://example.com/", + scriptmenus: [createMenu("normal-script", { runNum: 0 })], + }); + } + + await expect(service.getScriptMenu(-1)).resolves.toEqual([backgroundMenu]); + await expect(service.getScriptMenu(tabId)).resolves.toEqual(tabId === -1 ? [backgroundMenu] : []); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── + +describe("PopupService tabScript:-1 命名空间契约:页面事件不得触碰后台脚本列表", () => { + const backgroundUuid = "bg-script"; + const normalUuid = "normal-script"; + const backgroundScript = createScript(backgroundUuid, { type: SCRIPT_TYPE_BACKGROUND }); + const normalScript = createScript(normalUuid, { type: SCRIPT_TYPE_NORMAL }); + const byUuid = (uuid: string) => [backgroundScript, normalScript].find((script) => script.uuid === uuid); + + beforeEach(async () => { + await cacheInstance.clear(); + }); + + it("无标签页的页面加载、iframe、菜单注册/注销、启停与运行状态事件之后,tabScript:-1 仍只含后台脚本", async () => { + const { service, subscriptions } = createService({ + scriptDAO: { + get: vi.fn(async (uuid: string) => byUuid(uuid)), + gets: vi.fn(async (uuids: string[]) => uuids.map(byUuid)), + }, + }); + service.dealBackgroundScriptInstall(); + const [installHandler] = subscriptions.get("installScript") || []; + await installHandler!({ + script: backgroundScript, + update: false, + } as TInstallScript); + const expected = await service.getScriptMenu(-1); + expect(expected.map((menu) => menu.uuid)).toEqual([backgroundUuid]); + + const expectNamespaceUntouched = async () => { + await flushAsync(); + await expect(service.getScriptMenu(-1)).resolves.toEqual(expected); + }; + + // 无标签页的主 frame / iframe / tabId 0 页面加载 + for (const tabId of [-1, 0]) { + for (const frameId of [0, 2]) { + await service.addScriptRunNumber({ + tabId, + frameId, + url: "https://example.com/", + scriptmenus: [createMenu(normalUuid, { runNum: 0 })], + }); + await expectNamespaceUntouched(); + } + } + + // 普通脚本在无标签页页面里的 GM_registerMenuCommand / GM_unregisterMenuCommand(tabId 会落成 -1) + const menuMessage = { uuid: normalUuid, key: "menu", name: "Menu", options: {}, tabId: -1 }; + await (service as any).updateRegisterMenuCommand(menuMessage, 1 /* REGISTER */); + await expectNamespaceUntouched(); + await (service as any).updateRegisterMenuCommand(menuMessage, 2 /* UNREGISTER */); + await expectNamespaceUntouched(); + + // 普通脚本的启用 / 禁用 / 运行状态事件 + const [enableHandler] = subscriptions.get("enableScripts") || []; + await enableHandler!([{ uuid: normalUuid, enable: true }] satisfies TEnableScript[]); + await expectNamespaceUntouched(); + await enableHandler!([{ uuid: normalUuid, enable: false }] satisfies TEnableScript[]); + await expectNamespaceUntouched(); + const [runStatusHandler] = subscriptions.get("scriptRunStatus") || []; + runStatusHandler!({ uuid: normalUuid, runStatus: SCRIPT_RUN_STATUS_RUNNING } satisfies TScriptRunStatus); + await expectNamespaceUntouched(); + runStatusHandler!({ uuid: normalUuid, runStatus: SCRIPT_RUN_STATUS_COMPLETE } satisfies TScriptRunStatus); + await expectNamespaceUntouched(); + }); }); // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/app/service/service_worker/popup.ts b/src/app/service/service_worker/popup.ts index f921137e3..9f0f2b462 100644 --- a/src/app/service/service_worker/popup.ts +++ b/src/app/service/service_worker/popup.ts @@ -618,6 +618,9 @@ export class PopupService { async addScriptRunNumber(o: TPopupPageLoadInfo) { const { tabId, frameId, scriptmenus } = o; + // tabScript:-1 是后台脚本的命名空间,只允许后台脚本的 install/enable/runStatus 事件写入。 + // 页面执行计数若落到 tabId <= 0,frameId 为 0 时还会先清空整个列表(#1774);生产者(RuntimeService.pageLoad)已过滤,这里是第二道防线。 + if (tabId <= 0) return; // 设置数据 await cacheInstance.tx(`${CACHE_KEY_TAB_SCRIPT}${tabId}`, (data: ScriptMenu[] | undefined, tx) => { const isPrevDataEmpty = !data?.length; From e5d41de92c319e20f160b9f815df920ca5e532ce Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:40:10 +0900 Subject: [PATCH 3/3] =?UTF-8?q?=E2=9C=85=20=E4=BF=AE=E6=AD=A3=E5=91=BD?= =?UTF-8?q?=E5=90=8D=E7=A9=BA=E9=97=B4=E5=A5=91=E7=BA=A6=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E7=9A=84=E5=BC=95=E7=94=A8=E5=88=AB=E5=90=8D=E5=81=87=E9=98=B3?= =?UTF-8?q?=E6=80=A7=EF=BC=8C=E5=B9=B6=E8=AE=A9=20addScriptRunNumber=20?= =?UTF-8?q?=E6=8B=92=E7=BB=9D=20undefined/NaN=20tabId?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 契约测试:session 缓存读取不拷贝,expected 直接持有缓存引用时,被测路径原地修改 (如 menu.push、runNum 赋值)会连 expected 一起改掉,toEqual 恒真。改用 structuredClone 快照;在 scriptRunStatus 订阅里临时注入原地改 runNum,旧断言通过、 新断言失败,已验证后还原。 - addScriptRunNumber 守卫由 tabId <= 0 改为 !(tabId > 0),undefined / NaN 这类越过类型 的运行时数据同样拒绝,并补对应用例(守卫收紧前失败)。 Refs #1774 Co-Authored-By: Claude Sonnet 5.5 --- src/app/service/service_worker/popup.test.ts | 18 +++++++++++++++++- src/app/service/service_worker/popup.ts | 3 ++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/app/service/service_worker/popup.test.ts b/src/app/service/service_worker/popup.test.ts index e1acb8ebd..7cd26c15e 100644 --- a/src/app/service/service_worker/popup.test.ts +++ b/src/app/service/service_worker/popup.test.ts @@ -293,6 +293,21 @@ describe("PopupService addScriptRunNumber 页面脚本执行计数", () => { await expect(service.getScriptMenu(-1)).resolves.toEqual([backgroundMenu]); await expect(service.getScriptMenu(tabId)).resolves.toEqual(tabId === -1 ? [backgroundMenu] : []); }); + + // 消息/事件边界的运行时数据不一定符合类型,undefined / NaN 也不能落成 tabScript:undefined / tabScript:NaN。 + it.each([undefined, Number.NaN])("tabId 为 %s(越过类型的运行时数据)时不写入任何 tabScript 缓存", async (tabId) => { + const { service } = createService(); + + await service.addScriptRunNumber({ + tabId: tabId as never, + frameId: 0, + url: "https://example.com/", + scriptmenus: [createMenu("normal-script", { runNum: 0 })], + }); + + const keys = await cacheInstance.list(); + expect(keys.filter((key) => key.startsWith(CACHE_KEY_TAB_SCRIPT))).toEqual([]); + }); }); // ───────────────────────────────────────────────────────────────────────────── @@ -321,7 +336,8 @@ describe("PopupService tabScript:-1 命名空间契约:页面事件不得触 script: backgroundScript, update: false, } as TInstallScript); - const expected = await service.getScriptMenu(-1); + // session 缓存的读取不拷贝,直接持有引用的话原地修改会连 expected 一起改掉,断言恒真。 + const expected = structuredClone(await service.getScriptMenu(-1)); expect(expected.map((menu) => menu.uuid)).toEqual([backgroundUuid]); const expectNamespaceUntouched = async () => { diff --git a/src/app/service/service_worker/popup.ts b/src/app/service/service_worker/popup.ts index 9f0f2b462..33331444c 100644 --- a/src/app/service/service_worker/popup.ts +++ b/src/app/service/service_worker/popup.ts @@ -620,7 +620,8 @@ export class PopupService { const { tabId, frameId, scriptmenus } = o; // tabScript:-1 是后台脚本的命名空间,只允许后台脚本的 install/enable/runStatus 事件写入。 // 页面执行计数若落到 tabId <= 0,frameId 为 0 时还会先清空整个列表(#1774);生产者(RuntimeService.pageLoad)已过滤,这里是第二道防线。 - if (tabId <= 0) return; + // 写成 !(tabId > 0) 而非 tabId <= 0:undefined / NaN 这类越过类型的运行时数据同样要拒绝。 + if (!(tabId > 0)) return; // 设置数据 await cacheInstance.tx(`${CACHE_KEY_TAB_SCRIPT}${tabId}`, (data: ScriptMenu[] | undefined, tx) => { const isPrevDataEmpty = !data?.length;