Skip to content

Commit 202b693

Browse files
cyfung1031CopilotCodFrm
authored
✨ 支持 @unwrap (#1213)
* feat: 支持 `@unwrap` * 加 example js 加 monaco editor hint * 修正代码及测试 * 修正 restoreJSCodeFromCompiledResource * 加入 `embeddedPatternCheckerString` 让 `@unwrap` 可以在 `@exclude` 排除 * update test js * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * copilot suggestions * 行尾有多余空格 * ✅ 添加 @unwrap 功能单元测试和 E2E 测试 - isScriptletUnwrap / compileScriptletCode 单元测试 - embeddedPatternChecker / embeddedPatternCheckerString 单元测试 - compileInjectionCode unwrap 分支单元测试 - unwrap E2E 测试(GM API 为 undefined、全局变量可访问) * 💄 格式化 content/utils.test.ts(Prettier 自动修正) --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: 王一之 <yz@ggnb.top>
1 parent a144ae3 commit 202b693

11 files changed

Lines changed: 572 additions & 22 deletions

File tree

e2e/gm-api.spec.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,4 +196,21 @@ test.describe("GM API", () => {
196196
expect(failed, "Some content inject tests failed").toBe(0);
197197
expect(passed, "No test results found - script may not have run").toBeGreaterThan(0);
198198
});
199+
200+
test("Unwrap scriptlet tests (unwrap_e2e_test.js)", async ({ context, extensionId }) => {
201+
const { passed, failed, logs } = await runTestScript(
202+
context,
203+
extensionId,
204+
"unwrap_e2e_test.js",
205+
TARGET_URL,
206+
60_000
207+
);
208+
209+
console.log(`[unwrap_e2e_test] passed=${passed}, failed=${failed}`);
210+
if (failed !== 0) {
211+
console.log("[unwrap_e2e_test] logs:", logs.join("\n"));
212+
}
213+
expect(failed, "Some unwrap scriptlet tests failed").toBe(0);
214+
expect(passed, "No test results found - script may not have run").toBeGreaterThan(0);
215+
});
199216
});

example/tests/unwrap_e2e_test.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// ==UserScript==
2+
// @name Unwrap E2E Test
3+
// @namespace https://docs.scriptcat.org/
4+
// @version 1.0.0
5+
// @description E2E 测试 @unwrap 功能
6+
// @author ScriptCat
7+
// @match https://content-security-policy.com/*
8+
// @grant GM_setValue
9+
// @unwrap
10+
// ==/UserScript==
11+
12+
var __unwrap_e2e_global_var = "unwrap_success";
13+
14+
(function () {
15+
"use strict";
16+
17+
let testResults = {
18+
passed: 0,
19+
failed: 0,
20+
total: 0,
21+
};
22+
23+
function test(name, fn) {
24+
testResults.total++;
25+
try {
26+
fn();
27+
testResults.passed++;
28+
console.log("%c✓ " + name, "color: green;");
29+
return true;
30+
} catch (error) {
31+
testResults.failed++;
32+
console.error("%c✗ " + name, "color: red;", error);
33+
return false;
34+
}
35+
}
36+
37+
function assert(expected, actual, message) {
38+
if (expected !== actual) {
39+
var valueInfo = "期望 " + JSON.stringify(expected) + ", 实际 " + JSON.stringify(actual);
40+
var error = message ? message + " - " + valueInfo : "断言失败: " + valueInfo;
41+
throw new Error(error);
42+
}
43+
}
44+
45+
// ============ @unwrap 测试 ============
46+
console.log("%c=== @unwrap E2E 测试开始 ===", "color: blue; font-size: 16px; font-weight: bold;");
47+
48+
// 测试1: GM API 在 unwrap 模式下为 undefined
49+
test("GM 对象在 unwrap 模式下为 undefined", function () {
50+
assert("undefined", typeof GM, "GM 应为 undefined");
51+
});
52+
53+
test("GM_setValue 在 unwrap 模式下为 undefined", function () {
54+
assert("undefined", typeof GM_setValue, "GM_setValue 应为 undefined");
55+
});
56+
57+
// 测试2: 脚本代码在页面全局作用域执行
58+
test("全局变量可在页面作用域访问", function () {
59+
assert("unwrap_success", window.__unwrap_e2e_global_var, "全局变量应可访问");
60+
});
61+
62+
// ============ 测试总结 ============
63+
console.log("\n%c=== 测试结果总结 ===", "color: blue; font-size: 16px; font-weight: bold;");
64+
console.log("总测试数: " + testResults.total);
65+
console.log("%c通过: " + testResults.passed, "color: green; font-weight: bold;");
66+
console.log("%c失败: " + testResults.failed, "color: red; font-weight: bold;");
67+
})();

