Skip to content
Draft
3 changes: 2 additions & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ jobs:
run: >
pnpm exec vitest run
--coverage
--silent
--silent=passed-only
--reporter=blob
--shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}

Expand Down Expand Up @@ -158,6 +158,7 @@ jobs:
pnpm exec vitest
--merge-reports
--coverage
--silent=passed-only
--reporter=default
--reporter.default.summary=false

Expand Down
14 changes: 11 additions & 3 deletions src/app/repo/scripts.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Repo } from "./repo";
import type { Resource } from "./resource";
import type { Resource, ResourceType } from "./resource";
import type { SCMetadata } from "./metadata";
import type { GMInfoEnv } from "../service/content/types";
import type { URLRuleEntry } from "@App/pkg/utils/url_matcher";
Expand Down Expand Up @@ -95,12 +95,19 @@ export type ScriptAndCode = Script & ScriptCode;

export type ValueStore = { [key: string]: any };

export type ScriptResource = { [key: string]: { base64?: string } & Omit<Resource, "base64"> };

export type ScriptResourceByType = Record<ResourceType, ScriptResource>;

export type PageScriptResource = Record<string, { base64?: string; content: string; contentType: string }>;

// 脚本运行时的资源,包含已经编译好的脚本与脚本需要的资源
export interface ScriptRunResource extends Script {
code: string; // 原始代码
value: ValueStore;
flag: string;
resource: { [key: string]: { base64?: string } & Omit<Resource, "base64"> }; // 资源列表,包含脚本需要的资源
resource: ScriptResource; // 资源列表,包含脚本需要的资源
resourceByType?: ScriptResourceByType;
metadata: SCMetadata; // 经自定义覆盖的 Metadata
originalMetadata: SCMetadata; // 原本的 Metadata (目前只需要 match, include, exclude)
}
Expand All @@ -127,7 +134,8 @@ export type TScriptInfo = Override<
ScriptLoadInfo,
{
originalMetadata?: Partial<Record<string, string[]>>;
resource: Record<string, { base64?: string; content: string; contentType: string }>;
resource: PageScriptResource;
requireCssResource?: PageScriptResource;
code: "" | string;
sort?: number;
flag: string;
Expand Down
15 changes: 15 additions & 0 deletions src/app/service/content/create_context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,4 +237,19 @@ describe.concurrent("createProxyContext", () => {
const sandbox = createProxyContext(createTestContext([]));
expect(Object.hasOwn(sandbox, "addEventListener")).toBe(true);
});

it.concurrent("多个沙盒之间不共享自有属性,且各自保留原生事件方法", () => {
const first = createProxyContext(createTestContext([]));
const second = createProxyContext(createTestContext([]));

first.sandboxOnly = "first";
second.sandboxOnly = "second";

expect(first.sandboxOnly).toBe("first");
expect(second.sandboxOnly).toBe("second");
expect(Object.hasOwn(first, "addEventListener")).toBe(true);
expect(Object.hasOwn(second, "addEventListener")).toBe(true);
expect(first.window).toBe(first);
expect(second.window).toBe(second);
});
});
45 changes: 45 additions & 0 deletions src/app/service/content/gm_api/gm_api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { compileScript, compileScriptCode } from "../utils";
import type { Message } from "@Packages/message/types";
import { encodeRValue } from "@App/pkg/utils/message_value";
import { uuidv4 } from "@App/pkg/utils/uuid";
import type { ScriptRunResource } from "@App/app/repo/scripts";
import GMApi from "./gm_api";
const nilFn: ScriptFunc = () => {};

