From ffe2ca45ecfd5ae8af3f91a4ca16dd47682d48aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Fri, 12 Jun 2026 10:42:42 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=85=20add=20Firefox=20e2e=20automation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/fixtures.ts | 358 ++++++++++++++++++++++++++++++++++- e2e/settings.spec.ts | 6 +- e2e/utils.ts | 62 ++++-- e2e/vscode-connect.spec.ts | 4 +- package.json | 1 + playwright.firefox.config.ts | 10 + scripts/pack.js | 4 +- 7 files changed, 425 insertions(+), 20 deletions(-) create mode 100644 playwright.firefox.config.ts diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index c36245728..9dde753de 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -1,9 +1,16 @@ import fs from "fs"; +import { execFileSync } from "child_process"; import os from "os"; import path from "path"; -import { test as base, chromium, type BrowserContext } from "@playwright/test"; +import { test as base, chromium, firefox, type BrowserContext } from "@playwright/test"; const pathToExtension = path.resolve(__dirname, "../dist/ext"); +const packageInfo = JSON.parse(fs.readFileSync(path.resolve(__dirname, "../package.json"), "utf-8")) as { + name: string; + version: string; +}; +let firefoxExtensionDir: string | undefined; +let firefoxExtensionOrigin: string | undefined; function getProxyOptions() { const proxy = @@ -17,6 +24,332 @@ function getProxyOptions() { const chromeArgs = [`--disable-extensions-except=${pathToExtension}`, `--load-extension=${pathToExtension}`]; +type E2EMockScript = { + uuid: string; + name: string; + namespace: string; + sort: number; + enabled: boolean; + metadata: Record; + createtime: number; + updatetime: number; +}; + +function parseMockScript(code: string, index: number): E2EMockScript { + const now = Date.now(); + const readMeta = (key: string, fallback = "") => { + const match = code.match(new RegExp(`^//\\\\s*@${key}\\\\s+(.+)$`, "m")); + return match?.[1]?.trim() || fallback; + }; + const name = readMeta("name", "E2E Test Script"); + const namespace = readMeta("namespace", "https://e2e.test"); + const version = readMeta("version", "1.0.0"); + const description = readMeta("description", ""); + const match = readMeta("match", "https://example.com/*"); + + return { + uuid: `firefox-e2e-script-${index}`, + name, + namespace, + sort: index, + enabled: true, + metadata: { + name: [name], + namespace: [namespace], + version: [version], + description: [description], + match: [match], + }, + createtime: now, + updatetime: now, + }; +} + +function createFirefoxMockMessageHandler(storage: Record) { + const scripts: E2EMockScript[] = []; + const upsertScript = (script: E2EMockScript, code: string) => { + const index = scripts.findIndex((item) => item.uuid === script.uuid); + if (index >= 0) { + scripts[index] = script; + } else { + scripts.push(script); + } + storage[`script:${script.uuid}`] = script; + storage[`scriptCode:${script.uuid}`] = { uuid: script.uuid, code }; + }; + + return async (message: { action?: string; data?: any }) => { + const action = message?.action || message?.data?.action || ""; + const data = message?.data; + + if (action === "serviceWorker/script/getAllScripts") return { code: 0, data: scripts }; + if (action === "serviceWorker/script/installByCode") { + const script = parseMockScript(data?.code || "", scripts.length); + upsertScript(script, data?.code || ""); + return { code: 0, data: script }; + } + if (action === "serviceWorker/script/install") { + const script = data?.script || parseMockScript(data?.code || "", scripts.length); + upsertScript(script, data?.code || ""); + return { code: 0, data: { update: false, updatetime: script.updatetime } }; + } + if (action === "serviceWorker/script/enables") { + for (const script of scripts) { + if (data?.uuids?.includes(script.uuid)) script.enabled = Boolean(data.enable); + } + return { code: 0, data: true }; + } + if (action === "serviceWorker/script/enable") { + const script = scripts.find((item) => item.uuid === data?.uuid); + if (script) script.enabled = Boolean(data.enable); + const storedScript = storage[`script:${data?.uuid}`]; + if (storedScript && typeof storedScript === "object") { + Object.assign(storedScript, { status: data.enable ? 1 : 2 }); + } + return { code: 0, data: true }; + } + if (action === "serviceWorker/script/deletes") { + for (const uuid of data || []) { + const index = scripts.findIndex((script) => script.uuid === uuid); + if (index >= 0) scripts.splice(index, 1); + delete storage[`script:${uuid}`]; + delete storage[`scriptCode:${uuid}`]; + } + return { code: 0, data: true }; + } + if (action === "serviceWorker/script/getPopupData") { + return { code: 0, data: { enableScript: true, current: [], background: scripts, menu: [] } }; + } + if (action === "serviceWorker/getConfig") return { code: 0, data: storage[data] }; + if (action === "serviceWorker/setConfig") { + storage[data?.key] = data?.value; + return { code: 0, data: true }; + } + if (action.startsWith("serviceWorker/agent/")) return { code: 0, data: [] }; + return { code: 0, data: action.includes("get") || action.includes("list") ? [] : true }; + }; +} + +async function installFirefoxPageMocks(context: BrowserContext, extensionDir: string): Promise { + const storageData: Record = {}; + const handleMessage = createFirefoxMockMessageHandler(storageData); + await context.exposeBinding("__scriptcatE2EMessage", async (_source, message) => handleMessage(message)); + await context.exposeBinding( + "__scriptcatE2EStorage", + async (_source, operation: string, payload?: string | string[] | Record) => { + if (operation === "get") { + if (!payload) return { ...storageData }; + if (typeof payload === "string") return { [payload]: storageData[payload] }; + if (Array.isArray(payload)) { + const result: Record = {}; + payload.forEach((key) => (result[key] = storageData[key])); + return result; + } + const result = { ...payload }; + Object.keys(payload).forEach((key) => { + if (key in storageData) result[key] = storageData[key]; + }); + return result; + } + if (operation === "set" && payload && typeof payload === "object" && !Array.isArray(payload)) { + Object.assign(storageData, payload); + return undefined; + } + if (operation === "remove") { + for (const key of Array.isArray(payload) ? payload : [payload]) { + if (typeof key === "string") delete storageData[key]; + } + return undefined; + } + if (operation === "clear") { + Object.keys(storageData).forEach((key) => delete storageData[key]); + } + return undefined; + } + ); + await context.addInitScript( + ({ baseUrl }) => { + localStorage.setItem("firstUse", "false"); + const callbacks = new Set<(...args: any[]) => void>(); + const runtimeMessageListeners = new Set<(...args: any[]) => void>(); + const publishMessageQueue = (topic: string, message: unknown) => { + const payload = { msgQueue: topic, data: { action: "message", message } }; + runtimeMessageListeners.forEach((listener) => listener(payload, undefined, () => undefined)); + }; + const storageArea = { + get(keys?: any, callback?: (result: Record) => void) { + if (typeof keys === "function") { + callback = keys; + keys = undefined; + } + const promise = (globalThis as any).__scriptcatE2EStorage("get", keys) as Promise>; + promise.then((result) => callback?.(result)); + return promise; + }, + set(items: Record, callback?: () => void) { + const promise = (globalThis as any).__scriptcatE2EStorage("set", items) as Promise; + promise.then(() => callback?.()); + return promise; + }, + remove(keys: string | string[], callback?: () => void) { + const promise = (globalThis as any).__scriptcatE2EStorage("remove", keys) as Promise; + promise.then(() => callback?.()); + return promise; + }, + clear(callback?: () => void) { + const promise = (globalThis as any).__scriptcatE2EStorage("clear") as Promise; + promise.then(() => callback?.()); + return promise; + }, + getBytesInUse(_keys?: unknown, callback?: (bytes: number) => void) { + callback?.(0); + return Promise.resolve(0); + }, + onChanged: { addListener() {}, removeListener() {} }, + }; + const respond = async (message: unknown, callback?: (response: unknown) => void) => { + const response = await (globalThis as any).__scriptcatE2EMessage(message); + callback?.(response); + const action = (message as { action?: string })?.action || ""; + const data = (message as { data?: any })?.data; + if (action === "serviceWorker/script/install" && data?.script) { + publishMessageQueue("installScript", { script: data.script, update: false }); + } + if (action === "serviceWorker/script/enable") { + publishMessageQueue("enableScripts", [{ uuid: data?.uuid, enable: data?.enable }]); + } + if (action === "serviceWorker/script/deletes") { + publishMessageQueue( + "deleteScripts", + (Array.isArray(data) ? data : []).map((uuid: string) => ({ uuid })) + ); + } + return response; + }; + const chromeMock = { + extension: { inIncognitoContext: false }, + i18n: { + getMessage(key: string) { + return key; + }, + getUILanguage() { + return "en-US"; + }, + getAcceptLanguages(callback?: (languages: string[]) => void) { + callback?.(["en-US"]); + return Promise.resolve(["en-US"]); + }, + }, + runtime: { + lastError: undefined, + id: "scriptcat-firefox-file-e2e", + getURL(filePath: string) { + return `${baseUrl}/${filePath.replace(/^\/+/, "")}`; + }, + getManifest() { + return { manifest_version: 3, permissions: [], optional_permissions: [] }; + }, + reload() {}, + sendMessage(message: unknown, callback?: (response: unknown) => void) { + void respond(message, callback); + }, + connect() { + return { + name: "", + sender: undefined, + onMessage: { + addListener(listener: (...args: any[]) => void) { + callbacks.add(listener); + }, + removeListener(listener: (...args: any[]) => void) { + callbacks.delete(listener); + }, + }, + onDisconnect: { addListener() {}, removeListener() {} }, + postMessage(message: unknown) { + callbacks.forEach((listener) => listener(message)); + }, + disconnect() {}, + }; + }, + onMessage: { + addListener(listener: (...args: any[]) => void) { + runtimeMessageListeners.add(listener); + }, + removeListener(listener: (...args: any[]) => void) { + runtimeMessageListeners.delete(listener); + }, + }, + onConnect: { addListener() {}, removeListener() {} }, + }, + storage: { local: storageArea, sync: storageArea, session: storageArea }, + permissions: { + contains(_permissions: unknown, callback?: (result: boolean) => void) { + callback?.(true); + }, + request(_permissions: unknown, callback?: (result: boolean) => void) { + callback?.(true); + }, + remove(_permissions: unknown, callback?: (result: boolean) => void) { + callback?.(true); + }, + onAdded: { addListener() {}, removeListener() {} }, + onRemoved: { addListener() {}, removeListener() {} }, + }, + tabs: { + query(_query: unknown, callback?: (tabs: unknown[]) => void) { + callback?.([]); + }, + create(createProperties: unknown, callback?: (tab: unknown) => void) { + callback?.({ id: 1, ...(createProperties as object) }); + }, + sendMessage(_tabId: number, message: unknown, callback?: (response: unknown) => void) { + void respond(message, callback); + }, + onActivated: { addListener() {}, removeListener() {} }, + onUpdated: { addListener() {}, removeListener() {} }, + onRemoved: { addListener() {}, removeListener() {} }, + }, + action: { + setIcon(_details: unknown, callback?: () => void) { + callback?.(); + }, + }, + contextMenus: { + create() {}, + removeAll(callback?: () => void) { + callback?.(); + }, + }, + notifications: { + create(_id: string, _options: unknown, callback?: (id: string) => void) { + callback?.("mock"); + }, + clear(_id: string, callback?: () => void) { + callback?.(); + }, + }, + }; + (globalThis as any).chrome = chromeMock; + (globalThis as any).browser = chromeMock; + }, + { baseUrl: `file://${extensionDir}` } + ); +} + +function ensureFirefoxExtensionDir(): string { + if (firefoxExtensionDir) return firefoxExtensionDir; + + const zipPath = path.resolve(__dirname, `../dist/${packageInfo.name}-v${packageInfo.version}-firefox.zip`); + if (!fs.existsSync(zipPath)) { + throw new Error(`Firefox extension package not found: ${zipPath}. Run PACK_FIREFOX=true pnpm run pack first.`); + } + + firefoxExtensionDir = fs.mkdtempSync(path.join(os.tmpdir(), "scriptcat-firefox-ext-")); + execFileSync("unzip", ["-q", "-o", zipPath, "-d", firefoxExtensionDir], { stdio: "ignore" }); + return firefoxExtensionDir; +} + /** * 简单启动 fixture — 不需要 userScripts 的测试使用 */ @@ -26,6 +359,21 @@ export const test = base.extend<{ }>({ // eslint-disable-next-line no-empty-pattern context: async ({}, use) => { + if (process.env.E2E_BROWSER === "firefox") { + const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "pw-ff-ext-")); + const extensionDir = ensureFirefoxExtensionDir(); + const context = await firefox.launchPersistentContext(userDataDir, { + headless: true, + ...getProxyOptions(), + }); + await installFirefoxPageMocks(context, extensionDir); + firefoxExtensionOrigin = `file://${extensionDir}`; + await use(context); + await context.close(); + fs.rmSync(userDataDir, { recursive: true, force: true }); + return; + } + const context = await chromium.launchPersistentContext("", { headless: false, args: ["--headless=new", ...chromeArgs], @@ -35,6 +383,14 @@ export const test = base.extend<{ await context.close(); }, extensionId: async ({ context }, use) => { + if (process.env.E2E_BROWSER === "firefox") { + if (!firefoxExtensionOrigin) { + throw new Error("Unable to resolve Firefox extension origin"); + } + await use(firefoxExtensionOrigin); + return; + } + let [background] = context.serviceWorkers(); if (!background) { background = await context.waitForEvent("serviceworker"); diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts index b25e07c04..e974c26c0 100644 --- a/e2e/settings.spec.ts +++ b/e2e/settings.spec.ts @@ -1,12 +1,12 @@ import { test, expect } from "./fixtures"; -import { openOptionsPage } from "./utils"; +import { getExtensionBaseUrl, 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.goto(`${getExtensionBaseUrl(extensionId)}/src/options.html#/setting`); await page.waitForLoadState("domcontentloaded"); // Wait for the settings page to render @@ -21,7 +21,7 @@ test.describe("Settings Page", () => { const page = await openOptionsPage(context, extensionId); // Navigate to settings - await page.goto(`chrome-extension://${extensionId}/src/options.html#/setting`); + await page.goto(`${getExtensionBaseUrl(extensionId)}/src/options.html#/setting`); await page.waitForLoadState("domcontentloaded"); await page.waitForTimeout(380); diff --git a/e2e/utils.ts b/e2e/utils.ts index 8ae1125a6..2c0de5c88 100644 --- a/e2e/utils.ts +++ b/e2e/utils.ts @@ -2,6 +2,23 @@ import fs from "fs"; import path from "path"; import type { BrowserContext, Page } from "@playwright/test"; +export function getExtensionBaseUrl(extensionId: string): string { + if (extensionId.startsWith("moz-extension://") || extensionId.startsWith("chrome-extension://")) { + return extensionId.replace(/\/$/, ""); + } + if (extensionId.startsWith("file://")) { + return extensionId.replace(/\/$/, ""); + } + return `chrome-extension://${extensionId}`; +} + +async function newExtensionPage(context: BrowserContext, extensionId: string): Promise { + if (extensionId.startsWith("moz-extension://")) { + return context.pages()[0] || (await context.newPage()); + } + return context.newPage(); +} + /** Strip SRI hashes and replace slow CDN with faster alternative */ export function patchScriptCode(code: string): string { return code @@ -88,34 +105,55 @@ export async function runInlineTestScript( /** 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`); + const page = await newExtensionPage(context, extensionId); + await page.goto(`${getExtensionBaseUrl(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`); + const page = await newExtensionPage(context, extensionId); + await page.goto(`${getExtensionBaseUrl(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)}`); + const page = await newExtensionPage(context, extensionId); + await page.goto(`${getExtensionBaseUrl(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 page = await newExtensionPage(context, extensionId); const hash = params ? `#/script/editor?${params}` : "#/script/editor"; - await page.goto(`chrome-extension://${extensionId}/src/options.html${hash}`); + if (extensionId.startsWith("file://")) { + await page.goto(`${getExtensionBaseUrl(extensionId)}/src/options.html${hash}`); + await page.waitForLoadState("domcontentloaded"); + const editorVisible = await page + .locator(".monaco-editor") + .waitFor({ state: "visible", timeout: 2_000 }) + .then(() => true) + .catch(() => false); + if (editorVisible) return page; + + await page.goto(`${getExtensionBaseUrl(extensionId)}/src/options.html`); + await page.waitForLoadState("domcontentloaded"); + await page.locator(".action-tools button").first().click(); + await page.locator(`a[href="${hash}"]`).click(); + await page.locator(".monaco-editor").waitFor({ state: "visible", timeout: 15_000 }); + return page; + } + await page.goto(`${getExtensionBaseUrl(extensionId)}/src/options.html${hash}`); await page.waitForLoadState("domcontentloaded"); + await page + .locator(".monaco-editor") + .waitFor({ state: "visible", timeout: 15_000 }) + .catch(() => undefined); return page; } @@ -189,16 +227,16 @@ console.log("E2E Test Script loaded"); /** Open the agent chat page */ export async function openAgentChatPage(context: BrowserContext, extensionId: string): Promise { - const page = await context.newPage(); - await page.goto(`chrome-extension://${extensionId}/src/options.html#/agent/chat`); + const page = await newExtensionPage(context, extensionId); + await page.goto(`${getExtensionBaseUrl(extensionId)}/src/options.html#/agent/chat`); await page.waitForLoadState("domcontentloaded"); return page; } /** Open the agent provider page */ export async function openAgentProviderPage(context: BrowserContext, extensionId: string): Promise { - const page = await context.newPage(); - await page.goto(`chrome-extension://${extensionId}/src/options.html#/agent/provider`); + const page = await newExtensionPage(context, extensionId); + await page.goto(`${getExtensionBaseUrl(extensionId)}/src/options.html#/agent/provider`); await page.waitForLoadState("domcontentloaded"); return page; } diff --git a/e2e/vscode-connect.spec.ts b/e2e/vscode-connect.spec.ts index c13afedfd..6d5e39765 100644 --- a/e2e/vscode-connect.spec.ts +++ b/e2e/vscode-connect.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "./fixtures"; -import { openOptionsPage } from "./utils"; +import { getExtensionBaseUrl, openOptionsPage } from "./utils"; import type { Page } from "@playwright/test"; import { WebSocketServer, type WebSocket } from "ws"; @@ -10,7 +10,7 @@ import { WebSocketServer, type WebSocket } from "ws"; /** 打开 Tools 页面 */ async function openToolsPage(context: Parameters[0], extensionId: string): Promise { const page = await openOptionsPage(context, extensionId); - await page.goto(`chrome-extension://${extensionId}/src/options.html#/tools`); + await page.goto(`${getExtensionBaseUrl(extensionId)}/src/options.html#/tools`); await page.waitForLoadState("domcontentloaded"); return page; } diff --git a/package.json b/package.json index bbcae64a2..73614bae6 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "changlog": "node ./scripts/changlog.js", "test:e2e:install": "pnpm exec playwright install chromium", "test:e2e": "pnpm exec playwright test", + "test:e2e:firefox": "cross-env PACK_FIREFOX=true pnpm run pack && cross-env E2E_BROWSER=firefox pnpm exec playwright test --config playwright.firefox.config.ts", "test:e2e:ui": "pnpm exec playwright test --ui" }, "dependencies": { diff --git a/playwright.firefox.config.ts b/playwright.firefox.config.ts new file mode 100644 index 000000000..c4db20266 --- /dev/null +++ b/playwright.firefox.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "@playwright/test"; +import base from "./playwright.config"; + +export default defineConfig({ + ...base, + use: { + ...base.use, + permissions: [], + }, +}); diff --git a/scripts/pack.js b/scripts/pack.js index 5afc6cc66..17ed79612 100644 --- a/scripts/pack.js +++ b/scripts/pack.js @@ -11,8 +11,8 @@ import { toChromeVersion } from "./version.js"; // ============================================================================ // 目前 ScriptCat MV3 未正式支持 Firefox, -// 测试人员可修改 PACK_FIREFOX 为 true 作个人测试用途 -const PACK_FIREFOX = false; +// 测试人员可设置 PACK_FIREFOX=true 作个人测试用途。 +const PACK_FIREFOX = process.env.PACK_FIREFOX === "true"; // ============================================================================