example/tests/unwrap_test.js

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// ==UserScript==
2+
// @name A Scriptlet for @unwrap test
3+
// @namespace none
4+
// @version 2026-02-07
5+
// @description try to take over the world!
6+
// @author You
7+
// @match https://*/*?test_unwrap*
8+
// @exclude /test_\w+_excluded/
9+
// @grant GM_setValue
10+
// @require https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js#sha384-vtXRMe3mGCbOeY7l30aIg8H9p3GdeSe4IFlP6G8JMa7o7lXvnz3GFKzPxzJdPfGK
11+
// @unwrap
12+
// ==/UserScript==
13+
14+
// include: https://example.com/?test_unwrap_123
15+
// exclude: https://example.com/?test_unwrap_excluded
16+
17+
var test_global_injection = "success";
18+
// User can access the variable "test_global_injection" directly in DevTools
19+
20+
(function () {
21+
const results = {
22+
GM: {
23+
expected: "undefined",
24+
actual: typeof GM,
25+
},
26+
GM_setValue: {
27+
expected: "undefined",
28+
actual: typeof GM_setValue,
29+
},
30+
jQuery: {
31+
expected: "function",
32+
actual: typeof jQuery,
33+
},
34+
};
35+
36+
console.group(
37+
"%c@unwrap Test",
38+
"color:#0aa;font-weight:bold"
39+
);
40+
41+
const table = {};
42+
let allPass = true;
43+
44+
for (const key in results) {
45+
const { expected, actual } = results[key];
46+
const pass = expected === actual;
47+
allPass &&= pass;
48+
49+
table[key] = {
50+
Expected: expected,
51+
Actual: actual,
52+
Result: pass ? "✅ PASS" : "❌ FAIL",
53+
};
54+
}
55+
56+
console.table(table);
57+
58+
console.log(
59+
allPass
60+
? "%cAll tests passed ✔"
61+
: "%cSome tests failed ✘",
62+
`font-weight:bold;color:${allPass ? "green" : "red"}`
63+
);
64+
65+
console.groupEnd();
66+
})();

src/app/service/content/utils.test.ts

Lines changed: 144 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2-
import { compileScriptCode, compileScript, compileInjectScript, addStyle, addStyleSheet } from "./utils";
2+
import {
3+
compileScriptCode,
4+
compileScript,
5+
compileInjectScript,
6+
compileScriptletCode,
7+
isScriptletUnwrap,
8+
addStyle,
9+
addStyleSheet,
10+
} from "./utils";
311
import type { ScriptRunResource } from "@App/app/repo/scripts";
412
import type { ScriptFunc } from "./types";
13+
import { RuleType, type URLRuleEntry } from "@App/pkg/utils/url_matcher";
514

