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..7d613e7c1 --- /dev/null +++ b/e2e/fixtures.ts @@ -0,0 +1,39 @@ +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/gm-api.spec.ts b/e2e/gm-api.spec.ts new file mode 100644 index 000000000..3918bf094 --- /dev/null +++ b/e2e/gm-api.spec.ts @@ -0,0 +1,193 @@ +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: ["--headless=new", ...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/install.spec.ts b/e2e/install.spec.ts new file mode 100644 index 000000000..fe285c3ff --- /dev/null +++ b/e2e/install.spec.ts @@ -0,0 +1,29 @@ +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..2901e4113 --- /dev/null +++ b/e2e/options.spec.ts @@ -0,0 +1,102 @@ +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|订阅/i }) + .first() + .click(); + await expect(page).toHaveURL(/.*#\/subscribe/); + + // Click "Logs" / "日志" menu item + 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|工具/i }) + .first() + .click(); + await expect(page).toHaveURL(/.*#\/tools/); + + // Click "Settings" / "设置" menu item + 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|已安装脚本/i }) + .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..f34be06c8 --- /dev/null +++ b/e2e/popup.spec.ts @@ -0,0 +1,73 @@ +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..035e42b16 --- /dev/null +++ b/e2e/script-editor.spec.ts @@ -0,0 +1,69 @@ +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..d09542796 --- /dev/null +++ b/e2e/script-management.spec.ts @@ -0,0 +1,117 @@ +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..a3d1a8604 --- /dev/null +++ b/e2e/utils.ts @@ -0,0 +1,87 @@ +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; +} + +/** 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 +// @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/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/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: {} 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: {