Skip to content
31 changes: 30 additions & 1 deletion e2e/gm-api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,16 @@ async function startGMApiMockServer(): Promise<GMApiMockServer> {
return;
}

if (url.pathname === "/module-lib.js") {
// 供 script_module_e2e_test.js 静态 `import ... from` 使用,验证注入的确是可被浏览器
// 当作 ES module 解析执行的 <script type="module">(普通 script 遇到 import 会直接语法报错)
res.writeHead(200, { "Content-Type": "text/javascript; charset=utf-8" });
res.end(
'export function addNumbers(a, b) { return a + b; }\nexport const MODULE_LIB_MARKER = "script-module-e2e-import-ok";\n'
);
return;
}

const bytesMatch = url.pathname.match(/^\/bytes\/(\d+)$/);
if (bytesMatch) {
const size = Number(bytesMatch[1]);
Expand Down Expand Up @@ -212,7 +222,7 @@ function patchTargetMatchCode(code: string, targetUrl: string): string {
const url = new URL(targetUrl);
const targetPattern = `${url.protocol}//${url.hostname}/*${url.search}`;
return code.replace(
/^\/\/\s*@match\s+.*\?(gm_api_sync|gm_api_async|inject_content|WINDOW_MESSAGE_TEST_SC|SANDBOX_TEST_SC|unwrap_e2e_test)$/gm,
/^\/\/\s*@match\s+.*\?(gm_api_sync|gm_api_async|inject_content|WINDOW_MESSAGE_TEST_SC|SANDBOX_TEST_SC|unwrap_e2e_test|script_module_e2e_test)$/gm,
`// @match ${targetPattern}`
);
}
Expand Down Expand Up @@ -375,6 +385,25 @@ test.describe("GM API", () => {
expect(passed, "No test results found - script may not have run").toBeGreaterThan(0);
});

test("@script-module tests (script_module_e2e_test.js)", async ({ context, extensionId }) => {
// @script-module 以 <script type="module"> 直接注入页面 DOM,会受页面自身 CSP 限制
// (见 PR 描述的限制3),因此使用无 CSP 头的普通 mock 站点,而非 cspOrigin。
const { passed, failed, logs } = await runTestScript(
context,
extensionId,
"script_module_e2e_test.js",
`${gmApiMockServer.origin}/?script_module_e2e_test`,
60_000
);

console.log(`[script_module_e2e_test] passed=${passed}, failed=${failed}`);
if (failed !== 0) {
console.log("[script_module_e2e_test] logs:", logs.join("\n"));
}
expect(failed, "Some @script-module tests failed").toBe(0);
expect(passed, "No test results found - script may not have run").toBeGreaterThan(0);
});

test("WindowMessage Transport Test (window_message_test.js)", async ({ context, extensionId }) => {
const { passed, failed, logs } = await runTestScript(
context,
Expand Down
91 changes: 91 additions & 0 deletions example/tests/script_module_e2e_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// ==UserScript==
// @name @script-module E2E Test
// @namespace https://docs.scriptcat.org/
// @version 1.0.0
// @description E2E 测试 @script-module 功能(以 <script type="module"> 注入)
// @author ScriptCat
// @match https://content-security-policy.com/?script_module_e2e_test
// @grant GM.getValue
// @grant GM.setValue
// @script-module
// ==/UserScript==

// 真正的 ES module 才能使用静态 `import ... from`;普通 <script>(非 module)遇到此语法
// 会直接抛出 SyntaxError,导致整份脚本都无法执行。以此验证注入的确是 <script type="module">
import { addNumbers, MODULE_LIB_MARKER } from "/module-lib.js";

// module 顶层的 this 应为 undefined(普通 <script> 顶层 this 为 window),
// 必须在模块顶层(而非函数内)读取才能反映真实的执行上下文
const __sc_module_top_level_this_is_undefined = typeof this === "undefined";

// module 顶层的 const 声明不会挂载到 window 上(与普通注入脚本的顶层 var 不同)
const __sc_module_e2e_marker = true;

(async function () {
"use strict";

console.log("%c=== @script-module E2E 测试开始 ===", "color: blue; font-size: 16px; font-weight: bold;");

let testResults = {
passed: 0,
failed: 0,
total: 0,
};

async function test(name, fn) {
testResults.total++;
try {
await fn();
testResults.passed++;
console.log("%c✓ " + name, "color: green;");
return true;
} catch (error) {
testResults.failed++;
console.error("%c✗ " + name, "color: red;", error);
return false;
}
}

function assert(expected, actual, message) {
if (expected !== actual) {
const valueInfo = "期望 " + JSON.stringify(expected) + ", 实际 " + JSON.stringify(actual);
const error = message ? message + " - " + valueInfo : "断言失败: " + valueInfo;
throw new Error(error);
}
}

await test("module 顶层 this 为 undefined", async () => {
assert(true, __sc_module_top_level_this_is_undefined, "module 顶层 this 应为 undefined");
});

await test("document.currentScript 在 module 内为 null", async () => {
assert(null, document.currentScript, "module 脚本的 document.currentScript 应为 null");
});

await test("module 顶层声明不会挂载到 window", async () => {
assert("undefined", typeof window.__sc_module_e2e_marker, "module 顶层变量不应出现在 window 上");
});

// 与其他 GM 脚本一致:window 为沙盒 window(与页面隔离),unsafeWindow 才是真实页面 window
// 参见 window_message_test.js 中同样的约定
await test("window 为沙盒 window,与 unsafeWindow(真实页面 window)不同", async () => {
assert(true, window !== unsafeWindow, "window 不应等于 unsafeWindow");
assert(document, unsafeWindow.document, "unsafeWindow.document 应等于真实 document");
});

await test("静态 import 的模块(/module-lib.js)被正确加载并可用", async () => {
assert("script-module-e2e-import-ok", MODULE_LIB_MARKER, "导入的常量值应与 module-lib.js 导出的一致");
assert(7, addNumbers(3, 4), "导入的函数应可正常调用");
});

await test("GM.setValue / GM.getValue 正常工作", async () => {
await GM.setValue("script_module_e2e_key", "script_module_e2e_value");
const v = await GM.getValue("script_module_e2e_key");
assert("script_module_e2e_value", v, "GM.getValue 应读取到刚写入的值");
});

console.log("\n%c=== 测试结果总结 ===", "color: blue; font-size: 16px; font-weight: bold;");
console.log("总测试数: " + testResults.total);
console.log("%c通过: " + testResults.passed, "color: green; font-weight: bold;");
console.log("%c失败: " + testResults.failed, "color: red; font-weight: bold;");
})();
74 changes: 74 additions & 0 deletions src/app/service/content/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
compileInjectScript,
compileScriptletCode,
isScriptletUnwrap,
isScriptModule,
wrapScriptModuleCode,
addStyle,
addStyleSheet,
} from "./utils";
Expand Down Expand Up @@ -563,6 +565,78 @@ describe("utils", () => {
});
});

describe.concurrent("isScriptModule", () => {
it.concurrent("@script-module 为空值时返回 true", () => {
expect(isScriptModule({ "script-module": [""] })).toBe(true);
});

it.concurrent("@script-module 为 true 时返回 true", () => {
expect(isScriptModule({ "script-module": ["true"] })).toBe(true);
});

it.concurrent("没有 @script-module 时返回 false", () => {
expect(isScriptModule({})).toBe(false);
});

it.concurrent("@script-module 为 false 时返回 false", () => {
expect(isScriptModule({ "script-module": ["false"] })).toBe(false);
});

it.concurrent("@script-module 与 @inject-into content 同时存在时返回 false", () => {
expect(isScriptModule({ "script-module": [""], "inject-into": ["content"] })).toBe(false);
});

it.concurrent("@script-module 与 @inject-into page 同时存在时返回 true", () => {
expect(isScriptModule({ "script-module": [""], "inject-into": ["page"] })).toBe(true);
});

it.concurrent("搭配真实 @grant 时仍返回 true(module 支持完整 GM.* 能力,不限于 @grant none)", () => {
expect(isScriptModule({ "script-module": [""], grant: ["GM.getValue", "GM.setValue"] })).toBe(true);
});

it.concurrent("搭配 @require 时返回 false(module 无法访问外层 classic-script 的词法变量)", () => {
expect(isScriptModule({ "script-module": [""], require: ["https://example.com/a.js"] })).toBe(false);
});

it.concurrent("搭配 early-start(document-start)时仍返回 true", () => {
expect(
isScriptModule({
"script-module": [""],
"early-start": [""],
"run-at": ["document-start"],
})
).toBe(true);
});
});

describe.concurrent("wrapScriptModuleCode", () => {
it.concurrent("透过临时 document 属性传递 GM(含实际授予的能力)/window/unsafeWindow 等,并在读取后删除", () => {
const result = wrapScriptModuleCode("console.log('hi')", "Test Script");

// 生成 <script type="module"> 并挂载到 document
expect(result).toContain('jsScript.type = "module"');
expect(result).toContain('document.createElement("script")');
expect(result).toMatch(
/\(document\.body \|\| document\.head \|\| document\.documentElement\)\.appendChild\(jsScript\)/
);

// 通过临时 document 属性传递沙盒变量给 module script,并在读取后删除
expect(result).toMatch(
/document\.__[a-z0-9]+_[a-z0-9]+\s*=\s*\{GM, top, parent, window, globalThis: window, unsafeWindow:/
);
expect(result).toMatch(
/const \{GM, top, parent, window, unsafeWindow, globalThis\} = document\.__[a-z0-9]+_[a-z0-9]+; delete document\.__[a-z0-9]+_[a-z0-9]+;/
);

// 原始脚本代码被内嵌到注入的 module 字符串中
expect(result).toContain("console.log('hi')");

// 注入失败(import 解析失败 / 被页面 CSP 拦截)时有可观测的错误处理
expect(result).toContain('jsScript.addEventListener("error"');
expect(result).toContain(JSON.stringify("Test Script"));
});
});

describe.concurrent("compileScriptletCode", () => {
const createMockScriptRes = (
overrides: Partial<ScriptRunResource> = {},
Expand Down
37 changes: 37 additions & 0 deletions src/app/service/content/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,43 @@ export function isScriptletUnwrap(metadata: SCMetadata): boolean {
return metadataBlankOrTrue(metadata, "unwrap");
}

// @script-module 必须新建独立的 <script type="module">,无法复用外层 with(arguments[0]) 沙盒 Proxy,
// 因此通过临时的 document 属性把 GM(按 @grant 授予的实际能力)、window、unsafeWindow 等
// 转交给新建的 module;module 读取后立即删除该属性。
// 排除 @require:其内容在外层 classic-script 作用域执行,module 无法访问那些词法变量,
// 组合使用会静默产生 require 内容对 module 不可见的错误行为,因此在此明确拒绝而非放任。
export function isScriptModule(metadata: SCMetadata): boolean {
return (
metadataBlankOrTrue(metadata, "script-module") &&
!isInjectIntoContent(metadata) &&
!(Array.isArray(metadata.require) && metadata.require.length > 0)
);
}

/**
* 将脚本代码包装为通过独立 <script type="module"> 标签注入的形式。
* 通过临时的 document 属性把当前脚本的 GM(按 @grant 授予的实际能力)、window、unsafeWindow、
* top、parent、globalThis 转交给新建的 module,module 读取后立即删除该属性。
* @see {@link isScriptModule}
*/
export function wrapScriptModuleCode(scriptCode: string, scriptName: string): string {
const wId = `__${Date.now().toString(36)}_${(Math.random() + 1).toString(36).substring(2)}`;
const bakedScriptName = JSON.stringify(scriptName);
const moduleSource = JSON.stringify(
`const {GM, top, parent, window, unsafeWindow, globalThis} = document.${wId}; delete document.${wId};\n${scriptCode}`
);
return [
"(function(){",
`document.${wId} = {GM, top, parent, window, globalThis: window, unsafeWindow: typeof unsafeWindow !== "undefined" ? unsafeWindow : undefined};`,
'var jsScript = document.createElement("script");',
'jsScript.type = "module";',
`jsScript.textContent = ${moduleSource};`,
`jsScript.addEventListener("error", function(e){ console.error("[ScriptCat] @script-module " + ${bakedScriptName} + " 执行失败(可能是 import 解析失败或被页面 CSP 拦截)", e); });`,
"(document.body || document.head || document.documentElement).appendChild(jsScript);",
"})();",
].join("\n");
}

export function isInjectIntoContent(metadata: SCMetadata): boolean {
return metadata["inject-into"]?.[0] === "content";
}
Expand Down
65 changes: 65 additions & 0 deletions src/app/service/service_worker/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,71 @@ describe.concurrent("RuntimeService - getPageScriptMatchingResultByUrl 脚本匹
});
});

describe.concurrent("restoreJSCodeFromCompiledResource 缓存恢复路径与首次编译路径的一致性", () => {
it.concurrent("@script-module 脚本:缓存恢复路径也应套用 module 包装,而非退回 classic-script 编译", async () => {
const { runtime, mockScriptService } = createRuntimeTestContext();
(mockScriptService as any).scriptCodeDAO = {
get: vi.fn().mockResolvedValue({ code: "import x from './x.js'; console.log(x);" }),
};

const script = createMockScript({
metadata: { "script-module": [""], grant: ["GM.getValue"], match: ["https://www.example.com/*"] },
});
const compiledResource: CompiledResource = {
name: script.name,
flag: "#-" + script.uuid,
uuid: script.uuid,
require: [],
matches: ["https://www.example.com/*"],
includeGlobs: [],
excludeMatches: [],
excludeGlobs: [],
allFrames: false,
world: "MAIN",
runAt: "document-idle",
scriptUrlPatterns: [],
originalUrlPatterns: null,
};

const code = await runtime.restoreJSCodeFromCompiledResource(script, compiledResource);

expect(code).toContain('jsScript.type = "module"');
expect(code).toMatch(
/document\.__[a-z0-9]+_[a-z0-9]+\s*=\s*\{GM, top, parent, window, globalThis: window, unsafeWindow:/
);
});

it.concurrent("普通脚本(非 script-module):缓存恢复路径不应套用 module 包装", async () => {
const { runtime, mockScriptService } = createRuntimeTestContext();
(mockScriptService as any).scriptCodeDAO = {
get: vi.fn().mockResolvedValue({ code: "console.log('normal');" }),
};

const script = createMockScript({
metadata: { match: ["https://www.example.com/*"] },
});
const compiledResource: CompiledResource = {
name: script.name,
flag: "#-" + script.uuid,
uuid: script.uuid,
require: [],
matches: ["https://www.example.com/*"],
includeGlobs: [],
excludeMatches: [],
excludeGlobs: [],
allFrames: false,
world: "MAIN",
runAt: "document-idle",
scriptUrlPatterns: [],
originalUrlPatterns: null,
};

const code = await runtime.restoreJSCodeFromCompiledResource(script, compiledResource);

expect(code).not.toContain('jsScript.type = "module"');
});
});

describe("redo matcher cache behavior", () => {
it("安装或排序更新后,已缓存 URL 的下一次匹配顺序应立即反映新 sort", async () => {
const { runtime } = createRuntimeTestContext();
Expand Down
9 changes: 8 additions & 1 deletion src/app/service/service_worker/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ import {
isEarlyStartScript,
isInjectIntoContent,
isScriptletUnwrap,
isScriptModule,
trimScriptInfo,
wrapScriptModuleCode,
} from "../content/utils";
import LoggerCore from "@App/app/logger/core";
import PermissionVerify from "./permission_verify";
Expand Down Expand Up @@ -935,11 +937,16 @@ export class RuntimeService {
}
}

let code = originalCode?.code || "";
if (isScriptModule(script.metadata)) {
code = wrapScriptModuleCode(code, result.name);
}

return compileInjectScriptByFlag(
result.flag,
compileScriptCodeByResource({
name: result.name,
code: originalCode?.code || "",
code,
require,
isContextMenu: isContextMenuScript(script.metadata),
})
Expand Down
Loading
Loading