11import fs from "fs" ;
2+ import { execFileSync } from "child_process" ;
23import os from "os" ;
34import 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
67const 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
815function getProxyOptions ( ) {
916 const proxy =
@@ -17,6 +24,332 @@ function getProxyOptions() {
1724
1825const 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