const scriptRes = {
Expand All @@ -30,6 +32,49 @@ const envInfo: GMInfoEnv = {
isIncognito: false,
};

const makeResource = (url: string, content: string, type: "require" | "require-css" | "resource") => ({
url,
content,
base64: "",
hash: { md5: "", sha1: "", sha256: "", sha384: "", sha512: "" },
type,
link: {},
contentType: "text/plain",
createtime: Date.now(),
});

describe("GM Resource API", () => {
it("只从 resourceByType.resource 读取资源,并保留旧 payload fallback", async () => {
const name = "shared-name";
const script = {
...scriptRes,
uuid: "gm-resource-category-test",
value: {},
resource: { [name]: makeResource("https://example.com/lib.js", "require content", "require") },
resourceByType: {
require: { [name]: makeResource("https://example.com/lib.js", "require content", "require") },
"require-css": {},
resource: { [name]: makeResource("https://example.com/data.txt", "declared resource", "resource") },
},
} as unknown as ScriptRunResource;
const api = new GMApi("test", {} as Message, {} as Message, script);

expect(api.GM_getResourceText(name)).toBe("declared resource");
expect(api.GM_getResourceURL(name)).toContain("ZGVjbGFyZWQgcmVzb3VyY2U=");
expect(await api["GM.getResourceText"](name)).toBe("declared resource");
expect(await api["GM.getResourceUrl"](name)).toContain("ZGVjbGFyZWQgcmVzb3VyY2U=");

const legacyScript = {
...script,
resourceByType: undefined,
resource: { [name]: makeResource("https://example.com/data.txt", "legacy resource", "resource") },
} as unknown as ScriptRunResource;
const legacyApi = new GMApi("test", {} as Message, {} as Message, legacyScript);

expect(legacyApi.GM_getResourceText(name)).toBe("legacy resource");
});
});

describe.concurrent("@grant GM", () => {
it.concurrent("GM_", async () => {
const script = Object.assign({}, scriptRes) as ScriptLoadInfo;
Expand Down
4 changes: 2 additions & 2 deletions src/app/service/content/gm_api/gm_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1557,7 +1557,7 @@ export default class GMApi extends GM_Base {

@GMContext.API()
public GM_getResourceText(name: string): string | undefined {
const r = this.scriptRes?.resource?.[name];
const r = (this.scriptRes?.resourceByType?.resource ?? this.scriptRes?.resource)?.[name];
if (r) {
return r.content;
}
Expand All @@ -1575,7 +1575,7 @@ export default class GMApi extends GM_Base {

@GMContext.API()
public GM_getResourceURL(name: string, isBlobUrl?: boolean): string | undefined {
const r = this.scriptRes?.resource?.[name];
const r = (this.scriptRes?.resourceByType?.resource ?? this.scriptRes?.resource)?.[name];
if (r) {
let base64 = r.base64;
if (!base64) {
Expand Down
187 changes: 187 additions & 0 deletions src/app/service/content/script_executor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import type { Message } from "@Packages/message/types";
import type { ScriptLoadInfo } from "../service_worker/types";
import type { TScriptInfo } from "@App/app/repo/scripts";
import { initEnvInfo, ScriptExecutor } from "./script_executor";

const styleUrl = "https://example.com/style.css";
const secondStyleUrl = "https://example.com/second-style.css";

function makeScript(overrides: Partial<ScriptLoadInfo & Pick<TScriptInfo, "requireCssResource">> = {}): ScriptLoadInfo {
return {
uuid: "executor-test-uuid",
name: "Executor test",
namespace: "executor.test",
type: 1,
status: 1,
sort: 0,
runStatus: "complete",
createtime: Date.now(),
checktime: Date.now(),
code: "",
value: {},
flag: "executor-test-flag",
resource: {},
metadata: {},
originalMetadata: {},
metadataStr: "",
userConfigStr: "",
...overrides,
};
}

function makeValueUpdate(overrides: Partial<Parameters<ScriptExecutor["valueUpdate"]>[0]> = {}) {
return {
id: "value-update-id",
entries: [],
uuid: "missing-uuid",
storageName: "missing-storage",
sender: { runFlag: "remote-run" },
valueUpdated: true,
...overrides,
};
}

describe("ScriptExecutor", () => {
describe("resource execution", () => {
let adoptedSheets: CSSStyleSheet[];

beforeEach(() => {
class MockCSSStyleSheet {
cssText = "";

replaceSync(css: string) {
this.cssText = css;
}
}

vi.stubGlobal("CSSStyleSheet", MockCSSStyleSheet);
adoptedSheets = [];
vi.spyOn(document, "adoptedStyleSheets", "get").mockImplementation(() => [...adoptedSheets]);
vi.spyOn(document, "adoptedStyleSheets", "set").mockImplementation((value: CSSStyleSheet[]) => {
adoptedSheets = [...value];
});
});

afterEach(() => {
vi.restoreAllMocks();
});

it("injects every resolved @require-css resource in declaration order", () => {
const script = makeScript({
metadata: { "require-css": [styleUrl, secondStyleUrl] },
resource: {
[secondStyleUrl]: {
url: secondStyleUrl,
content: "body { color: blue; }",
base64: "",
hash: { md5: "test", sha1: "test", sha256: "test", sha384: "test", sha512: "test" },
type: "require-css",
link: {},
contentType: "text/css",
createtime: Date.now(),
},
[styleUrl]: {
url: styleUrl,
content: "body { color: red; }",
base64: "",
hash: { md5: "test", sha1: "test", sha256: "test", sha384: "test", sha512: "test" },
type: "require-css",
link: {},
contentType: "text/css",
createtime: Date.now(),
},
},
});

const executor = new ScriptExecutor({} as Message, {} as Message);
executor.execScriptEntry({
scriptLoadInfo: script,
scriptFlag: script.flag,
envInfo: initEnvInfo,
scriptFunc: () => undefined,
});

expect(adoptedSheets).toHaveLength(2);
expect((adoptedSheets[0] as CSSStyleSheet & { cssText: string }).cssText).toBe("body { color: red; }");
expect((adoptedSheets[1] as CSSStyleSheet & { cssText: string }).cssText).toBe("body { color: blue; }");
});

it("uses the category-specific CSS resource when a key collides", () => {
const script = makeScript({
metadata: { "require-css": [styleUrl] },
resource: {
[styleUrl]: {
url: styleUrl,
content: "not css",
base64: "",
hash: { md5: "test", sha1: "test", sha256: "test", sha384: "test", sha512: "test" },
type: "resource",
link: {},
contentType: "text/plain",
createtime: Date.now(),
},
},
requireCssResource: {
[styleUrl]: {
content: "body { color: green; }",
contentType: "text/css",
},
},
});

const executor = new ScriptExecutor({} as Message, {} as Message);
executor.execScriptEntry({
scriptLoadInfo: script,
scriptFlag: script.flag,
envInfo: initEnvInfo,
scriptFunc: () => undefined,
});

expect((adoptedSheets[0] as CSSStyleSheet & { cssText: string }).cssText).toBe("body { color: green; }");
});
});

describe("value update routing", () => {
it("delivers UUID and shared-storage matches once, preserving registration order", () => {
const executor = new ScriptExecutor({} as Message, {} as Message);
const delivered: string[] = [];
const add = (uuid: string, storageName: string) => {
executor.execScriptMap.set(uuid, {
scriptRes: { uuid, metadata: storageName ? { storagename: [storageName] } : {} },
valueUpdate: vi.fn(() => delivered.push(uuid)),
} as never);
};

add("first", "shared-storage");
add("second", "shared-storage");
add("third", "other-storage");

executor.valueUpdate(makeValueUpdate({ uuid: "first", storageName: "shared-storage" }));

expect(delivered).toEqual(["first", "second"]);
expect(executor.execScriptMap.get("first")?.valueUpdate).toHaveBeenCalledTimes(1);
expect(executor.execScriptMap.get("second")?.valueUpdate).toHaveBeenCalledTimes(1);
expect(executor.execScriptMap.get("third")?.valueUpdate).not.toHaveBeenCalled();
});

it("delivers only the UUID match when no shared storage overlaps", () => {
const executor = new ScriptExecutor({} as Message, {} as Message);
const first = vi.fn();
const second = vi.fn();
executor.execScriptMap.set("first", {
scriptRes: { uuid: "first", metadata: { storagename: ["first-storage"] } },
valueUpdate: first,
} as never);
executor.execScriptMap.set("second", {
scriptRes: { uuid: "second", metadata: { storagename: ["second-storage"] } },
valueUpdate: second,
} as never);

executor.valueUpdate(makeValueUpdate({ uuid: "second", storageName: "unknown-storage" }));

expect(first).not.toHaveBeenCalled();
expect(second).toHaveBeenCalledTimes(1);
});
});
});
2 changes: 1 addition & 1 deletion src/app/service/content/script_executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ export class ScriptExecutor {
});
this.execScriptMap.set(scriptLoadInfo.uuid, execScript);
const metadata = scriptLoadInfo.metadata || {};
const resource = scriptLoadInfo.resource;
const resource = scriptLoadInfo.requireCssResource ?? scriptLoadInfo.resource;
// 注入css
if (metadata["require-css"] && resource) {
for (const val of metadata["require-css"]) {
Expand Down
Loading
Loading