615
// 设置 console mock 来避免测试输出污染
716
vi.spyOn(console, "error").mockImplementation(() => {});
@@ -518,4 +527,138 @@ describe("utils", () => {
518527
expect((returnedSheet as any).cssText).toBe(css);
519528
});
520529
});
530+
531+
describe.concurrent("isScriptletUnwrap", () => {
532+
it.concurrent("@unwrap 为空值时返回 true", () => {
533+
expect(isScriptletUnwrap({ unwrap: [""] })).toBe(true);
534+
});
535+
536+
it.concurrent("@unwrap 为 true 时返回 true", () => {
537+
expect(isScriptletUnwrap({ unwrap: ["true"] })).toBe(true);
538+
});
539+
540+
it.concurrent("没有 @unwrap 时返回 false", () => {
541+
expect(isScriptletUnwrap({})).toBe(false);
542+
});
543+
544+
it.concurrent("@unwrap 为 false 时返回 false", () => {
545+
expect(isScriptletUnwrap({ unwrap: ["false"] })).toBe(false);
546+
});
547+
});
548+
549+
describe.concurrent("compileScriptletCode", () => {
550+
const createMockScriptRes = (
551+
overrides: Partial<ScriptRunResource> = {},
552+
scriptUrlPatterns: URLRuleEntry[] = []
553+
): { scriptRes: ScriptRunResource; scriptUrlPatterns: URLRuleEntry[] } => ({
554+
scriptRes: {
555+
uuid: "test-uuid",
556+
name: "Unwrap Script",
557+
namespace: "test.namespace",
558+
type: 1,
559+
status: 1,
560+
sort: 0,
561+
runStatus: "complete",
562+
createtime: Date.now(),
563+
checktime: Date.now(),
564+
code: "console.log('unwrap');",
565+
value: {},
566+
flag: "test-flag",
567+
resource: {},
568+
metadata: { unwrap: [""] },
569+
originalMetadata: {},
570+
...overrides,
571+
},
572+
scriptUrlPatterns,
573+
});
574+
575+
it.concurrent("应该正确编译基本 unwrap 脚本", () => {
576+
const patterns: URLRuleEntry[] = [
577+
{
578+
ruleType: RuleType.MATCH_INCLUDE,
579+
ruleContent: ["https", "example.com", "*"],
580+
ruleTag: "match",
581+
patternString: "https://example.com/*",
582+
},
583+
];
584+
const { scriptRes } = createMockScriptRes({}, patterns);
585+
586+
const result = compileScriptletCode(scriptRes, scriptRes.code, patterns);
587+
588+
// 包含脚本代码
589+
expect(result).toContain("console.log('unwrap');");
590+
// 包含 sourceURL
591+
expect(result).toContain("sourceURL=");
592+
expect(chrome.runtime.getURL).toHaveBeenCalledWith("/Unwrap%20Script.user.js");
593+
// 包含 flag 注册
594+
expect(result).toContain("window['test-flag']=function(){};");
595+
// 包含 URL 条件包裹 (if(...){...})
596+
expect(result).toMatch(/^if\(/);
597+
// 不包含沙箱封装
598+
expect(result).not.toContain("with(arguments[0]||this.$)");
599+
expect(result).not.toContain("return(async function(){");
600+
});
601+
602+
it.concurrent("应该包含 require 资源", () => {
603+
const patterns: URLRuleEntry[] = [
604+
{
605+
ruleType: RuleType.MATCH_INCLUDE,
606+
ruleContent: ["*", "example.com", "*"],
607+
ruleTag: "match",
608+
patternString: "*://example.com/*",
609+
},
610+
];
611+
const { scriptRes } = createMockScriptRes(
612+
{
613+
metadata: {
614+
unwrap: [""],
615+
require: ["https://cdn.example.com/lib.js"],
616+
},
617+
resource: {
618+
"https://cdn.example.com/lib.js": {
619+
url: "https://cdn.example.com/lib.js",
620+
content: "var libLoaded = true;",
621+
base64: "",
622+
hash: { md5: "t", sha1: "t", sha256: "t", sha384: "t", sha512: "t" },
623+
type: "require",
624+
link: {},
625+
contentType: "text/javascript",
626+
createtime: Date.now(),
627+
},
628+
},
629+
},
630+
patterns
631+
);
632+
633+
const result = compileScriptletCode(scriptRes, scriptRes.code, patterns);
634+
635+
expect(result).toContain("var libLoaded = true;");
636+
expect(result).toContain("console.log('unwrap');");
637+
});
638+
639+
it.concurrent("应该包含 URL 条件检查代码", () => {
640+
const patterns: URLRuleEntry[] = [
641+
{
642+
ruleType: RuleType.MATCH_INCLUDE,
643+
ruleContent: ["https", "example.com", "*"],
644+
ruleTag: "match",
645+
patternString: "https://example.com/*",
646+
},
647+
{
648+
ruleType: RuleType.MATCH_EXCLUDE,
649+
ruleContent: ["https", "example.com", "admin/*"],
650+
ruleTag: "match",
651+
patternString: "https://example.com/admin/*",
652+
},
653+
];
654+
const { scriptRes } = createMockScriptRes({}, patterns);
655+
656+
const result = compileScriptletCode(scriptRes, scriptRes.code, patterns);
657+
658+
// 生成的代码应包含 embeddedPatternChecker 调用
659+
expect(result).toContain("location.href");
660+
// if 条件包裹
661+
expect(result).toMatch(/^if\(/);
662+
});
663+
});
521664
});

