Skip to content
Draft
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
174 changes: 174 additions & 0 deletions src/app/service/service_worker/popup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { initTestEnv } from "@Tests/utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cacheInstance } from "@App/app/cache";
import { CACHE_KEY_TAB_SCRIPT } from "@App/app/cache_key";
import { PopupService } from "./popup";
import type { ScriptMenu } from "./types";
import type { RuntimeService } from "./runtime";
import { SCRIPT_STATUS_ENABLE, SCRIPT_TYPE_NORMAL, type Script, type ScriptDAO } from "@App/app/repo/scripts";
import type { IMessageQueue } from "@Packages/message/message_queue";
import type { Group } from "@Packages/message/server";
import type { SystemConfig } from "@App/pkg/config/config";
import type { TDeleteScript } from "../queue";

initTestEnv();

describe("PopupService stale script menu cleanup", () => {
const createMenu = (uuid: string): ScriptMenu => ({
uuid,
name: `script-${uuid}`,
storageName: `script-${uuid}`,
enable: true,
updatetime: 1,
hasUserConfig: false,
runStatus: "running",
runNum: 1,
runNumByIframe: 0,
menus: [],
isEffective: true,
});

const createScript = (uuid: string): Script => ({
uuid,
name: `script-${uuid}`,
namespace: "test-namespace",
metadata: {},
type: SCRIPT_TYPE_NORMAL,
status: SCRIPT_STATUS_ENABLE,
sort: 0,
runStatus: "running",
createtime: 1,
checktime: 1,
});

const createService = (overrides: { runtime?: Partial<RuntimeService>; scriptDAO?: Partial<ScriptDAO> } = {}) => {
const subscriptions = new Map<string, Array<(message: unknown) => unknown>>();
const mq = {
subscribe: vi.fn((topic: string, handler: (message: unknown) => unknown) => {
const handlers = subscriptions.get(topic) || [];
handlers.push(handler);
subscriptions.set(topic, handlers);
return () => undefined;
}),
publish: vi.fn(),
emit: vi.fn(),
group: vi.fn(),
} as unknown as IMessageQueue;
const runtime = {
getPopupPageScriptMatchingResultByUrl: vi.fn().mockResolvedValue(new Map()),
isUrlBlacklist: vi.fn().mockReturnValue(false),
emitEventToTab: vi.fn(),
...overrides.runtime,
} as unknown as RuntimeService;
const scriptDAO = {
get: vi.fn(),
gets: vi.fn().mockResolvedValue([]),
...overrides.scriptDAO,
} as unknown as ScriptDAO;
const systemConfig = {
getScriptMenuDisplayType: vi.fn().mockResolvedValue("all"),
getBadgeNumberType: vi.fn().mockResolvedValue("script_count"),
getBadgeBackgroundColor: vi.fn().mockResolvedValue("#000000"),
getBadgeTextColor: vi.fn().mockResolvedValue("#ffffff"),
} as unknown as SystemConfig;
const service = new PopupService({} as Group, mq, runtime, scriptDAO, systemConfig);
return { service, subscriptions, runtime, scriptDAO };
};

beforeEach(async () => {
await cacheInstance.clear();
});

it("filters deleted scripts from stale tabScript run records when reading Popup data", async () => {
const deletedUuid = "deleted-script";
const liveUuid = "live-script";
await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${1}`, [createMenu(deletedUuid), createMenu(liveUuid)]);

const { service, scriptDAO } = createService({
scriptDAO: {
gets: vi.fn(async (uuids: string[]) =>
uuids.map((uuid) => (uuid === liveUuid ? createScript(uuid) : undefined))
),
},
});

const result = await service.getPopupData({ tabId: 1, url: "https://example.com/" });

expect(result.scriptList.map((script) => script.uuid)).toEqual([liveUuid]);
expect(scriptDAO.gets).toHaveBeenCalledWith([deletedUuid, liveUuid]);
});

it("ignores late menu register commands for deleted scripts", async () => {
const deletedUuid = "deleted-script";
const { service, scriptDAO } = createService({
scriptDAO: {
gets: vi.fn(async () => [undefined]),
},
});

await (service as any).updateRegisterMenuCommand(
{
uuid: deletedUuid,
key: "late-menu",
name: "Late menu",
options: {},
tabId: 1,
},
1
);

await expect(service.getScriptMenu(1)).resolves.toEqual([]);
expect(scriptDAO.gets).toHaveBeenCalledWith([deletedUuid]);
});

it("removes deleted scripts from all Popup menu caches and pending menu commands", async () => {
const deletedUuid = "deleted-script";
const liveUuid = "live-script";
await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${1}`, [createMenu(deletedUuid), createMenu(liveUuid)]);
await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${2}`, [createMenu(deletedUuid)]);
await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${-1}`, [createMenu(deletedUuid)]);

const { service, subscriptions } = createService();
service.updateMenuCommands.set(1, [
{
uuid: deletedUuid,
key: "deleted-menu",
name: "Deleted menu",
options: {},
tabId: 1,
registerType: 1,
},
{
uuid: liveUuid,
key: "live-menu",
name: "Live menu",
options: {},
tabId: 1,
registerType: 1,
},
] as never);
service.updateMenuCommands.set(2, [
{
uuid: deletedUuid,
key: "deleted-menu",
name: "Deleted menu",
options: {},
tabId: 2,
registerType: 1,
},
] as never);
service.dealBackgroundScriptInstall();

const [deleteHandler] = subscriptions.get("deleteScripts") || [];
expect(deleteHandler).toBeDefined();
await deleteHandler!([
{ uuid: deletedUuid, storageName: "deleted-script", type: SCRIPT_TYPE_NORMAL },
] satisfies TDeleteScript[]);

await expect(service.getScriptMenu(1)).resolves.toEqual([createMenu(liveUuid)]);
await expect(service.getScriptMenu(2)).resolves.toEqual([]);
await expect(service.getScriptMenu(-1)).resolves.toEqual([]);
expect(service.updateMenuCommands.get(1)?.map((command) => command.uuid)).toEqual([liveUuid]);
expect(service.updateMenuCommands.has(2)).toBe(false);
});
});
108 changes: 90 additions & 18 deletions src/app/service/service_worker/popup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,8 +291,31 @@ export class PopupService {
list.push({ ...message, registerType });
let retUpdated: string[] | undefined;
return Promise.resolve(list) // 增加一个 await Promise.reslove() 转移微任务队列 再判断长度是否为0
.then((list) => {
.then(async (list) => {
if (!list.length) return;

// Content scripts can still send a late GM_registerMenuCommand after their script has been deleted.
// Drop those register records before they can recreate stale tabScript:<tabId> Popup entries.
const registerUuids = [
...new Set(
list.filter((entry) => entry.registerType === ScriptMenuRegisterType.REGISTER).map((entry) => entry.uuid)
),
];
if (registerUuids.length) {
const scripts = await this.scriptDAO.gets(registerUuids);
const existingUuids = new Set(scripts.flatMap((script) => (script ? [script.uuid] : [])));
for (let idx = list.length - 1; idx >= 0; idx--) {
const entry = list[idx];
if (entry.registerType === ScriptMenuRegisterType.REGISTER && !existingUuids.has(entry.uuid)) {
list.splice(idx, 1);
}
}
if (!list.length) {
this.updateMenuCommands.delete(tabId);
return;
}
}

return cacheInstance.tx(`${CACHE_KEY_TAB_SCRIPT}${tabId}`, (data: ScriptMenu[] | undefined, tx) => {
if (!list.length) return;
data = data || [];
Expand Down Expand Up @@ -342,7 +365,7 @@ export class PopupService {
async getPopupData(req: GetPopupDataReq): Promise<GetPopupDataRes> {
const { url, tabId } = req;
const [matchingResult, runScripts, backScriptList] = await Promise.all([
this.runtime.getPageScriptMatchingResultByUrl(url, true, true),
this.runtime.getPopupPageScriptMatchingResultByUrl(url),
this.getScriptMenu(tabId),
this.getScriptMenu(-1),
]);
Expand Down Expand Up @@ -382,11 +405,16 @@ export class PopupService {
}

// 将未匹配当前 url 但仍在运行的脚本,附加到清单末端,避免使用者找不到其菜单。
// 把运行了但是不在匹配中的脚本加入到菜单的最后 (因此 runMap 和 scriptMenuMap 分开成两个变数)
for (const script of runScripts) {
// 把运行了但是不在匹配中的脚本加入菜单
if (!scriptMenuMap.has(script.uuid)) {
scriptMenuMap.set(script.uuid, script);
// 这些记录来自 tabScript:<tabId> session cache;脚本删除事件与 Popup 读取可能交错,
// 因此这里必须用 DAO 结果做读侧防护,避免已删除脚本残留在 Popup 清单。
const unmatchedRunScripts = runScripts.filter((script) => !scriptMenuMap.has(script.uuid));
if (unmatchedRunScripts.length) {
const existingRunScripts = await this.scriptDAO.gets(unmatchedRunScripts.map((script) => script.uuid));
for (let idx = 0, l = unmatchedRunScripts.length; idx < l; idx++) {
const script = unmatchedRunScripts[idx];
if (existingRunScripts[idx]) {
scriptMenuMap.set(script.uuid, script);
}
}
}
const scriptMenu = [...scriptMenuMap.values()];
Expand All @@ -401,6 +429,55 @@ export class PopupService {
return (await cacheInstance.get<ScriptMenu[]>(cacheKey)) || [];
}

private updateCachedScriptMenuCounters(tabId: number, menu: ScriptMenu[]) {
if (tabId <= 0) return;
scriptCountMap.set(tabId, menu.length ? `${menu.length}` : "");
const runCount = menu.reduce((count, script) => count + (script.runNum || 0), 0);
runCountMap.set(tabId, runCount ? `${runCount}` : "");
}

private removeDeletedScriptsFromPendingMenuCommands(deletedUuids: Set<string>) {
for (const [tabId, commands] of this.updateMenuCommands) {
const nextCommands = commands.filter((command) => !deletedUuids.has(command.uuid));
if (nextCommands.length) {
if (nextCommands.length !== commands.length) {
this.updateMenuCommands.set(tabId, nextCommands);
}
} else {
this.updateMenuCommands.delete(tabId);
}
}
}

private async removeDeletedScriptsFromPopupCaches(uuids: string[]) {
if (!uuids.length) return false;

const deletedUuids = new Set(uuids);
this.removeDeletedScriptsFromPendingMenuCommands(deletedUuids);

const keys = (await cacheInstance.list()).filter((key) => key.startsWith(CACHE_KEY_TAB_SCRIPT));
let changed = false;
await Promise.all(
keys.map((key) =>
cacheInstance.tx(key, (menu: ScriptMenu[] | undefined, tx) => {
if (!menu?.length) return;
const nextMenu = menu.filter((item) => !deletedUuids.has(item.uuid));
if (nextMenu.length === menu.length) return;

changed = true;
const tabId = Number(key.slice(CACHE_KEY_TAB_SCRIPT.length));
this.updateCachedScriptMenuCounters(tabId, nextMenu);
if (nextMenu.length) {
tx.set(nextMenu);
} else {
tx.del();
}
})
)
);
return changed;
}

async addScriptRunNumber(o: TPopupPageLoadInfo) {
const { tabId, frameId, scriptmenus } = o;
// 设置数据
Expand Down Expand Up @@ -504,17 +581,12 @@ export class PopupService {
}
});
});
this.mq.subscribe<TDeleteScript[]>("deleteScripts", (data) => {
cacheInstance.tx(`${CACHE_KEY_TAB_SCRIPT}${-1}`, (menu: ScriptMenu[] | undefined, tx) => {
if (!menu) return;
for (const { uuid } of data) {
const index = menu.findIndex((item) => item.uuid === uuid);
if (index !== -1) {
menu.splice(index, 1);
tx.set(menu);
}
}
});
this.mq.subscribe<TDeleteScript[]>("deleteScripts", async (data) => {
const changed = await this.removeDeletedScriptsFromPopupCaches(data.map(({ uuid }) => uuid));
if (changed) {
this.updateBadgeIcon();
this.genScriptMenu();
}
});
this.mq.subscribe<TScriptRunStatus>("scriptRunStatus", ({ uuid, runStatus }) => {
cacheInstance.tx(`${CACHE_KEY_TAB_SCRIPT}${-1}`, (menu: ScriptMenu[] | undefined, tx) => {
Expand Down
23 changes: 23 additions & 0 deletions src/app/service/service_worker/resource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,27 @@ describe("ResourceService - loadByUrl", () => {

expect(res.contentType).toBe("application/octet-stream");
});

it("命名 @resource 的 file:/// path 应按 path 判断并立即更新", async () => {
const fileResource = {
url: "file:///tmp/local.txt",
content: "local",
base64: "",
hash: { md5: "mock-md5", sha1: "", sha256: "", sha384: "", sha512: "sha" },
type: "resource" as const,
link: {},
contentType: "text/plain",
createtime: Date.now(),
};
const updateSpy = vi.spyOn(service, "updateResource").mockResolvedValue(fileResource);

const res = await service.getResourceByType(
{ uuid: "script-1", metadata: { resource: ["data file:///tmp/local.txt"] } } as any,
"resource",
false
);

expect(updateSpy).toHaveBeenCalledWith("script-1", "file:///tmp/local.txt", "resource");
expect(res.data).toBe(fileResource);
});
});
2 changes: 1 addition & 1 deletion src/app/service/service_worker/resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export class ResourceService {
}
}
if (path) {
if (uri.startsWith("file:///")) {
if (path.startsWith("file:///")) {
// 如果是file://协议,则每次请求更新一下文件
const res = await this.updateResource(script.uuid, path, type);
ret[resourceKey] = res;
Expand Down
Loading
Loading