Skip to content

[缺e2e测试] 当前站点排除后脚本从 Popup 消失,无法在 Popup 中取消排除  #1591

Description

@cyfung1031

预检查

  • 已搜索现有 Issue;本问题从 [Feature] Add "Unmatched Scripts" section to popup #1590 中拆分,避免将既有功能故障与“显示从未匹配的脚本”新功能混在一起。
  • 已检查当前 main 分支 Popup、运行时匹配器及相关测试。
  • 这不是安全漏洞。

问题区域

  • Script execution / injection / matching rules
  • Popup / Options page / UI

问题描述

在 Popup 中对一个当前页面已匹配的脚本执行“排除当前站点”后,脚本会从 Popup 的“当前页面脚本”列表消失。此后用户无法在 Popup 中执行“取消排除”,只能进入管理页找到脚本并手工修改规则。

这应当视为 Bug,而不是缺少“未匹配脚本”功能:当前 Popup 已经实现了双向的“排除 / 取消排除”操作,ScriptRow 会根据 script.isEffective 显示对应按钮;如果排除后脚本不再出现在列表中,现有的取消排除分支就永远不可达。

关联背景:#1590

最小复现脚本

// ==UserScript==
// @name         ScriptCat Popup Exclude Reproduction
// @namespace    scriptcat-popup-exclude-repro
// @version      0.1
// @match        https://example.com/*
// @grant        none
// ==/UserScript==

console.log("ScriptCat Popup Exclude Reproduction");

