From 00dff2e9a2da9c8a1d8d216b6ed767d509bafe44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Thu, 12 Mar 2026 11:33:25 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=85=20=E6=B7=BB=E5=8A=A0=20Playwright?= =?UTF-8?q?=20E2E=20=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 22 个 E2E 测试覆盖 Options、Popup、Install、Editor、Settings 页面 - 配置 Playwright 使用 --headless=new 模式加载扩展 - 在 CI workflow 中添加 E2E 测试 job --- .github/workflows/test.yaml | 28 ++++++++ .gitignore | 3 + e2e/fixtures.ts | 43 ++++++++++++ e2e/install.spec.ts | 32 +++++++++ e2e/options.spec.ts | 89 +++++++++++++++++++++++++ e2e/popup.spec.ts | 76 +++++++++++++++++++++ e2e/script-editor.spec.ts | 72 ++++++++++++++++++++ e2e/script-management.spec.ts | 122 ++++++++++++++++++++++++++++++++++ e2e/settings.spec.ts | 40 +++++++++++ e2e/utils.ts | 63 ++++++++++++++++++ package.json | 5 +- playwright.config.ts | 18 +++++ pnpm-lock.yaml | 38 +++++++++++ 13 files changed, 628 insertions(+), 1 deletion(-) create mode 100644 e2e/fixtures.ts create mode 100644 e2e/install.spec.ts create mode 100644 e2e/options.spec.ts create mode 100644 e2e/popup.spec.ts create mode 100644 e2e/script-editor.spec.ts create mode 100644 e2e/script-management.spec.ts create mode 100644 e2e/settings.spec.ts create mode 100644 e2e/utils.ts create mode 100644 playwright.config.ts diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 821429270..fa547a387 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -49,3 +49,31 @@ jobs: - name: Upload coverage reports to Codecov with GitHub Action uses: codecov/codecov-action@v5 + + e2e: + runs-on: ubuntu-latest + name: Run E2E tests + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Setup pnpm + run: corepack enable + + - name: Install dependencies + run: pnpm i --frozen-lockfile + + - name: Install Playwright Chromium + run: npx playwright install chromium + + - name: Build extension + run: pnpm build + + - name: Run E2E tests + run: pnpm test:e2e diff --git a/.gitignore b/.gitignore index 7a344bb73..bfb41a63c 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,6 @@ yarn.lock .claude CLAUDE.md + +test-results +playwright-report diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts new file mode 100644 index 000000000..291fba5ba --- /dev/null +++ b/e2e/fixtures.ts @@ -0,0 +1,43 @@ +import { test as base, chromium, type BrowserContext } from "@playwright/test"; +import path from "path"; + +export const test = base.extend<{ + context: BrowserContext; + extensionId: string; +}>({ + // eslint-disable-next-line no-empty-pattern + context: async ({}, use) => { + const pathToExtension = path.resolve(__dirname, "../dist/ext"); + const context = await chromium.launchPersistentContext("", { + headless: false, + args: [ + "--headless=new", + `--disable-extensions-except=${pathToExtension}`, + `--load-extension=${pathToExtension}`, + ], + }); + await use(context); + await context.close(); + }, + extensionId: async ({ context }, use) => { + let [background] = context.serviceWorkers(); + if (!background) { + background = await context.waitForEvent("serviceworker"); + } + const extensionId = background.url().split("/")[2]; + + // Dismiss the first-use guide by navigating to the options page and setting localStorage, + // then reload to apply the change before any tests run. + const initPage = await context.newPage(); + await initPage.goto(`chrome-extension://${extensionId}/src/options.html`); + await initPage.waitForLoadState("domcontentloaded"); + await initPage.evaluate(() => { + localStorage.setItem("firstUse", "false"); + }); + await initPage.close(); + + await use(extensionId); + }, +}); + +export const expect = test.expect; diff --git a/e2e/install.spec.ts b/e2e/install.spec.ts new file mode 100644 index 000000000..017019552 --- /dev/null +++ b/e2e/install.spec.ts @@ -0,0 +1,32 @@ +import { test, expect } from "./fixtures"; +import { openInstallPage } from "./utils"; + +test.describe("Install Page", () => { + // Use a well-known public userscript URL for testing + const testScriptUrl = + "https://raw.githubusercontent.com/nicedayzhu/userscripts/refs/heads/master/hello-world.user.js"; + + test("should open install page with URL parameter", async ({ context, extensionId }) => { + const page = await openInstallPage(context, extensionId, testScriptUrl); + + // The page should load without errors + await expect(page).toHaveTitle(/Install.*ScriptCat|ScriptCat/i); + }); + + test("should display script metadata when loading a script", async ({ + context, + extensionId, + }) => { + const page = await openInstallPage(context, extensionId, testScriptUrl); + + // Wait for the script to be fetched and metadata to be displayed + // The install page shows script name, version, description, etc. + // Wait for either the metadata to load or an error message + await page.waitForTimeout(5000); + + // Check that the page has loaded content (not just blank) + const body = page.locator("body"); + const text = await body.innerText(); + expect(text.length).toBeGreaterThan(0); + }); +}); diff --git a/e2e/options.spec.ts b/e2e/options.spec.ts new file mode 100644 index 000000000..d7cd446b3 --- /dev/null +++ b/e2e/options.spec.ts @@ -0,0 +1,89 @@ +import { test, expect } from "./fixtures"; +import { openOptionsPage } from "./utils"; + +test.describe("Options Page", () => { + test("should load and display ScriptCat title and logo", async ({ context, extensionId }) => { + const page = await openOptionsPage(context, extensionId); + + // Check logo is visible + const logo = page.locator('img[alt="ScriptCat"]'); + await expect(logo).toBeVisible(); + + // Check title text + await expect(page.getByText("ScriptCat", { exact: true })).toBeVisible(); + }); + + test("should navigate via sidebar menu items", async ({ context, extensionId }) => { + const page = await openOptionsPage(context, extensionId); + + // Wait for the sidebar menu to be visible (use first() since there are two menus) + await expect(page.locator(".arco-menu").first()).toBeVisible(); + + // Click "Subscribe" / "订阅" menu item and verify route change + await page.locator(".arco-menu-item").filter({ hasText: /subscribe|订阅/ }).first().click(); + await expect(page).toHaveURL(/.*#\/subscribe/); + + // Click "Logs" / "日志" menu item + await page.locator(".arco-menu-item").filter({ hasText: /log|日志/ }).first().click(); + await expect(page).toHaveURL(/.*#\/logger/); + + // Click "Tools" / "工具" menu item + await page.locator(".arco-menu-item").filter({ hasText: /tool|工具/ }).first().click(); + await expect(page).toHaveURL(/.*#\/tools/); + + // Click "Settings" / "设置" menu item + await page.locator(".arco-menu-item").filter({ hasText: /setting|设置/ }).first().click(); + await expect(page).toHaveURL(/.*#\/setting/); + + // Navigate back to script list (home) - click the first menu item + await page + .locator(".arco-menu-item") + .filter({ hasText: /installed.*script|已安装脚本/ }) + .first() + .click(); + await expect(page).toHaveURL(/.*#\//); + }); + + test("should show theme switch dropdown with light/dark/auto options", async ({ + context, + extensionId, + }) => { + const page = await openOptionsPage(context, extensionId); + + // Find the theme toggle button in the action-tools area (icon-only button) + const actionTools = page.locator(".action-tools"); + const themeButton = actionTools.locator(".arco-btn-icon-only").first(); + await themeButton.click(); + + // Verify dropdown with theme options appears - use role="menuitem" + const menuItems = page.locator('[role="menuitem"]'); + await expect(menuItems.first()).toBeVisible({ timeout: 5000 }); + const count = await menuItems.count(); + expect(count).toBeGreaterThanOrEqual(3); + }); + + test("should show create script dropdown menu", async ({ context, extensionId }) => { + const page = await openOptionsPage(context, extensionId); + + // The create script button is the first text button in action-tools + const createBtn = page.locator(".action-tools .arco-btn-text").first(); + await createBtn.click(); + + // Verify dropdown menu appears - use role="menuitem" + const menuItems = page.locator('[role="menuitem"]'); + await expect(menuItems.first()).toBeVisible({ timeout: 5000 }); + const count = await menuItems.count(); + expect(count).toBeGreaterThanOrEqual(3); + }); + + test("should show empty state when script list is empty", async ({ context, extensionId }) => { + const page = await openOptionsPage(context, extensionId); + + // Wait for the content area to load + await page.waitForTimeout(2000); + + // The empty state component from arco-design should be visible + const emptyState = page.locator(".arco-empty"); + await expect(emptyState).toBeVisible({ timeout: 10_000 }); + }); +}); diff --git a/e2e/popup.spec.ts b/e2e/popup.spec.ts new file mode 100644 index 000000000..d3f4f3a69 --- /dev/null +++ b/e2e/popup.spec.ts @@ -0,0 +1,76 @@ +import { test, expect } from "./fixtures"; +import { openPopupPage } from "./utils"; + +test.describe("Popup Page", () => { + test("should load and display ScriptCat title", async ({ context, extensionId }) => { + const page = await openPopupPage(context, extensionId); + + // The popup should show "ScriptCat" title in the card header + await expect(page.getByText("ScriptCat", { exact: true })).toBeVisible({ timeout: 10_000 }); + }); + + test("should show global script enable/disable switch", async ({ context, extensionId }) => { + const page = await openPopupPage(context, extensionId); + + // The switch for enabling/disabling scripts should be present + const globalSwitch = page.locator(".arco-switch").first(); + await expect(globalSwitch).toBeVisible({ timeout: 10_000 }); + }); + + test("should render Collapse sections for scripts", async ({ context, extensionId }) => { + const page = await openPopupPage(context, extensionId); + + // Wait for the collapse component to render + const collapse = page.locator(".arco-collapse"); + await expect(collapse).toBeVisible({ timeout: 10_000 }); + + // Should have at least one collapse item (current page scripts) + const collapseItems = page.locator(".arco-collapse-item"); + const count = await collapseItems.count(); + expect(count).toBeGreaterThanOrEqual(1); + }); + + test("should have settings button that works", async ({ context, extensionId }) => { + const page = await openPopupPage(context, extensionId); + + // Wait for the popup to fully load + await expect(page.getByText("ScriptCat", { exact: true })).toBeVisible({ timeout: 10_000 }); + + // Find the settings button - it's an icon-only button in the header + // The order is: Switch, Settings, Notification, MoreMenu + const iconButtons = page.locator(".arco-btn-icon-only"); + // Settings is the first icon-only button + const settingsBtn = iconButtons.first(); + await expect(settingsBtn).toBeVisible(); + + // Click the settings button - it should open a new page + const [newPage] = await Promise.all([ + context.waitForEvent("page"), + settingsBtn.click(), + ]); + + // The new page should be the options page + await expect(newPage).toHaveURL(/options\.html/); + }); + + test("should show more menu dropdown with items", async ({ context, extensionId }) => { + const page = await openPopupPage(context, extensionId); + + // Wait for popup to load + await expect(page.getByText("ScriptCat", { exact: true })).toBeVisible({ timeout: 10_000 }); + + // The more menu button is the last icon-only button + const iconButtons = page.locator(".arco-btn-icon-only"); + const count = await iconButtons.count(); + const moreBtn = iconButtons.nth(count - 1); + await moreBtn.click(); + + // Wait for the dropdown to appear + await page.waitForTimeout(500); + + // The dropdown menu items use role="menuitem" + const menuItems = page.locator('[role="menuitem"]'); + const itemCount = await menuItems.count(); + expect(itemCount).toBeGreaterThanOrEqual(3); + }); +}); diff --git a/e2e/script-editor.spec.ts b/e2e/script-editor.spec.ts new file mode 100644 index 000000000..47216376f --- /dev/null +++ b/e2e/script-editor.spec.ts @@ -0,0 +1,72 @@ +import { test, expect } from "./fixtures"; +import { openEditorPage, openOptionsPage } from "./utils"; + +test.describe("Script Editor", () => { + test("should load editor page with Monaco editor", async ({ context, extensionId }) => { + const page = await openEditorPage(context, extensionId); + + // Wait for Monaco editor to render + const monacoEditor = page.locator(".monaco-editor"); + await expect(monacoEditor).toBeVisible({ timeout: 30_000 }); + }); + + test("should load new user script template", async ({ context, extensionId }) => { + const page = await openEditorPage(context, extensionId); + + // Wait for Monaco editor + const monacoEditor = page.locator(".monaco-editor"); + await expect(monacoEditor).toBeVisible({ timeout: 30_000 }); + + // The editor should contain a UserScript header with default template content + const editorContent = page.locator(".view-lines"); + await expect(editorContent).toContainText("==UserScript==", { timeout: 15_000 }); + }); + + test("should save script and show success message", async ({ context, extensionId }) => { + const page = await openEditorPage(context, extensionId); + + // Wait for Monaco editor to fully load + const monacoEditor = page.locator(".monaco-editor"); + await expect(monacoEditor).toBeVisible({ timeout: 30_000 }); + await expect(page.locator(".view-lines")).toContainText("==UserScript==", { timeout: 15_000 }); + + // Click inside the editor to ensure it has focus + await page.locator(".monaco-editor .view-lines").click(); + await page.waitForTimeout(500); + + // Save the script using Ctrl+S + await page.keyboard.press("ControlOrMeta+s"); + + // After saving, a success message should appear + // Arco Message renders with class "arco-message" containing "arco-message-icon-success" + const successMsg = page.locator(".arco-message"); + await expect(successMsg.first()).toBeVisible({ timeout: 15_000 }); + }); + + test("should show newly created script in the list after saving", async ({ + context, + extensionId, + }) => { + // First create a script via the editor + const editorPage = await openEditorPage(context, extensionId); + + await expect(editorPage.locator(".monaco-editor")).toBeVisible({ timeout: 30_000 }); + await expect(editorPage.locator(".view-lines")).toContainText("==UserScript==", { + timeout: 15_000, + }); + + // Click inside editor to ensure focus, then save + await editorPage.locator(".monaco-editor .view-lines").click(); + await editorPage.waitForTimeout(500); + await editorPage.keyboard.press("ControlOrMeta+s"); + await expect(editorPage.locator(".arco-message").first()).toBeVisible({ timeout: 15_000 }); + + // Now open the options page to check the script list + const listPage = await openOptionsPage(context, extensionId); + await listPage.waitForTimeout(2000); + + // The script list should now contain at least one script entry (no empty state) + const emptyState = listPage.locator(".arco-empty"); + await expect(emptyState).toHaveCount(0); + }); +}); diff --git a/e2e/script-management.spec.ts b/e2e/script-management.spec.ts new file mode 100644 index 000000000..fc065e3ee --- /dev/null +++ b/e2e/script-management.spec.ts @@ -0,0 +1,122 @@ +import { test, expect } from "./fixtures"; +import type { BrowserContext, Page } from "@playwright/test"; +import { openEditorPage, openOptionsPage } from "./utils"; + +/** + * Helper: create a script via the editor, then open the options page. + */ +async function createScriptAndGoToList( + context: BrowserContext, + extensionId: string +): Promise { + const editorPage = await openEditorPage(context, extensionId); + + // Wait for Monaco editor + await expect(editorPage.locator(".monaco-editor")).toBeVisible({ timeout: 30_000 }); + await expect(editorPage.locator(".view-lines")).toContainText("==UserScript==", { + timeout: 15_000, + }); + + // Click inside editor to ensure focus, then save + await editorPage.locator(".monaco-editor .view-lines").click(); + await editorPage.waitForTimeout(500); + await editorPage.keyboard.press("ControlOrMeta+s"); + + // Wait for success message, retry once if needed + try { + await expect(editorPage.locator(".arco-message").first()).toBeVisible({ timeout: 10_000 }); + } catch { + // Retry: click editor again and resave + await editorPage.locator(".monaco-editor .view-lines").click(); + await editorPage.waitForTimeout(500); + await editorPage.keyboard.press("ControlOrMeta+s"); + await expect(editorPage.locator(".arco-message").first()).toBeVisible({ timeout: 15_000 }); + } + + // Open the options page (script list) + const page = await openOptionsPage(context, extensionId); + await page.waitForTimeout(2000); + + return page; +} + +test.describe("Script Management", () => { + test("should create a script and see it in the list", async ({ context, extensionId }) => { + const page = await createScriptAndGoToList(context, extensionId); + + // The script list should have at least one entry (no empty state) + const emptyState = page.locator(".arco-empty"); + await expect(emptyState).toHaveCount(0); + }); + + test("should toggle enable/disable on a script", async ({ context, extensionId }) => { + const page = await createScriptAndGoToList(context, extensionId); + + // Find the switch/toggle in the script list + const scriptSwitch = page.locator(".arco-switch").first(); + await expect(scriptSwitch).toBeVisible({ timeout: 10_000 }); + + // Get initial state + const initialChecked = await scriptSwitch.getAttribute("aria-checked"); + + // Click to toggle + await scriptSwitch.click(); + await page.waitForTimeout(1000); + + // The state should have changed + const newChecked = await scriptSwitch.getAttribute("aria-checked"); + expect(newChecked).not.toBe(initialChecked); + }); + + test("should delete a script", async ({ context, extensionId }) => { + const page = await createScriptAndGoToList(context, extensionId); + + // Right-click on a script row to get context menu + const scriptRow = page + .locator(".arco-table-row, .arco-card-body .arco-list-item, [class*='script']") + .first(); + if (await scriptRow.isVisible()) { + await scriptRow.click({ button: "right" }); + await page.waitForTimeout(500); + + // Look for delete option in context menu + const deleteOption = page.getByText(/delete|删除/i).first(); + if (await deleteOption.isVisible({ timeout: 2000 }).catch(() => false)) { + await deleteOption.click(); + + // Confirm deletion if a modal appears + const confirmBtn = page.locator(".arco-modal .arco-btn-primary"); + if (await confirmBtn.isVisible({ timeout: 2000 }).catch(() => false)) { + await confirmBtn.click(); + } + + await page.waitForTimeout(2000); + + // After deletion, the list should be empty again + const emptyState = page.locator(".arco-empty"); + await expect(emptyState).toBeVisible({ timeout: 10_000 }); + } + } + }); + + test("should search/filter scripts", async ({ context, extensionId }) => { + const page = await createScriptAndGoToList(context, extensionId); + + // Look for a search input + const searchInput = page.locator('input[type="text"], .arco-input').first(); + if (await searchInput.isVisible({ timeout: 3000 }).catch(() => false)) { + // Type a search query that won't match + await searchInput.fill("nonexistent_script_xyz"); + await page.waitForTimeout(1000); + + // The list should show empty or no results + const emptyState = page.locator(".arco-empty"); + await expect(emptyState).toBeVisible({ timeout: 5000 }); + + // Clear search and scripts should reappear + await searchInput.clear(); + await page.waitForTimeout(1000); + await expect(emptyState).toHaveCount(0); + } + }); +}); diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts new file mode 100644 index 000000000..27e26beaf --- /dev/null +++ b/e2e/settings.spec.ts @@ -0,0 +1,40 @@ +import { test, expect } from "./fixtures"; +import { openOptionsPage } from "./utils"; + +test.describe("Settings Page", () => { + test("should render the settings page", async ({ context, extensionId }) => { + const page = await openOptionsPage(context, extensionId); + + // Navigate to settings via hash route + await page.goto(`chrome-extension://${extensionId}/src/options.html#/setting`); + await page.waitForLoadState("domcontentloaded"); + + // Wait for the settings page to render + await page.waitForTimeout(2000); + + // The settings page should have visible content (cards, selects, inputs, etc.) + const content = page.locator(".arco-layout-content"); + await expect(content).toBeVisible(); + }); + + test("should have visible and interactive settings items", async ({ context, extensionId }) => { + const page = await openOptionsPage(context, extensionId); + + // Navigate to settings + await page.goto(`chrome-extension://${extensionId}/src/options.html#/setting`); + await page.waitForLoadState("domcontentloaded"); + await page.waitForTimeout(2000); + + // Check that at least one Select component or Input is visible + const selects = page.locator(".arco-select"); + const inputs = page.locator(".arco-input"); + const checkboxes = page.locator(".arco-checkbox"); + + const selectCount = await selects.count(); + const inputCount = await inputs.count(); + const checkboxCount = await checkboxes.count(); + + // Settings page should have at least some interactive elements + expect(selectCount + inputCount + checkboxCount).toBeGreaterThan(0); + }); +}); diff --git a/e2e/utils.ts b/e2e/utils.ts new file mode 100644 index 000000000..ce6fcfab0 --- /dev/null +++ b/e2e/utils.ts @@ -0,0 +1,63 @@ +import type { BrowserContext, Page } from "@playwright/test"; + +/** Open the options page and wait for it to load */ +export async function openOptionsPage( + context: BrowserContext, + extensionId: string +): Promise { + const page = await context.newPage(); + await page.goto(`chrome-extension://${extensionId}/src/options.html`); + await page.waitForLoadState("domcontentloaded"); + return page; +} + +/** Open the popup page and wait for it to load */ +export async function openPopupPage( + context: BrowserContext, + extensionId: string +): Promise { + const page = await context.newPage(); + await page.goto(`chrome-extension://${extensionId}/src/popup.html`); + await page.waitForLoadState("domcontentloaded"); + return page; +} + +/** Open the install page with a script URL parameter */ +export async function openInstallPage( + context: BrowserContext, + extensionId: string, + scriptUrl: string +): Promise { + const page = await context.newPage(); + await page.goto( + `chrome-extension://${extensionId}/src/install.html?url=${encodeURIComponent(scriptUrl)}` + ); + await page.waitForLoadState("domcontentloaded"); + return page; +} + +/** Open the script editor page */ +export async function openEditorPage( + context: BrowserContext, + extensionId: string, + params?: string +): Promise { + const page = await context.newPage(); + const hash = params ? `#/script/editor?${params}` : "#/script/editor"; + await page.goto(`chrome-extension://${extensionId}/src/options.html${hash}`); + await page.waitForLoadState("domcontentloaded"); + return page; +} + +/** A sample userscript for testing */ +export const sampleUserScript = `// ==UserScript== +// @name E2E Test Script +// @namespace https://e2e.test +// @version 1.0.0 +// @description A test script for E2E testing +// @author E2E Test +// @match https://example.com/* +// ==/UserScript== + +console.log("E2E Test Script loaded"); +`; diff --git a/package.json b/package.json index 217efa93c..5555d0d63 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,9 @@ "lint-fix": "eslint --fix .", "changlog": "node ./scripts/changlog.js", "crowdin": "crowdin", - "crowdin:download": "node ./scripts/crowdin-download.js" + "crowdin:download": "node ./scripts/crowdin-download.js", + "test:e2e": "npx playwright test", + "test:e2e:ui": "npx playwright test --ui" }, "dependencies": { "@arco-design/web-react": "^2.66.7", @@ -57,6 +59,7 @@ "devDependencies": { "@eslint/compat": "^1.4.1", "@eslint/js": "9.39.2", + "@playwright/test": "^1.58.2", "@rspack/cli": "^1.7.6", "@rspack/core": "^1.6.8", "@swc/helpers": "^0.5.17", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 000000000..9d0fe6c61 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + timeout: 60_000, + expect: { + timeout: 10_000, + }, + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: "list", + use: { + actionTimeout: 10_000, + trace: "on-first-retry", + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d80e4503b..d2dab0f5e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,6 +96,9 @@ importers: '@eslint/js': specifier: 9.39.2 version: 9.39.2 + '@playwright/test': + specifier: ^1.58.2 + version: 1.58.2 '@rspack/cli': specifier: ^1.7.6 version: 1.7.6(@rspack/core@1.7.6(@swc/helpers@0.5.17))(@types/express@4.17.25)(tslib@2.8.1)(webpack@5.96.1) @@ -948,6 +951,11 @@ packages: resolution: {integrity: sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.28': resolution: {integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==} @@ -2417,6 +2425,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3255,6 +3268,16 @@ packages: pkg-types@2.2.0: resolution: {integrity: sha512-2SM/GZGAEkPp3KWORxQZns4M+WSeXbC2HEvmOIJe3Cmiv6ieAJvdVhDldtHqM5J1Y7MrR1XhkBT/rMlhh9FdqQ==} + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + popper.js@1.16.1: resolution: {integrity: sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==} deprecated: You can find the new Popper v2 at @popperjs/core, this package is dedicated to the legacy v1 @@ -4903,6 +4926,10 @@ snapshots: '@pkgr/core@0.2.7': {} + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + '@polka/url@1.0.0-next.28': {} '@quansync/fs@0.1.5': @@ -6691,6 +6718,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -7536,6 +7566,14 @@ snapshots: exsolve: 1.0.7 pathe: 2.0.3 + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + popper.js@1.16.1: {} possible-typed-array-names@1.0.0: {} From 9fe45b0183f126759292504c8676e0745dee3f10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Thu, 12 Mar 2026 14:57:31 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9C=85=20=E6=B7=BB=E5=8A=A0=20GM=20API?= =?UTF-8?q?=20E2E=20=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 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 检测 --- e2e/gm-api.spec.ts | 225 +++++++++++++++++++++++++++++++++++++++++++++ e2e/utils.ts | 44 +++++++++ 2 files changed, 269 insertions(+) create mode 100644 e2e/gm-api.spec.ts diff --git a/e2e/gm-api.spec.ts b/e2e/gm-api.spec.ts new file mode 100644 index 000000000..4b0428a56 --- /dev/null +++ b/e2e/gm-api.spec.ts @@ -0,0 +1,225 @@ +import fs from "fs"; +import path from "path"; +import os from "os"; +import { + test as base, + expect, + chromium, + type BrowserContext, +} from "@playwright/test"; +import { installScriptByCode } from "./utils"; + +const test = base.extend<{ + context: BrowserContext; + extensionId: string; +}>({ + // eslint-disable-next-line no-empty-pattern + context: async ({}, use) => { + const pathToExtension = path.resolve(__dirname, "../dist/ext"); + const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "pw-ext-")); + const chromeArgs = [ + `--disable-extensions-except=${pathToExtension}`, + `--load-extension=${pathToExtension}`, + ]; + + // Phase 1: Enable user scripts permission + const ctx1 = await chromium.launchPersistentContext(userDataDir, { + headless: false, + args: chromeArgs, + }); + let [bg] = ctx1.serviceWorkers(); + if (!bg) bg = await ctx1.waitForEvent("serviceworker"); + const extensionId = bg.url().split("/")[2]; + const extPage = await ctx1.newPage(); + await extPage.goto("chrome://extensions/"); + await extPage.waitForLoadState("domcontentloaded"); + await extPage.waitForTimeout(1_000); + await extPage.evaluate(async (id) => { + await (chrome as any).developerPrivate.updateExtensionConfiguration({ + extensionId: id, + userScriptsAccess: true, + }); + }, extensionId); + await extPage.close(); + await ctx1.close(); + + // Phase 2: Relaunch with user scripts enabled + const context = await chromium.launchPersistentContext(userDataDir, { + headless: false, + args: ["--headless=new", ...chromeArgs], + }); + await use(context); + await context.close(); + fs.rmSync(userDataDir, { recursive: true, force: true }); + }, + extensionId: async ({ context }, use) => { + let [background] = context.serviceWorkers(); + if (!background) background = await context.waitForEvent("serviceworker"); + const extensionId = background.url().split("/")[2]; + const initPage = await context.newPage(); + await initPage.goto(`chrome-extension://${extensionId}/src/options.html`); + await initPage.waitForLoadState("domcontentloaded"); + await initPage.evaluate(() => localStorage.setItem("firstUse", "false")); + await initPage.close(); + await use(extensionId); + }, +}); + +/** Strip SRI hashes and replace slow CDN with faster alternative */ +function patchScriptCode(code: string): string { + return code + .replace( + /^(\/\/\s*@(?:require|resource)\s+.*?)#sha(?:256|384|512)[=-][^\s]+/gm, + "$1" + ) + .replace( + /https:\/\/cdn\.jsdelivr\.net\/npm\//g, + "https://unpkg.com/" + ); +} + +/** + * Auto-approve permission confirm dialogs opened by the extension. + * Listens for new pages matching confirm.html and clicks the + * "permanent allow all" button (type=4, allow=true). + */ +function autoApprovePermissions(context: BrowserContext): void { + context.on("page", async (page) => { + const url = page.url(); + if (!url.includes("confirm.html")) return; + + try { + await page.waitForLoadState("domcontentloaded"); + // Click the "permanent allow" button (4th success button = type=5 permanent allow this) + // The buttons in order are: allow_once(1), temporary_allow(3), permanent_allow(5) + // We want "permanent_allow" which is the 3rd success button + const successButtons = page.locator("button.arco-btn-status-success"); + await successButtons.first().waitFor({ timeout: 5_000 }); + // Find and click the last always-visible success button (permanent_allow, type=5) + // Button order: allow_once(type=1), temporary_allow(type=3), permanent_allow(type=5) + // Index 2 = permanent_allow (always visible) + const count = await successButtons.count(); + if (count >= 3) { + // permanent_allow is at index 2 + await successButtons.nth(2).click(); + } else { + // Fallback: click the last visible success button + await successButtons.last().click(); + } + console.log("[autoApprove] Permission approved on confirm page"); + } catch (e) { + console.log("[autoApprove] Failed to approve:", e); + } + }); +} + +/** Run a test script on the target page and collect console results */ +async function runTestScript( + context: BrowserContext, + extensionId: string, + scriptFile: string, + targetUrl: string, + timeoutMs: number +): Promise<{ passed: number; failed: number; logs: string[] }> { + let code = fs.readFileSync( + path.join(__dirname, `../example/tests/${scriptFile}`), + "utf-8" + ); + code = patchScriptCode(code); + + await installScriptByCode(context, extensionId, code); + + // Start auto-approving permission dialogs + autoApprovePermissions(context); + + const page = await context.newPage(); + const logs: string[] = []; + page.on("console", (msg) => logs.push(msg.text())); + + await page.goto(targetUrl, { waitUntil: "domcontentloaded" }); + + // Wait for test results to appear in console + const deadline = Date.now() + timeoutMs; + let passed = -1; + let failed = -1; + while (Date.now() < deadline) { + for (const log of logs) { + const passMatch = log.match(/通过[::]\s*(\d+)/); + const failMatch = log.match(/失败[::]\s*(\d+)/); + if (passMatch) passed = parseInt(passMatch[1], 10); + if (failMatch) failed = parseInt(failMatch[1], 10); + } + if (passed >= 0 && failed >= 0) break; + await page.waitForTimeout(500); + } + + await page.close(); + return { passed, failed, logs }; +} + +const TARGET_URL = "https://content-security-policy.com/"; + +test.describe("GM API", () => { + // Two-phase launch + script install + network fetches + permission dialogs + test.setTimeout(300_000); + + test("GM_ sync API tests (gm_api_test.js)", async ({ + context, + extensionId, + }) => { + const { passed, failed, logs } = await runTestScript( + context, + extensionId, + "gm_api_test.js", + TARGET_URL, + 90_000 + ); + + console.log(`[gm_api_test] passed=${passed}, failed=${failed}`); + if (failed !== 0) { + console.log("[gm_api_test] logs:", logs.join("\n")); + } + expect(failed, "Some GM_ sync API tests failed").toBe(0); + expect(passed, "No test results found - script may not have run").toBeGreaterThan(0); + }); + + test("GM.* async API tests (gm_api_async_test.js)", async ({ + context, + extensionId, + }) => { + const { passed, failed, logs } = await runTestScript( + context, + extensionId, + "gm_api_async_test.js", + TARGET_URL, + 90_000 + ); + + console.log(`[gm_api_async_test] passed=${passed}, failed=${failed}`); + if (failed !== 0) { + console.log("[gm_api_async_test] logs:", logs.join("\n")); + } + expect(failed, "Some GM.* async API tests failed").toBe(0); + expect(passed, "No test results found - script may not have run").toBeGreaterThan(0); + }); + + test("Content inject tests (inject_content_test.js)", async ({ + context, + extensionId, + }) => { + const { passed, failed, logs } = await runTestScript( + context, + extensionId, + "inject_content_test.js", + TARGET_URL, + 60_000 + ); + + console.log(`[inject_content_test] passed=${passed}, failed=${failed}`); + if (failed !== 0) { + console.log("[inject_content_test] logs:", logs.join("\n")); + } + expect(failed, "Some content inject tests failed").toBe(0); + expect(passed, "No test results found - script may not have run").toBeGreaterThan(0); + }); +}); diff --git a/e2e/utils.ts b/e2e/utils.ts index ce6fcfab0..fb8f16d54 100644 --- a/e2e/utils.ts +++ b/e2e/utils.ts @@ -49,6 +49,50 @@ export async function openEditorPage( return page; } +/** Install a script by injecting code into the Monaco editor and saving */ +export async function installScriptByCode( + context: BrowserContext, + extensionId: string, + code: string +): Promise { + const page = await openEditorPage(context, extensionId); + // Wait for Monaco editor to be ready + await page.locator(".monaco-editor").waitFor({ timeout: 30_000 }); + await page.locator(".view-lines").waitFor({ timeout: 15_000 }); + // Click into editor to ensure focus + await page.locator(".monaco-editor .view-lines").click(); + await page.waitForTimeout(500); + // Select all existing content and replace via clipboard + await page.keyboard.press("ControlOrMeta+a"); + await page.waitForTimeout(500); + await page.evaluate((text) => navigator.clipboard.writeText(text), code); + await page.keyboard.press("ControlOrMeta+v"); + await page.waitForTimeout(2000); + // Save + await page.keyboard.press("ControlOrMeta+s"); + // Wait for save: try arco-message first, then verify via script list + const saved = await page + .locator(".arco-message") + .first() + .waitFor({ timeout: 10_000 }) + .then(() => true) + .catch(() => false); + if (!saved) { + // For scripts with @require/@resource, the message may not appear. + // Verify save by checking the script list on the options page. + const listPage = await openOptionsPage(context, extensionId); + await listPage.waitForTimeout(2_000); + const emptyState = listPage.locator(".arco-empty"); + // Wait until at least one script appears (no empty state) + for (let i = 0; i < 30; i++) { + if ((await emptyState.count()) === 0) break; + await listPage.waitForTimeout(1_000); + } + await listPage.close(); + } + await page.close(); +} + /** A sample userscript for testing */ export const sampleUserScript = `// ==UserScript== // @name E2E Test Script From 409e2a543594f31895eb25475c263e2a265ae874 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Thu, 12 Mar 2026 15:07:56 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=20GM=20AP?= =?UTF-8?q?I=20E2E=20=E6=B5=8B=E8=AF=95=20CI=20=E5=85=BC=E5=AE=B9=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Phase 1 添加 --headless=new 参数,修复 CI 无 X server 环境 - 添加 eslint-disable 注释消除 Playwright use() 的误报 - prettier 格式化修正 --- e2e/gm-api.spec.ts | 54 +++++++++++----------------------------------- e2e/utils.ts | 32 ++++++--------------------- 2 files changed, 18 insertions(+), 68 deletions(-) diff --git a/e2e/gm-api.spec.ts b/e2e/gm-api.spec.ts index 4b0428a56..971dea4fc 100644 --- a/e2e/gm-api.spec.ts +++ b/e2e/gm-api.spec.ts @@ -1,12 +1,7 @@ import fs from "fs"; import path from "path"; import os from "os"; -import { - test as base, - expect, - chromium, - type BrowserContext, -} from "@playwright/test"; +import { test as base, expect, chromium, type BrowserContext } from "@playwright/test"; import { installScriptByCode } from "./utils"; const test = base.extend<{ @@ -17,15 +12,12 @@ const test = base.extend<{ context: async ({}, use) => { const pathToExtension = path.resolve(__dirname, "../dist/ext"); const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "pw-ext-")); - const chromeArgs = [ - `--disable-extensions-except=${pathToExtension}`, - `--load-extension=${pathToExtension}`, - ]; + const chromeArgs = [`--disable-extensions-except=${pathToExtension}`, `--load-extension=${pathToExtension}`]; // Phase 1: Enable user scripts permission const ctx1 = await chromium.launchPersistentContext(userDataDir, { headless: false, - args: chromeArgs, + args: ["--headless=new", ...chromeArgs], }); let [bg] = ctx1.serviceWorkers(); if (!bg) bg = await ctx1.waitForEvent("serviceworker"); @@ -48,6 +40,7 @@ const test = base.extend<{ headless: false, args: ["--headless=new", ...chromeArgs], }); + // eslint-disable-next-line react-hooks/rules-of-hooks await use(context); await context.close(); fs.rmSync(userDataDir, { recursive: true, force: true }); @@ -61,6 +54,7 @@ const test = base.extend<{ await initPage.waitForLoadState("domcontentloaded"); await initPage.evaluate(() => localStorage.setItem("firstUse", "false")); await initPage.close(); + // eslint-disable-next-line react-hooks/rules-of-hooks await use(extensionId); }, }); @@ -68,14 +62,8 @@ const test = base.extend<{ /** Strip SRI hashes and replace slow CDN with faster alternative */ function patchScriptCode(code: string): string { return code - .replace( - /^(\/\/\s*@(?:require|resource)\s+.*?)#sha(?:256|384|512)[=-][^\s]+/gm, - "$1" - ) - .replace( - /https:\/\/cdn\.jsdelivr\.net\/npm\//g, - "https://unpkg.com/" - ); + .replace(/^(\/\/\s*@(?:require|resource)\s+.*?)#sha(?:256|384|512)[=-][^\s]+/gm, "$1") + .replace(/https:\/\/cdn\.jsdelivr\.net\/npm\//g, "https://unpkg.com/"); } /** @@ -121,10 +109,7 @@ async function runTestScript( targetUrl: string, timeoutMs: number ): Promise<{ passed: number; failed: number; logs: string[] }> { - let code = fs.readFileSync( - path.join(__dirname, `../example/tests/${scriptFile}`), - "utf-8" - ); + let code = fs.readFileSync(path.join(__dirname, `../example/tests/${scriptFile}`), "utf-8"); code = patchScriptCode(code); await installScriptByCode(context, extensionId, code); @@ -163,17 +148,8 @@ test.describe("GM API", () => { // Two-phase launch + script install + network fetches + permission dialogs test.setTimeout(300_000); - test("GM_ sync API tests (gm_api_test.js)", async ({ - context, - extensionId, - }) => { - const { passed, failed, logs } = await runTestScript( - context, - extensionId, - "gm_api_test.js", - TARGET_URL, - 90_000 - ); + test("GM_ sync API tests (gm_api_test.js)", async ({ context, extensionId }) => { + const { passed, failed, logs } = await runTestScript(context, extensionId, "gm_api_test.js", TARGET_URL, 90_000); console.log(`[gm_api_test] passed=${passed}, failed=${failed}`); if (failed !== 0) { @@ -183,10 +159,7 @@ test.describe("GM API", () => { expect(passed, "No test results found - script may not have run").toBeGreaterThan(0); }); - test("GM.* async API tests (gm_api_async_test.js)", async ({ - context, - extensionId, - }) => { + test("GM.* async API tests (gm_api_async_test.js)", async ({ context, extensionId }) => { const { passed, failed, logs } = await runTestScript( context, extensionId, @@ -203,10 +176,7 @@ test.describe("GM API", () => { expect(passed, "No test results found - script may not have run").toBeGreaterThan(0); }); - test("Content inject tests (inject_content_test.js)", async ({ - context, - extensionId, - }) => { + test("Content inject tests (inject_content_test.js)", async ({ context, extensionId }) => { const { passed, failed, logs } = await runTestScript( context, extensionId, diff --git a/e2e/utils.ts b/e2e/utils.ts index fb8f16d54..a3d1a8604 100644 --- a/e2e/utils.ts +++ b/e2e/utils.ts @@ -1,10 +1,7 @@ import type { BrowserContext, Page } from "@playwright/test"; /** Open the options page and wait for it to load */ -export async function openOptionsPage( - context: BrowserContext, - extensionId: string -): Promise { +export async function openOptionsPage(context: BrowserContext, extensionId: string): Promise { const page = await context.newPage(); await page.goto(`chrome-extension://${extensionId}/src/options.html`); await page.waitForLoadState("domcontentloaded"); @@ -12,10 +9,7 @@ export async function openOptionsPage( } /** Open the popup page and wait for it to load */ -export async function openPopupPage( - context: BrowserContext, - extensionId: string -): Promise { +export async function openPopupPage(context: BrowserContext, extensionId: string): Promise { const page = await context.newPage(); await page.goto(`chrome-extension://${extensionId}/src/popup.html`); await page.waitForLoadState("domcontentloaded"); @@ -23,25 +17,15 @@ export async function openPopupPage( } /** Open the install page with a script URL parameter */ -export async function openInstallPage( - context: BrowserContext, - extensionId: string, - scriptUrl: string -): Promise { +export async function openInstallPage(context: BrowserContext, extensionId: string, scriptUrl: string): Promise { const page = await context.newPage(); - await page.goto( - `chrome-extension://${extensionId}/src/install.html?url=${encodeURIComponent(scriptUrl)}` - ); + await page.goto(`chrome-extension://${extensionId}/src/install.html?url=${encodeURIComponent(scriptUrl)}`); await page.waitForLoadState("domcontentloaded"); return page; } /** Open the script editor page */ -export async function openEditorPage( - context: BrowserContext, - extensionId: string, - params?: string -): Promise { +export async function openEditorPage(context: BrowserContext, extensionId: string, params?: string): Promise { const page = await context.newPage(); const hash = params ? `#/script/editor?${params}` : "#/script/editor"; await page.goto(`chrome-extension://${extensionId}/src/options.html${hash}`); @@ -50,11 +34,7 @@ export async function openEditorPage( } /** Install a script by injecting code into the Monaco editor and saving */ -export async function installScriptByCode( - context: BrowserContext, - extensionId: string, - code: string -): Promise { +export async function installScriptByCode(context: BrowserContext, extensionId: string, code: string): Promise { const page = await openEditorPage(context, extensionId); // Wait for Monaco editor to be ready await page.locator(".monaco-editor").waitFor({ timeout: 30_000 }); From 434fb9f53271a69ea118ab853a1b829e2c8d5556 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Thu, 12 Mar 2026 15:27:38 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=20E2E=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=20CI=20=E5=85=BC=E5=AE=B9=E6=80=A7=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - vitest.config.ts: 排除 e2e/ 目录避免 Vitest 误跑 Playwright 测试 - eslint.config.mjs: 为 e2e/ 目录关闭 react-hooks/rules-of-hooks 规则 - e2e/options.spec.ts: 菜单正则加 /i 标志修复英文环境大小写匹配 - prettier 格式化修正 --- e2e/fixtures.ts | 6 +----- e2e/gm-api.spec.ts | 2 -- e2e/install.spec.ts | 5 +---- e2e/options.spec.ts | 31 ++++++++++++++++++++++--------- e2e/popup.spec.ts | 5 +---- e2e/script-editor.spec.ts | 5 +---- e2e/script-management.spec.ts | 9 ++------- eslint.config.mjs | 6 ++++++ vitest.config.ts | 1 + 9 files changed, 35 insertions(+), 35 deletions(-) diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index 291fba5ba..7d613e7c1 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -10,11 +10,7 @@ export const test = base.extend<{ const pathToExtension = path.resolve(__dirname, "../dist/ext"); const context = await chromium.launchPersistentContext("", { headless: false, - args: [ - "--headless=new", - `--disable-extensions-except=${pathToExtension}`, - `--load-extension=${pathToExtension}`, - ], + args: ["--headless=new", `--disable-extensions-except=${pathToExtension}`, `--load-extension=${pathToExtension}`], }); await use(context); await context.close(); diff --git a/e2e/gm-api.spec.ts b/e2e/gm-api.spec.ts index 971dea4fc..3918bf094 100644 --- a/e2e/gm-api.spec.ts +++ b/e2e/gm-api.spec.ts @@ -40,7 +40,6 @@ const test = base.extend<{ headless: false, args: ["--headless=new", ...chromeArgs], }); - // eslint-disable-next-line react-hooks/rules-of-hooks await use(context); await context.close(); fs.rmSync(userDataDir, { recursive: true, force: true }); @@ -54,7 +53,6 @@ const test = base.extend<{ await initPage.waitForLoadState("domcontentloaded"); await initPage.evaluate(() => localStorage.setItem("firstUse", "false")); await initPage.close(); - // eslint-disable-next-line react-hooks/rules-of-hooks await use(extensionId); }, }); diff --git a/e2e/install.spec.ts b/e2e/install.spec.ts index 017019552..fe285c3ff 100644 --- a/e2e/install.spec.ts +++ b/e2e/install.spec.ts @@ -13,10 +13,7 @@ test.describe("Install Page", () => { await expect(page).toHaveTitle(/Install.*ScriptCat|ScriptCat/i); }); - test("should display script metadata when loading a script", async ({ - context, - extensionId, - }) => { + test("should display script metadata when loading a script", async ({ context, extensionId }) => { const page = await openInstallPage(context, extensionId, testScriptUrl); // Wait for the script to be fetched and metadata to be displayed diff --git a/e2e/options.spec.ts b/e2e/options.spec.ts index d7cd446b3..2901e4113 100644 --- a/e2e/options.spec.ts +++ b/e2e/options.spec.ts @@ -20,34 +20,47 @@ test.describe("Options Page", () => { await expect(page.locator(".arco-menu").first()).toBeVisible(); // Click "Subscribe" / "订阅" menu item and verify route change - await page.locator(".arco-menu-item").filter({ hasText: /subscribe|订阅/ }).first().click(); + await page + .locator(".arco-menu-item") + .filter({ hasText: /subscribe|订阅/i }) + .first() + .click(); await expect(page).toHaveURL(/.*#\/subscribe/); // Click "Logs" / "日志" menu item - await page.locator(".arco-menu-item").filter({ hasText: /log|日志/ }).first().click(); + await page + .locator(".arco-menu-item") + .filter({ hasText: /log|日志/i }) + .first() + .click(); await expect(page).toHaveURL(/.*#\/logger/); // Click "Tools" / "工具" menu item - await page.locator(".arco-menu-item").filter({ hasText: /tool|工具/ }).first().click(); + await page + .locator(".arco-menu-item") + .filter({ hasText: /tool|工具/i }) + .first() + .click(); await expect(page).toHaveURL(/.*#\/tools/); // Click "Settings" / "设置" menu item - await page.locator(".arco-menu-item").filter({ hasText: /setting|设置/ }).first().click(); + await page + .locator(".arco-menu-item") + .filter({ hasText: /setting|设置/i }) + .first() + .click(); await expect(page).toHaveURL(/.*#\/setting/); // Navigate back to script list (home) - click the first menu item await page .locator(".arco-menu-item") - .filter({ hasText: /installed.*script|已安装脚本/ }) + .filter({ hasText: /installed.*script|已安装脚本/i }) .first() .click(); await expect(page).toHaveURL(/.*#\//); }); - test("should show theme switch dropdown with light/dark/auto options", async ({ - context, - extensionId, - }) => { + test("should show theme switch dropdown with light/dark/auto options", async ({ context, extensionId }) => { const page = await openOptionsPage(context, extensionId); // Find the theme toggle button in the action-tools area (icon-only button) diff --git a/e2e/popup.spec.ts b/e2e/popup.spec.ts index d3f4f3a69..f34be06c8 100644 --- a/e2e/popup.spec.ts +++ b/e2e/popup.spec.ts @@ -44,10 +44,7 @@ test.describe("Popup Page", () => { await expect(settingsBtn).toBeVisible(); // Click the settings button - it should open a new page - const [newPage] = await Promise.all([ - context.waitForEvent("page"), - settingsBtn.click(), - ]); + const [newPage] = await Promise.all([context.waitForEvent("page"), settingsBtn.click()]); // The new page should be the options page await expect(newPage).toHaveURL(/options\.html/); diff --git a/e2e/script-editor.spec.ts b/e2e/script-editor.spec.ts index 47216376f..035e42b16 100644 --- a/e2e/script-editor.spec.ts +++ b/e2e/script-editor.spec.ts @@ -43,10 +43,7 @@ test.describe("Script Editor", () => { await expect(successMsg.first()).toBeVisible({ timeout: 15_000 }); }); - test("should show newly created script in the list after saving", async ({ - context, - extensionId, - }) => { + test("should show newly created script in the list after saving", async ({ context, extensionId }) => { // First create a script via the editor const editorPage = await openEditorPage(context, extensionId); diff --git a/e2e/script-management.spec.ts b/e2e/script-management.spec.ts index fc065e3ee..d09542796 100644 --- a/e2e/script-management.spec.ts +++ b/e2e/script-management.spec.ts @@ -5,10 +5,7 @@ import { openEditorPage, openOptionsPage } from "./utils"; /** * Helper: create a script via the editor, then open the options page. */ -async function createScriptAndGoToList( - context: BrowserContext, - extensionId: string -): Promise { +async function createScriptAndGoToList(context: BrowserContext, extensionId: string): Promise { const editorPage = await openEditorPage(context, extensionId); // Wait for Monaco editor @@ -72,9 +69,7 @@ test.describe("Script Management", () => { const page = await createScriptAndGoToList(context, extensionId); // Right-click on a script row to get context menu - const scriptRow = page - .locator(".arco-table-row, .arco-card-body .arco-list-item, [class*='script']") - .first(); + const scriptRow = page.locator(".arco-table-row, .arco-card-body .arco-list-item, [class*='script']").first(); if (await scriptRow.isVisible()) { await scriptRow.click({ button: "right" }); await page.waitForTimeout(500); diff --git a/eslint.config.mjs b/eslint.config.mjs index 8946a46db..a476a50e8 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -63,5 +63,11 @@ export default [ }, }, prettier, + { + files: ["e2e/**/*.ts"], + rules: { + "react-hooks/rules-of-hooks": "off", + }, + }, { ignores: ["dist/", "example/"] }, ]; diff --git a/vitest.config.ts b/vitest.config.ts index 1a82bcb1b..fa324d2cf 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -25,6 +25,7 @@ export default defineConfig({ ], test: { environment: "jsdom", + exclude: ["e2e/**", "node_modules/**"], // List setup file setupFiles: ["./tests/vitest.setup.ts"], env: {