Skip to content

Commit ffe2ca4

Browse files
committed
✅ add Firefox e2e automation
1 parent 313e464 commit ffe2ca4

7 files changed

Lines changed: 425 additions & 20 deletions

File tree

e2e/fixtures.ts

Lines changed: 357 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
import fs from "fs";
2+
import { execFileSync } from "child_process";
23
import os from "os";
34
import path from "path";
4-
import { test as base, chromium, type BrowserContext } from "@playwright/test";
5+
import { test as base, chromium, firefox, type BrowserContext } from "@playwright/test";
56

67
const pathToExtension = path.resolve(__dirname, "../dist/ext");
8+
const packageInfo = JSON.parse(fs.readFileSync(path.resolve(__dirname, "../package.json"), "utf-8")) as {
9+
name: string;
10+
version: string;
11+
};
12+
let firefoxExtensionDir: string | undefined;
13+
let firefoxExtensionOrigin: string | undefined;
714

815
function getProxyOptions() {
916
const proxy =
@@ -17,6 +24,332 @@ function getProxyOptions() {
1724

1825
const chromeArgs = [`--disable-extensions-except=${pathToExtension}`, `--load-extension=${pathToExtension}`];
1926

27+
type E2EMockScript = {
28+
uuid: string;
29+
name: string;
30+
namespace: string;
31+
sort: number;
32+
enabled: boolean;
33+
metadata: Record<string, string[]>;
34+
createtime: number;
35+
updatetime: number;
36+
};
37+
38+
function parseMockScript(code: string, index: number): E2EMockScript {
39+
const now = Date.now();
40+
const readMeta = (key: string, fallback = "") => {
41+
const match = code.match(new RegExp(`^//\\\\s*@${key}\\\\s+(.+)$`, "m"));
42+
return match?.[1]?.trim() || fallback;
43+
};
44+
const name = readMeta("name", "E2E Test Script");
45+
const namespace = readMeta("namespace", "https://e2e.test");
46+
const version = readMeta("version", "1.0.0");
47+
const description = readMeta("description", "");
48+
const match = readMeta("match", "https://example.com/*");
49+
50+
return {
51+
uuid: `firefox-e2e-script-${index}`,
52+
name,
53+
namespace,
54+
sort: index,
55+
enabled: true,
56+
metadata: {
57+
name: [name],
58+
namespace: [namespace],
59+
version: [version],
60+
description: [description],
61+
match: [match],
62+
},
63+
createtime: now,
64+
updatetime: now,
65+
};
66+
}
67+
68+
function createFirefoxMockMessageHandler(storage: Record<string, unknown>) {
69+
const scripts: E2EMockScript[] = [];
70+
const upsertScript = (script: E2EMockScript, code: string) => {
71+
const index = scripts.findIndex((item) => item.uuid === script.uuid);
72+
if (index >= 0) {
73+
scripts[index] = script;
74+
} else {
75+
scripts.push(script);
76+
}
77+
storage[`script:${script.uuid}`] = script;
78+
storage[`scriptCode:${script.uuid}`] = { uuid: script.uuid, code };
79+
};
80+
81+
return async (message: { action?: string; data?: any }) => {
82+
const action = message?.action || message?.data?.action || "";
83+
const data = message?.data;
84+
85+
if (action === "serviceWorker/script/getAllScripts") return { code: 0, data: scripts };
86+
if (action === "serviceWorker/script/installByCode") {
87+
const script = parseMockScript(data?.code || "", scripts.length);
88+
upsertScript(script, data?.code || "");
89+
return { code: 0, data: script };
90+
}
91+
if (action === "serviceWorker/script/install") {
92+
const script = data?.script || parseMockScript(data?.code || "", scripts.length);
93+
upsertScript(script, data?.code || "");
94+
return { code: 0, data: { update: false, updatetime: script.updatetime } };
95+
}
96+
if (action === "serviceWorker/script/enables") {
97+
for (const script of scripts) {
98+
if (data?.uuids?.includes(script.uuid)) script.enabled = Boolean(data.enable);
99+
}
100+
return { code: 0, data: true };
101+
}
102+
if (action === "serviceWorker/script/enable") {
103+
const script = scripts.find((item) => item.uuid === data?.uuid);
104+
if (script) script.enabled = Boolean(data.enable);
105+
const storedScript = storage[`script:${data?.uuid}`];
106+
if (storedScript && typeof storedScript === "object") {
107+
Object.assign(storedScript, { status: data.enable ? 1 : 2 });
108+
}
109+
return { code: 0, data: true };
110+
}
111+
if (action === "serviceWorker/script/deletes") {
112+
for (const uuid of data || []) {
113+
const index = scripts.findIndex((script) => script.uuid === uuid);
114+
if (index >= 0) scripts.splice(index, 1);
115+
delete storage[`script:${uuid}`];
116+
delete storage[`scriptCode:${uuid}`];
117+
}
118+
return { code: 0, data: true };
119+
}
120+
if (action === "serviceWorker/script/getPopupData") {
121+
return { code: 0, data: { enableScript: true, current: [], background: scripts, menu: [] } };
122+
}
123+
if (action === "serviceWorker/getConfig") return { code: 0, data: storage[data] };
124+
if (action === "serviceWorker/setConfig") {
125+
storage[data?.key] = data?.value;
126+
return { code: 0, data: true };
127+
}
128+
if (action.startsWith("serviceWorker/agent/")) return { code: 0, data: [] };
129+
return { code: 0, data: action.includes("get") || action.includes("list") ? [] : true };
130+
};
131+
}
132+
133+
async function installFirefoxPageMocks(context: BrowserContext, extensionDir: string): Promise<void> {
134+
const storageData: Record<string, unknown> = {};
135+
const handleMessage = createFirefoxMockMessageHandler(storageData);
136+
await context.exposeBinding("__scriptcatE2EMessage", async (_source, message) => handleMessage(message));
137+
await context.exposeBinding(
138+
"__scriptcatE2EStorage",
139+
async (_source, operation: string, payload?: string | string[] | Record<string, unknown>) => {
140+
if (operation === "get") {
141+
if (!payload) return { ...storageData };
142+
if (typeof payload === "string") return { [payload]: storageData[payload] };
143+
if (Array.isArray(payload)) {
144+
const result: Record<string, unknown> = {};
145+
payload.forEach((key) => (result[key] = storageData[key]));
146+
return result;
147+
}
148+
const result = { ...payload };
149+
Object.keys(payload).forEach((key) => {
150+
if (key in storageData) result[key] = storageData[key];
151+
});
152+
return result;
153+
}
154+
if (operation === "set" && payload && typeof payload === "object" && !Array.isArray(payload)) {
155+
Object.assign(storageData, payload);
156+
return undefined;
157+
}
158+
if (operation === "remove") {
159+
for (const key of Array.isArray(payload) ? payload : [payload]) {
160+
if (typeof key === "string") delete storageData[key];
161+
}
162+
return undefined;
163+
}
164+
if (operation === "clear") {
165+
Object.keys(storageData).forEach((key) => delete storageData[key]);
166+
}
167+
return undefined;
168+
}
169+
);
170+
await context.addInitScript(
171+
({ baseUrl }) => {
172+
localStorage.setItem("firstUse", "false");
173+
const callbacks = new Set<(...args: any[]) => void>();
174+
const runtimeMessageListeners = new Set<(...args: any[]) => void>();
175+
const publishMessageQueue = (topic: string, message: unknown) => {
176+
const payload = { msgQueue: topic, data: { action: "message", message } };
177+
runtimeMessageListeners.forEach((listener) => listener(payload, undefined, () => undefined));
178+
};
179+
const storageArea = {
180+
get(keys?: any, callback?: (result: Record<string, unknown>) => void) {
181+
if (typeof keys === "function") {
182+
callback = keys;
183+
keys = undefined;
184+
}
185+
const promise = (globalThis as any).__scriptcatE2EStorage("get", keys) as Promise<Record<string, unknown>>;
186+
promise.then((result) => callback?.(result));
187+
return promise;
188+
},
189+
set(items: Record<string, unknown>, callback?: () => void) {
190+
const promise = (globalThis as any).__scriptcatE2EStorage("set", items) as Promise<void>;
191+
promise.then(() => callback?.());
192+
return promise;
193+
},
194+
remove(keys: string | string[], callback?: () => void) {
195+
const promise = (globalThis as any).__scriptcatE2EStorage("remove", keys) as Promise<void>;
196+
promise.then(() => callback?.());
197+
return promise;
198+
},
199+
clear(callback?: () => void) {
200+
const promise = (globalThis as any).__scriptcatE2EStorage("clear") as Promise<void>;
201+
promise.then(() => callback?.());
202+
return promise;
203+
},
204+
getBytesInUse(_keys?: unknown, callback?: (bytes: number) => void) {
205+
callback?.(0);
206+
return Promise.resolve(0);
207+
},
208+
onChanged: { addListener() {}, removeListener() {} },
209+
};
210+
const respond = async (message: unknown, callback?: (response: unknown) => void) => {
211+
const response = await (globalThis as any).__scriptcatE2EMessage(message);
212+
callback?.(response);
213+
const action = (message as { action?: string })?.action || "";
214+
const data = (message as { data?: any })?.data;
215+
if (action === "serviceWorker/script/install" && data?.script) {
216+
publishMessageQueue("installScript", { script: data.script, update: false });
217+
}
218+
if (action === "serviceWorker/script/enable") {
219+
publishMessageQueue("enableScripts", [{ uuid: data?.uuid, enable: data?.enable }]);
220+
}
221+
if (action === "serviceWorker/script/deletes") {
222+
publishMessageQueue(
223+
"deleteScripts",
224+
(Array.isArray(data) ? data : []).map((uuid: string) => ({ uuid }))
225+
);
226+
}
227+
return response;
228+
};
229+
const chromeMock = {
230+
extension: { inIncognitoContext: false },
231+
i18n: {
232+
getMessage(key: string) {
233+
return key;
234+
},
235+
getUILanguage() {
236+
return "en-US";
237+
},
238+
getAcceptLanguages(callback?: (languages: string[]) => void) {
239+
callback?.(["en-US"]);
240+
return Promise.resolve(["en-US"]);
241+
},
242+
},
243+
runtime: {
244+
lastError: undefined,
245+
id: "scriptcat-firefox-file-e2e",
246+
getURL(filePath: string) {
247+
return `${baseUrl}/${filePath.replace(/^\/+/, "")}`;
248+
},
249+
getManifest() {
250+
return { manifest_version: 3, permissions: [], optional_permissions: [] };
251+
},
252+
reload() {},
253+
sendMessage(message: unknown, callback?: (response: unknown) => void) {
254+
void respond(message, callback);
255+
},
256+
connect() {
257+
return {
258+
name: "",
259+
sender: undefined,
260+
onMessage: {
261+
addListener(listener: (...args: any[]) => void) {
262+
callbacks.add(listener);
263+
},
264+
removeListener(listener: (...args: any[]) => void) {
265+
callbacks.delete(listener);
266+
},
267+
},
268+
onDisconnect: { addListener() {}, removeListener() {} },
269+
postMessage(message: unknown) {
270+
callbacks.forEach((listener) => listener(message));
271+
},
272+
disconnect() {},
273+
};
274+
},
275+
onMessage: {
276+
addListener(listener: (...args: any[]) => void) {
277+
runtimeMessageListeners.add(listener);
278+
},
279+
removeListener(listener: (...args: any[]) => void) {
280+
runtimeMessageListeners.delete(listener);
281+
},
282+
},
283+
onConnect: { addListener() {}, removeListener() {} },
284+
},
285+
storage: { local: storageArea, sync: storageArea, session: storageArea },
286+
permissions: {
287+
contains(_permissions: unknown, callback?: (result: boolean) => void) {
288+
callback?.(true);
289+
},
290+
request(_permissions: unknown, callback?: (result: boolean) => void) {
291+
callback?.(true);
292+
},
293+
remove(_permissions: unknown, callback?: (result: boolean) => void) {
294+
callback?.(true);
295+
},
296+
onAdded: { addListener() {}, removeListener() {} },
297+
onRemoved: { addListener() {}, removeListener() {} },
298+
},
299+
tabs: {
300+
query(_query: unknown, callback?: (tabs: unknown[]) => void) {
301+
callback?.([]);
302+
},
303+
create(createProperties: unknown, callback?: (tab: unknown) => void) {
304+
callback?.({ id: 1, ...(createProperties as object) });
305+
},
306+
sendMessage(_tabId: number, message: unknown, callback?: (response: unknown) => void) {
307+
void respond(message, callback);
308+
},
309+
onActivated: { addListener() {}, removeListener() {} },
310+
onUpdated: { addListener() {}, removeListener() {} },
311+
onRemoved: { addListener() {}, removeListener() {} },
312+
},
313+
action: {
314+
setIcon(_details: unknown, callback?: () => void) {
315+
callback?.();
316+
},
317+
},
318+
contextMenus: {
319+
create() {},
320+
removeAll(callback?: () => void) {
321+
callback?.();
322+
},
323+
},
324+
notifications: {
325+
create(_id: string, _options: unknown, callback?: (id: string) => void) {
326+
callback?.("mock");
327+
},
328+
clear(_id: string, callback?: () => void) {
329+
callback?.();
330+
},
331+
},
332+
};
333+
(globalThis as any).chrome = chromeMock;
334+
(globalThis as any).browser = chromeMock;
335+
},
336+
{ baseUrl: `file://${extensionDir}` }
337+
);
338+
}
339+
340+
function ensureFirefoxExtensionDir(): string {
341+
if (firefoxExtensionDir) return firefoxExtensionDir;
342+
343+
const zipPath = path.resolve(__dirname, `../dist/${packageInfo.name}-v${packageInfo.version}-firefox.zip`);
344+
if (!fs.existsSync(zipPath)) {
345+
throw new Error(`Firefox extension package not found: ${zipPath}. Run PACK_FIREFOX=true pnpm run pack first.`);
346+
}
347+
348+
firefoxExtensionDir = fs.mkdtempSync(path.join(os.tmpdir(), "scriptcat-firefox-ext-"));
349+
execFileSync("unzip", ["-q", "-o", zipPath, "-d", firefoxExtensionDir], { stdio: "ignore" });
350+
return firefoxExtensionDir;
351+
}
352+
20353
/**
21354
* 简单启动 fixture — 不需要 userScripts 的测试使用
22355
*/
@@ -26,6 +359,21 @@ export const test = base.extend<{
26359
}>({
27360
// eslint-disable-next-line no-empty-pattern
28361
context: async ({}, use) => {
362+
if (process.env.E2E_BROWSER === "firefox") {
363+
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "pw-ff-ext-"));
364+
const extensionDir = ensureFirefoxExtensionDir();
365+
const context = await firefox.launchPersistentContext(userDataDir, {
366+
headless: true,
367+
...getProxyOptions(),
368+
});
369+
await installFirefoxPageMocks(context, extensionDir);
370+
firefoxExtensionOrigin = `file://${extensionDir}`;
371+
await use(context);
372+
await context.close();
373+
fs.rmSync(userDataDir, { recursive: true, force: true });
374+
return;
375+
}
376+
29377
const context = await chromium.launchPersistentContext("", {
30378
headless: false,
31379
args: ["--headless=new", ...chromeArgs],
@@ -35,6 +383,14 @@ export const test = base.extend<{
35383
await context.close();
36384
},
37385
extensionId: async ({ context }, use) => {
386+
if (process.env.E2E_BROWSER === "firefox") {
387+
if (!firefoxExtensionOrigin) {
388+
throw new Error("Unable to resolve Firefox extension origin");
389+
}
390+
await use(firefoxExtensionOrigin);
391+
return;
392+
}
393+
38394
let [background] = context.serviceWorkers();
39395
if (!background) {
40396
background = await context.waitForEvent("serviceworker");

0 commit comments

Comments
 (0)