22 * Returns the Chrome/Firefox extension base URL:
33 * - Chrome: chrome-extension://<id>
44 * - Firefox: moz-extension://<uuid>
5+ *
6+ * CI fix:
7+ * Prefer CDP Target.getTargets (works in headless CI) and fall back to chrome://extensions scraping.
58 */
69
10+ import { URL } from 'node:url' ;
11+
712let cachedChromeExtensionPath : string | null = null ;
813
9- export const getChromeExtensionPath = async ( browser : WebdriverIO . Browser ) => {
10- if ( cachedChromeExtensionPath ) return cachedChromeExtensionPath ;
14+ type TargetInfo = { type : string ; url : string ; title ?: string } ;
15+ type GetTargetsResult = { targetInfos ?: TargetInfo [ ] } ;
1116
17+ function hostFromUrl ( raw : string ) : string | null {
18+ try {
19+ return new URL ( raw ) . host || null ;
20+ } catch {
21+ return null ;
22+ }
23+ }
24+
25+ async function tryResolveChromeExtensionIdViaCdp ( browser : WebdriverIO . Browser ) : Promise < string | null > {
26+ // Allow explicit override (useful if multiple extensions are loaded).
27+ const forced = ( process . env . RC_EXTENSION_ID ?? process . env . EXTENSION_ID ?? '' ) . trim ( ) ;
28+ if ( forced ) return forced ;
29+
30+ // browser.cdp may not be available depending on WDIO services/driver; treat as optional.
31+ const cdp = ( browser as any ) ?. cdp ;
32+ if ( typeof cdp !== 'function' ) return null ;
33+
34+ try {
35+ const res = ( await ( browser as any ) . cdp ( 'Target' , 'getTargets' , { } ) ) as GetTargetsResult ;
36+ const infos = res . targetInfos ?? [ ] ;
37+
38+ const extTargets = infos
39+ . filter ( t => typeof t . url === 'string' && t . url . startsWith ( 'chrome-extension://' ) )
40+ . map ( t => ( { ...t , id : hostFromUrl ( t . url ) } ) )
41+ . filter ( t => ! ! t . id ) as Array < TargetInfo & { id : string } > ;
42+
43+ // Prefer MV3 service worker pointing to background.js
44+ const preferred =
45+ extTargets . find ( t => t . type === 'service_worker' && / \/ b a c k g r o u n d \. j s ( \? | $ ) / . test ( t . url ) ) ??
46+ extTargets . find ( t => t . type === 'service_worker' ) ??
47+ extTargets [ 0 ] ;
48+
49+ return preferred ?. id ?? null ;
50+ } catch {
51+ return null ;
52+ }
53+ }
54+
55+ async function resolveChromeExtensionIdViaExtensionsPage ( browser : WebdriverIO . Browser ) : Promise < string > {
1256 const originalHandle = await browser . getWindowHandle ( ) ;
1357
1458 // Open extensions manager in a TAB (not a new window), so it’s less disruptive.
@@ -17,14 +61,11 @@ export const getChromeExtensionPath = async (browser: WebdriverIO.Browser) => {
1761 const mgrHandle = await browser . getWindowHandle ( ) ;
1862 await browser . switchToWindow ( mgrHandle ) ;
1963
20- // Wait for the extensions UI to exist.
2164 await browser . waitUntil ( async ( ) => ( await $ ( 'extensions-manager' ) . isExisting ( ) ) === true , {
22- timeout : 10000 ,
65+ timeout : 10_000 ,
2366 timeoutMsg : 'chrome://extensions did not load extensions-manager' ,
2467 } ) ;
2568
26- // Find an extensions-item. If you have multiple extensions loaded in this profile,
27- // you should filter by name; otherwise the first item is typically your unpacked extension.
2869 const extensionId = await ( async ( ) => {
2970 const extensionsManager = await $ ( 'extensions-manager' ) . getElement ( ) ;
3071
@@ -36,27 +77,36 @@ export const getChromeExtensionPath = async (browser: WebdriverIO.Browser) => {
3677 } ) ( ) ;
3778
3879 if ( ! extensionId ) {
39- // Best-effort diagnostics
4080 const debug = await browser . execute ( ( ) => {
4181 const mgr = document . querySelector ( 'extensions-manager' ) as any ;
4282 if ( ! mgr ?. shadowRoot ) return { hasManager : ! ! mgr , hasShadowRoot : false } ;
4383 const itemList = mgr . shadowRoot . querySelector ( '#viewManager > extensions-item-list' ) as any ;
44- return {
45- hasManager : true ,
46- hasShadowRoot : true ,
47- hasItemList : ! ! itemList ,
48- } ;
84+ return { hasManager : true , hasShadowRoot : true , hasItemList : ! ! itemList } ;
4985 } ) ;
5086
5187 throw new Error ( `Extension ID not found on chrome://extensions. Debug: ${ JSON . stringify ( debug ) } ` ) ;
5288 }
5389
54- cachedChromeExtensionPath = `chrome-extension://${ extensionId } ` ;
55-
5690 // Close chrome://extensions tab and go back
5791 await browser . closeWindow ( ) ;
5892 await browser . switchToWindow ( originalHandle ) ;
5993
94+ return extensionId ;
95+ }
96+
97+ export const getChromeExtensionPath = async ( browser : WebdriverIO . Browser ) => {
98+ if ( cachedChromeExtensionPath ) return cachedChromeExtensionPath ;
99+
100+ // CI-safe path first
101+ const cdpId = await tryResolveChromeExtensionIdViaCdp ( browser ) ;
102+ if ( cdpId ) {
103+ cachedChromeExtensionPath = `chrome-extension://${ cdpId } ` ;
104+ return cachedChromeExtensionPath ;
105+ }
106+
107+ // Fallback for local environments where CDP might not be available
108+ const uiId = await resolveChromeExtensionIdViaExtensionsPage ( browser ) ;
109+ cachedChromeExtensionPath = `chrome-extension://${ uiId } ` ;
60110 return cachedChromeExtensionPath ;
61111} ;
62112
0 commit comments