src/app/service/content/utils.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { ScriptLoadInfo } from "../service_worker/types";
44
import { DefinedFlags } from "../service_worker/runtime.consts";
55
import { sourceMapTo } from "@App/pkg/utils/utils";
66
import { ScriptEnvTag } from "@Packages/message/consts";
7+
import { embeddedPatternCheckerString, type EmbeddedURLRuleEntry, type URLRuleEntry } from "@App/pkg/utils/url_matcher";
78

89
export type CompileScriptCodeResource = {
910
name: string;
@@ -66,6 +67,31 @@ export function getScriptRequire(scriptRes: ScriptRunResource): CompileScriptCod
6667
return resourceArray;
6768
}
6869

70+
/**
71+
* 构建unwrap脚本运行代码
72+
* @see {@link ExecScript}
73+
* @param scriptRes
74+
* @param scriptCode
75+
* @returns
76+
*/
77+
export function compileScriptletCode(
78+
scriptRes: ScriptRunResource,
79+
scriptCode: string,
80+
scriptUrlPatterns: URLRuleEntry[]
81+
): string {
82+
scriptCode = scriptCode ?? scriptRes.code;
83+
const requireArray = getScriptRequire(scriptRes);
84+
const requireCode = requireArray.map((r) => r.content).join("\n;");
85+
// 在window[flag]注册一个空脚本让原本的脚本管理器知道并记录脚本成功执行
86+
const reducedPatterns = scriptUrlPatterns.map(({ ruleType, ruleContent }) => ({
87+
ruleType,
88+
ruleContent,
89+
})) satisfies EmbeddedURLRuleEntry[];
90+
const urlCondition = embeddedPatternCheckerString("location.href", JSON.stringify(reducedPatterns));
91+
const codeBody = `if(${urlCondition}){\n${requireCode}\n${scriptCode}\nwindow['${scriptRes.flag}']=function(){};\n}`;
92+
return `${codeBody}${sourceMapTo(`${scriptRes.name}.user.js`)}\n`;
93+
}
94+
6995
/**
7096
* 构建脚本运行代码
7197
* @see {@link ExecScript}
@@ -241,6 +267,10 @@ export function isEarlyStartScript(metadata: SCMetadata): boolean {
241267
return metadataBlankOrTrue(metadata, "early-start") && metadata["run-at"]?.[0] === "document-start";
242268
}
243269

270+
export function isScriptletUnwrap(metadata: SCMetadata): boolean {
271+
return metadataBlankOrTrue(metadata, "unwrap");
272+
}
273+
244274
export function isInjectIntoContent(metadata: SCMetadata): boolean {
245275
return metadata["inject-into"]?.[0] === "content";
246276
}

src/app/service/service_worker/runtime.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,10 @@ import type { CompileScriptCodeResource } from "../content/utils";
3232
import {
3333
compileInjectScriptByFlag,
3434
compileScriptCodeByResource,
35+
compileScriptletCode,
3536
isEarlyStartScript,
3637
isInjectIntoContent,
38+
isScriptletUnwrap,
3739
trimScriptInfo,
3840
} from "../content/utils";
3941
import LoggerCore from "@App/app/logger/core";
@@ -755,9 +757,15 @@ export class RuntimeService {
755757

756758
// 从CompiledResource中还原脚本代码
757759
async restoreJSCodeFromCompiledResource(script: Script, result: CompiledResource) {
758-
const earlyScript = isEarlyStartScript(script.metadata);
760+
// 如果是 Scriptlet (unwrap) 脚本,需要另外的处理方式
761+
if (isScriptletUnwrap(script.metadata)) {
762+
const scriptRes = await this.script.buildScriptRunResource(script);
763+
if (!scriptRes) return "";
764+
return compileScriptletCode(scriptRes, scriptRes.code, result.scriptUrlPatterns);
765+
}
766+
759767
// 如果是预加载脚本,需要另外的处理方式
760-
if (earlyScript) {
768+
if (isEarlyStartScript(script.metadata)) {
761769
const scriptRes = await this.script.buildScriptRunResource(script);
762770
if (!scriptRes) return "";
763771
return compileInjectionCode(scriptRes, scriptRes.code, result.scriptUrlPatterns);

0 commit comments

Comments
 (0)