Skip to content

Commit 5d5c3d9

Browse files
authored
✅ 添加 Playwright E2E 测试及 GM API 功能测试 (#1283)
* ✅ 添加 Playwright E2E 测试 - 新增 22 个 E2E 测试覆盖 Options、Popup、Install、Editor、Settings 页面 - 配置 Playwright 使用 --headless=new 模式加载扩展 - 在 CI workflow 中添加 E2E 测试 job * ✅ 添加 GM API E2E 测试 新增 gm-api.spec.ts 测试三类 GM API: - GM_ 同步 API (gm_api_test.js): 29 项测试 - GM.* 异步 API (gm_api_async_test.js): 29 项测试 - Content 注入测试 (inject_content_test.js): 11 项测试 实现要点: - 两阶段浏览器启动:Phase 1 启用 userScriptsAccess,Phase 2 重启运行测试 - 自动审批权限确认弹窗(cookie 等需要用户授权的 API) - 通过剪贴板注入脚本代码到 Monaco 编辑器 - 替换 jsdelivr CDN 为 unpkg 提升资源加载速度 - 去除 @require/@resource 的 SRI hash 避免校验失败 更新 utils.ts 中 installScriptByCode 增加保存失败的 fallback 检测 * 🐛 修复 GM API E2E 测试 CI 兼容性 - Phase 1 添加 --headless=new 参数,修复 CI 无 X server 环境 - 添加 eslint-disable 注释消除 Playwright use() 的误报 - prettier 格式化修正 * 🐛 修复 E2E 测试 CI 兼容性问题 - vitest.config.ts: 排除 e2e/ 目录避免 Vitest 误跑 Playwright 测试 - eslint.config.mjs: 为 e2e/ 目录关闭 react-hooks/rules-of-hooks 规则 - e2e/options.spec.ts: 菜单正则加 /i 标志修复英文环境大小写匹配 - prettier 格式化修正
1 parent 30bf746 commit 5d5c3d9

16 files changed

Lines changed: 847 additions & 1 deletion

.github/workflows/test.yaml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,31 @@ jobs:
4949
5050
- name: Upload coverage reports to Codecov with GitHub Action
5151
uses: codecov/codecov-action@v5
52+
53+
e2e:
54+
runs-on: ubuntu-latest
55+
name: Run E2E tests
56+
steps:
57+
- uses: actions/checkout@v4
58+
- uses: pnpm/action-setup@v4
59+
60+
- name: Use Node.js
61+
uses: actions/setup-node@v4
62+
with:
63+
node-version: 22
64+
cache: 'pnpm'
65+
66+
- name: Setup pnpm
67+
run: corepack enable
68+
69+
- name: Install dependencies
70+
run: pnpm i --frozen-lockfile
71+
72+
- name: Install Playwright Chromium
73+
run: npx playwright install chromium
74+
75+
- name: Build extension
76+
run: pnpm build
77+
78+
- name: Run E2E tests
79+
run: pnpm test:e2e

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,6 @@ yarn.lock
3636
.claude
3737

3838
CLAUDE.md
39+
40+
test-results
41+
playwright-report

e2e/fixtures.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { test as base, chromium, type BrowserContext } from "@playwright/test";
2+
import path from "path";
3+
4+
export const test = base.extend<{
5+
context: BrowserContext;
6+
extensionId: string;
7+
}>({
8+
// eslint-disable-next-line no-empty-pattern
9+
context: async ({}, use) => {
10+
const pathToExtension = path.resolve(__dirname, "../dist/ext");
11+
const context = await chromium.launchPersistentContext("", {
12+
headless: false,
13+
args: ["--headless=new", `--disable-extensions-except=${pathToExtension}`, `--load-extension=${pathToExtension}`],
14+
});
15+
await use(context);
16+
await context.close();
17+
},
18+
extensionId: async ({ context }, use) => {
19+
let [background] = context.serviceWorkers();
20+
if (!background) {
21+
background = await context.waitForEvent("serviceworker");
22+
}
23+
const extensionId = background.url().split("/")[2];
24+
25+
// Dismiss the first-use guide by navigating to the options page and setting localStorage,
26+
// then reload to apply the change before any tests run.
27+
const initPage = await context.newPage();
28+
await initPage.goto(`chrome-extension://${extensionId}/src/options.html`);
29+
await initPage.waitForLoadState("domcontentloaded");
30+
await initPage.evaluate(() => {
31+
localStorage.setItem("firstUse", "false");
32+
});
33+
await initPage.close();
34+
35+
await use(extensionId);
36+
},
37+
});
38+
39+
export const expect = test.expect;

e2e/gm-api.spec.ts

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import fs from "fs";
2+
import path from "path";
3+
import os from "os";
4+
import { test as base, expect, chromium, type BrowserContext } from "@playwright/test";
5+
import { installScriptByCode } from "./utils";
6+
7+
const test = base.extend<{
8+
context: BrowserContext;
9+
extensionId: string;
10+
}>({
11+
// eslint-disable-next-line no-empty-pattern
12+
context: async ({}, use) => {
13+
const pathToExtension = path.resolve(__dirname, "../dist/ext");
14+
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "pw-ext-"));
15+
const chromeArgs = [`--disable-extensions-except=${pathToExtension}`, `--load-extension=${pathToExtension}`];
16+
17+
// Phase 1: Enable user scripts permission
18+
const ctx1 = await chromium.launchPersistentContext(userDataDir, {
19+
headless: false,
20+
args: ["--headless=new", ...chromeArgs],
21+
});
22+
let [bg] = ctx1.serviceWorkers();
23+
if (!bg) bg = await ctx1.waitForEvent("serviceworker");
24+
const extensionId = bg.url().split("/")[2];
25+
const extPage = await ctx1.newPage();
26+
await extPage.goto("chrome://extensions/");
27+
await extPage.waitForLoadState("domcontentloaded");
28+
await extPage.waitForTimeout(1_000);
29+
await extPage.evaluate(async (id) => {
30+
await (chrome as any).developerPrivate.updateExtensionConfiguration({
31+
extensionId: id,
32+
userScriptsAccess: true,
33+
});
34+
}, extensionId);
35+
await extPage.close();
36+
await ctx1.close();
37+
38+
// Phase 2: Relaunch with user scripts enabled
39+
const context = await chromium.launchPersistentContext(userDataDir, {
40+
headless: false,
41+
args: ["--headless=new", ...chromeArgs],
42+
});
43+
await use(context);
44+
await context.close();
45+
fs.rmSync(userDataDir, { recursive: true, force: true });
46+
},
47+
extensionId: async ({ context }, use) => {
48+
let [background] = context.serviceWorkers();
49+
if (!background) background = await context.waitForEvent("serviceworker");
50+
const extensionId = background.url().split("/")[2];
51+
const initPage = await context.newPage();
52+
await initPage.goto(`chrome-extension://${extensionId}/src/options.html`);
53+
await initPage.waitForLoadState("domcontentloaded");
54+
await initPage.evaluate(() => localStorage.setItem("firstUse", "false"));
55+
await initPage.close();
56+
await use(extensionId);
57+
},
58+
});
59+
60+
/** Strip SRI hashes and replace slow CDN with faster alternative */
61+
function patchScriptCode(code: string): string {
62+
return code
63+
.replace(/^(\/\/\s*@(?:require|resource)\s+.*?)#sha(?:256|384|512)[=-][^\s]+/gm, "$1")
64+
.replace(/https:\/\/cdn\.jsdelivr\.net\/npm\//g, "https://unpkg.com/");
65+
}
66+
67+
/**
68+
* Auto-approve permission confirm dialogs opened by the extension.
69+
* Listens for new pages matching confirm.html and clicks the
70+
* "permanent allow all" button (type=4, allow=true).
71+
*/
72+
function autoApprovePermissions(context: BrowserContext): void {
73+
context.on("page", async (page) => {
74+
const url = page.url();
75+
if (!url.includes("confirm.html")) return;
76+
77+
try {
78+
await page.waitForLoadState("domcontentloaded");
79+
// Click the "permanent allow" button (4th success button = type=5 permanent allow this)
80+
// The buttons in order are: allow_once(1), temporary_allow(3), permanent_allow(5)
81+
// We want "permanent_allow" which is the 3rd success button
82+
const successButtons = page.locator("button.arco-btn-status-success");
83+
await successButtons.first().waitFor({ timeout: 5_000 });
84+
// Find and click the last always-visible success button (permanent_allow, type=5)
85+
// Button order: allow_once(type=1), temporary_allow(type=3), permanent_allow(type=5)
86+
// Index 2 = permanent_allow (always visible)
87+
const count = await successButtons.count();
88+
if (count >= 3) {
89+
// permanent_allow is at index 2
90+
await successButtons.nth(2).click();
91+
} else {
92+
// Fallback: click the last visible success button
93+
await successButtons.last().click();
94+
}
95+
console.log("[autoApprove] Permission approved on confirm page");
96+
} catch (e) {
97+
console.log("[autoApprove] Failed to approve:", e);
98+
}
99+
});
100+
}
101+
102+
/** Run a test script on the target page and collect console results */
103+
async function runTestScript(
104+
context: BrowserContext,
105+
extensionId: string,
106+
scriptFile: string,
107+
targetUrl: string,
108+
timeoutMs: number
109+
): Promise<{ passed: number; failed: number; logs: string[] }> {
110+
let code = fs.readFileSync(path.join(__dirname, `../example/tests/${scriptFile}`), "utf-8");
111+
code = patchScriptCode(code);
112+
113+
await installScriptByCode(context, extensionId, code);
114+
115+
// Start auto-approving permission dialogs
116+
autoApprovePermissions(context);
117+
118+
const page = await context.newPage();
119+
const logs: string[] = [];
120+
page.on("console", (msg) => logs.push(msg.text()));
121+
122+
await page.goto(targetUrl, { waitUntil: "domcontentloaded" });
123+
124+
// Wait for test results to appear in console
125+
const deadline = Date.now() + timeoutMs;
126+
let passed = -1;
127+
let failed = -1;
128+
while (Date.now() < deadline) {
129+
for (const log of logs) {
130+
const passMatch = log.match(/[:]\s*(\d+)/);
131+
const failMatch = log.match(/[:]\s*(\d+)/);
132+
if (passMatch) passed = parseInt(passMatch[1], 10);
133+
if (failMatch) failed = parseInt(failMatch[1], 10);
134+
}
135+
if (passed >= 0 && failed >= 0) break;
136+
await page.waitForTimeout(500);
137+
}
138+
139+
await page.close();
140+
return { passed, failed, logs };
141+
}
142+
143+
const TARGET_URL = "https://content-security-policy.com/";
144+
145+
test.describe("GM API", () => {
146+
// Two-phase launch + script install + network fetches + permission dialogs
147+
test.setTimeout(300_000);
148+
149+
test("GM_ sync API tests (gm_api_test.js)", async ({ context, extensionId }) => {
150+
const { passed, failed, logs } = await runTestScript(context, extensionId, "gm_api_test.js", TARGET_URL, 90_000);
151+
152+
console.log(`[gm_api_test] passed=${passed}, failed=${failed}`);
153+
if (failed !== 0) {
154+
console.log("[gm_api_test] logs:", logs.join("\n"));
155+
}
156+
expect(failed, "Some GM_ sync API tests failed").toBe(0);
157+
expect(passed, "No test results found - script may not have run").toBeGreaterThan(0);
158+
});
159+
160+
test("GM.* async API tests (gm_api_async_test.js)", async ({ context, extensionId }) => {
161+
const { passed, failed, logs } = await runTestScript(
162+
context,
163+
extensionId,
164+
"gm_api_async_test.js",
165+
TARGET_URL,
166+
90_000
167+
);
168+
169+
console.log(`[gm_api_async_test] passed=${passed}, failed=${failed}`);
170+
if (failed !== 0) {
171+
console.log("[gm_api_async_test] logs:", logs.join("\n"));
172+
}
173+
expect(failed, "Some GM.* async API tests failed").toBe(0);
174+
expect(passed, "No test results found - script may not have run").toBeGreaterThan(0);
175+
});
176+
177+
test("Content inject tests (inject_content_test.js)", async ({ context, extensionId }) => {
178+
const { passed, failed, logs } = await runTestScript(
179+
context,
180+
extensionId,
181+
"inject_content_test.js",
182+
TARGET_URL,
183+
60_000
184+
);
185+
186+
console.log(`[inject_content_test] passed=${passed}, failed=${failed}`);
187+
if (failed !== 0) {
188+
console.log("[inject_content_test] logs:", logs.join("\n"));
189+
}
190+
expect(failed, "Some content inject tests failed").toBe(0);
191+
expect(passed, "No test results found - script may not have run").toBeGreaterThan(0);
192+
});
193+
});

e2e/install.spec.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { test, expect } from "./fixtures";
2+
import { openInstallPage } from "./utils";
3+
4+
test.describe("Install Page", () => {
5+
// Use a well-known public userscript URL for testing
6+
const testScriptUrl =
7+
"https://raw.githubusercontent.com/nicedayzhu/userscripts/refs/heads/master/hello-world.user.js";
8+
9+
test("should open install page with URL parameter", async ({ context, extensionId }) => {
10+
const page = await openInstallPage(context, extensionId, testScriptUrl);
11+
12+
// The page should load without errors
13+
await expect(page).toHaveTitle(/Install.*ScriptCat|ScriptCat/i);
14+
});
15+
16+
test("should display script metadata when loading a script", async ({ context, extensionId }) => {
17+
const page = await openInstallPage(context, extensionId, testScriptUrl);
18+
19+
// Wait for the script to be fetched and metadata to be displayed
20+
// The install page shows script name, version, description, etc.
21+
// Wait for either the metadata to load or an error message
22+
await page.waitForTimeout(5000);
23+
24+
// Check that the page has loaded content (not just blank)
25+
const body = page.locator("body");
26+
const text = await body.innerText();
27+
expect(text.length).toBeGreaterThan(0);
28+
});
29+
});

0 commit comments

Comments
 (0)