最小复现步骤

  1. 安装以上脚本并确认其处于启用状态。
  2. 打开 https://example.com/
  3. 打开 ScriptCat Popup,确认脚本显示在“当前页面脚本”中。
  4. 展开该脚本,点击“排除 example.com”(实际写入规则应为 *://example.com/*)。
  5. 关闭并重新打开 Popup;同时建议分别测试:
    • 不刷新页面,直接重新打开 Popup;
    • 刷新页面后重新打开 Popup;
    • 切换到其他标签页再切回来后重新打开 Popup。
  6. 观察脚本是否仍显示,并提供“取消排除 example.com”操作。

预期行为与实际行为

预期行为

  • 脚本仍保留在“当前页面脚本”列表中。
  • script.isEffective === false
  • Popup 显示现有的“取消排除 example.com”操作。
  • 点击后移除该站点排除规则,脚本恢复为 isEffective === true

实际行为

  • 排除后脚本从 Popup 列表消失。
  • 用户无法使用 Popup 中已经存在的“取消排除”操作。
  • 只能进入完整管理页手工撤销规则。

代码证据

以下分析基于当前 main 快照 48e96351e9bbbeefae4275d53aa5b076fc71f79b

1. Popup 已有双向操作,因此不是缺少 UI 功能

src/pages/popup/App.tsx 中,页面脚本只要 script.isEffective !== null,就会显示操作:

  • isEffective === true:显示排除;
  • isEffective === false:显示取消排除。

相关代码:

totalCount={data.fullScriptCount}
>
{data.scriptList.map((script) => (
<ScriptRow
key={script.uuid}
script={script}
host={data.host}
isPageScript
menuExpandNum={data.menuExpandNum}
onToggle={data.handleToggleScript}
onDelete={data.handleDeleteScript}
onOpenEditor={data.handleOpenEditor}

src/pages/popup/usePopupData.ts 的处理器也明确支持两个方向:

// isEffective=true → 排除(remove=false)
// isEffective=false → 取消排除(remove=true)
await scriptClient.excludeUrl(uuid, `*://${host}/*`, !isEffective);

并在成功后把当前行的 isEffective 取反:

if (!host) return;
try {
// isEffective=true → 排除(remove=false); isEffective=false → 取消排除(remove=true)
await scriptClient.excludeUrl(uuid, `*://${host}/*`, !isEffective);
setScriptList((prev) => prev.map((s) => (s.uuid === uuid ? { ...s, isEffective: !isEffective } : s)));
} catch (e) {
console.error("Failed to toggle exclude:", e);

因此,只要脚本仍在 scriptList 中,Popup 已经具备撤销能力。

2. Popup 列表由 URL 匹配结果驱动,而不是由所有脚本驱动

PopupService.getPopupData() 首先调用:

this.runtime.getPopupPageScriptMatchingResultByUrl(url)

然后只根据 matchingResult.keys() 查询 DAO 并构造 scriptList

const [matchingResult, runScripts, backScriptList] = await Promise.all([
this.runtime.getPopupPageScriptMatchingResultByUrl(url),
this.getScriptMenu(tabId),
this.getScriptMenu(-1),
]);
const uuids = [...matchingResult.keys()];
const scripts = await this.scriptDAO.gets(uuids);
// 与运行时脚本进行合并
// 以已运行脚本建立快取(uuid→ScriptMenu),供后续合并与覆盖状态。
const runMap = new Map<string, ScriptMenu>(runScripts.map((script) => [script.uuid, script]));
// 合并后结果
const scriptMenuMap = new Map<string, ScriptMenu>();
// 合并数据
for (let idx = 0, l = uuids.length; idx < l; idx++) {
const uuid = uuids[idx];
const script = scripts[idx];
const o = matchingResult.get(uuid);
if (!script || !o) continue;
let run = runMap.get(uuid);
if (run) {
// 如果脚本已经存在,则不添加,更新信息
run.enable = script.status === SCRIPT_STATUS_ENABLE;
run.isEffective = o.effective!;
run.hasUserConfig = !!script.config;
} else {
// 由于目前没有在 Popup 显示 @match @include @exclude, 所以以下代码暂不需要
// if (script.selfMetadata) {
// script.metadata = getCombinedMeta(script.metadata, script.selfMetadata);
// }
run = scriptToMenu(script);
run.isEffective = o.effective!;

所以,如果被排除脚本没有出现在该匹配结果中,它就不会进入 Popup;UI 中的“取消排除”分支也无法触达。

3. 当前 main 已设计 uuid{ORIGINAL} 影子规则来保留被排除脚本

运行时会同时维护:

  • 合并自定义规则后的有效匹配:uuid
  • 脚本原始正向匹配:uuid{ORIGINAL}

当 URL 只命中原始规则、但未命中合并后的规则时,应返回同一个脚本 UUID,并标记:

effective = false

实现位置:

return this.getPageScriptMatchingResultByUrlInternal(url, disabledMatcher, true);
}
private getPageScriptMatchingResultByUrlInternal(
url: string,
disabledMatcher: UrlMatch<string> | undefined,
includeNonEffective: boolean
) {
// 返回当前页面匹配的uuids
// 如果有使用自定义排除,原本脚本定义的会返回 uuid{Ori}
// 因此基于自定义排除页面被排除的情况下,结果只包含 uuid{Ori} 而不包含 uuid
const matchedUuids = disabledMatcher
? [...this.scriptMatchEnable.urlMatch(url!), ...disabledMatcher.urlMatch(url!)]
: this.scriptMatchEnable.urlMatch(url!);
const ret = new Map<string, { uuid: string; effective: boolean }>();
for (const e of matchedUuids) {
const uuid = e.endsWith(ORIGINAL_URLMATCH_SUFFIX) ? e.slice(0, -ORIGINAL_URLMATCH_SUFFIX.length) : e;
if (!includeNonEffective && uuid !== e) continue;
const o = ret.get(uuid) || { uuid, effective: false };
// 只包含 uuid{Ori} 而不包含 uuid 的情况,effective = false
if (e === uuid) {
o.effective = true;
}
ret.set(uuid, o);
}
// ret 只包含 uuid 为键的 matchingResult
return ret;
}
async updateSites() {
if (this.sitesLoaded.size === 0 || this.updateSitesBusy) return;
this.updateSitesBusy = true;
const list = [...this.sitesLoaded];
this.sitesLoaded.clear();
const currentSites = (await this.localStorageDAO.getValue<ScriptSite>("sites")) || ({} as ScriptSite);
const sets: Partial<Record<string, Set<string>>> = {};
for (const str of list) {
const [uuid, domain] = str.split("|");
const s = sets[uuid] || (sets[uuid] = new Set([] as string[]));
s.add(domain);
}
for (const uuid in sets) {
const s = new Set([...sets[uuid]!, ...(currentSites[uuid] || ([] as string[]))]);
const arr = (currentSites[uuid] = [...s]);
if (arr.length > 50) arr.length = 50;
}
await this.localStorageDAO.saveValue("sites", currentSites);
this.updateSitesBusy = false;
if (this.sitesLoaded.size > 0) {
Promise.resolve().then(() => this.updateSites());
}
}
async pageLoad(_: any, sender: IGetSender): Promise<TClientPageLoadInfo> {
const chromeSender = sender.getSender();
const url = chromeSender?.url;
if (!url) {
// 异常加载
return { ok: false };
}
const tabId = chromeSender.tab?.id || -1;
const frameId = chromeSender.frameId;
const res = await this.getScriptsForTab({ url, tabId, frameId });
this.mq.emit<TPopupPageLoadInfo>("popupPageLoadUpdate", {
tabId: tabId,
frameId: frameId,
scriptmenus: res?.scriptmenus || [], // 对于 popup, resources那些不需要
});

关键逻辑:

const uuid = e.endsWith(ORIGINAL_URLMATCH_SUFFIX)
  ? e.slice(0, -ORIGINAL_URLMATCH_SUFFIX.length)
  : e;

const o = ret.get(uuid) || { uuid, effective: false };
if (e === uuid) o.effective = true;
ret.set(uuid, o);

Popup 查询使用 includeNonEffective=true

return this.getPageScriptMatchingResultByUrlInternal(url, disabledMatcher, true);

因此从设计意图看,被自定义排除的脚本本应仍返回,只是 effective=false

4. 已有单元测试也明确规定此行为

runtime.test.ts 已测试自定义排除:

  • 默认执行查询不返回被排除脚本;
  • includeNonEffective=true 时必须返回该脚本;
  • 返回项的 effective 必须为 false

const script = createMockScript({
metadata: {
match: ["https://www.example.com/*"],
},
selfMetadata: {
exclude: ["https://www.example.com/*"],
},
});
const scriptRunResource = createScriptRunResource(script);
mockScriptService.buildScriptRunResource.mockReturnValue(scriptRunResource);
// Act
const scriptMatchInfo = await runtime.applyScriptMatchInfo(scriptRunResource);
expect(scriptMatchInfo).toBeDefined();
// 测试默认查询(不包含无效匹配)
const defaultResult = runtime.getPageScriptMatchingResultByUrl("https://www.example.com/path");
// 测试包含无效匹配的查询
const allResult = runtime.getPageScriptMatchingResultByUrl("https://www.example.com/path", true);
// Assert
// expect(mockScriptService.buildScriptRunResource).toHaveBeenCalledWith(script);
// 默认查询应该不包含被排除的脚本
expect(defaultResult.has(script.uuid)).toBe(false);
// 包含无效匹配的查询应该包含被排除的脚本,但标记为无效
expect(allResult.has(script.uuid)).toBe(true);
const matchInfo = allResult.get(script.uuid);
expect(matchInfo).toBeDefined();
expect(matchInfo!.effective).toBe(false);
});
});

这进一步说明“Popup 中仍可见并可撤销”是既定行为,而不是新增需求。

根因分析

高置信度结论

故障发生在 Popup 数据源 / 匹配状态链路,而不是 Popup 按钮实现:

Popup 点击排除
  → ScriptClient.excludeUrl 写入 selfMetadata.exclude
  → 运行时匹配缓存/规则重建
  → getPopupPageScriptMatchingResultByUrl(url)
  → PopupService.getPopupData()
  → scriptList

只要上述链路在排除后没有返回 { uuid, effective: false },脚本就会消失。

建议优先检查的具体原因

  1. 稳定版尚未包含当前 main 的 ORIGINAL_URLMATCH_SUFFIX / includeNonEffective 实现
    当前 main 看起来已经具备正确设计,但 [Feature] Add "Unmatched Scripts" section to popup #1590 描述的用户行为表明发布版本可能仍使用旧逻辑,或修复尚未回移到稳定渠道。

  2. excludeUrl 写入后,运行时匹配器或 cachedPatterns 未及时失效/重建
    如果主 uuid 规则被更新为排除后的规则,但 uuid{ORIGINAL} 未注册、被错误清理或保留了陈旧缓存,结果会完全不包含该 UUID。

  3. originalUrlPatterns 构建条件存在边界分支
    需要确认通过 Popup 新增 selfMetadata.exclude 时,buildScriptRunResourceBasic()getOrBuildPatternCache() 和 compiled resource 中的 originalUrlPatterns 始终保留脚本原始 @match/@include,且不被合并后的 exclude 污染。

  4. Service Worker 重启后的恢复路径与即时更新路径不一致
    即时点击后本地 React 状态只是把 isEffective 取反;重新打开 Popup 会重新调用后端。如果 SW 重启或重新构建时没有恢复 uuid{ORIGINAL} 规则,就会出现“点击后短暂可见,重开后消失”。

  5. 当前测试只覆盖 Runtime 方法,缺少端到端的 Popup 数据回归测试
    Runtime 单测正确并不能保证 excludeUrl → 失效缓存 → getPopupData 的完整链路正确。

建议修复方案

  1. 确保每个存在原始正向匹配、但被 selfMetadata.exclude 排除的普通脚本同时维护:
    • 合并规则键 uuid
    • 原始正向规则键 uuid{ORIGINAL}
  2. 修改 exclusion 后同步失效并重建:
    • pageLoadCaches
    • cachedPatterns
    • enabled matcher / disabled matcher 中的主键和 ORIGINAL 键;
    • 如有 compiled resource 缓存,也应保证其原始规则正确刷新。
  3. getPopupPageScriptMatchingResultByUrl() 必须继续使用 includeNonEffective=true
  4. PopupService.getPopupData() 对该结果保留脚本,并将 isEffective=false 传给现有 UI。
  5. 如果当前 main 已修复,请确认首次包含修复的版本,并考虑回移至当前稳定渠道。

建议新增回归测试

Runtime 层

现有测试基础上增加“通过 excludeUrl 修改后立即查询”和“模拟 SW 重启后重新构建”的用例。

PopupService 层(必要)

新增集成测试:

it("排除当前站点后 getPopupData 仍返回脚本且 isEffective=false", async () => {
  // 1. DAO 中脚本原始 @match 命中当前 URL
  // 2. 写入 selfMetadata.exclude
  // 3. 触发与生产相同的缓存失效/规则重建
  // 4. 调用 getPopupData({ tabId, url })
  // 5. 断言 scriptList 包含 uuid
  // 6. 断言 isEffective === false
});

另增加:

  • 取消排除后 isEffective === true
  • 关闭并重新打开 Popup;
  • 模拟 Service Worker 重启;
  • 启用和禁用脚本各一例;
  • @match@include、多个规则、已有手工 @exclude 各一例。

版本 / 环境

  • 用户可见报告来源:[Feature] Add "Unmatched Scripts" section to popup #1590(2026-07-16)
  • 当前代码审查版本:main / 48e96351e9bbbeefae4275d53aa5b076fc71f79b
  • 当前 mainpackage.json1.5.0-beta
  • 报告者实际浏览器、操作系统及扩展发行渠道:需由能够稳定复现的用户补充

临时解决方法

进入 ScriptCat 管理页,找到对应脚本并手工删除当前站点的排除规则。

Pinned by cyfung1031

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions