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
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