@@ -28,6 +28,119 @@ export const DEFAULT_CHROME_FLAGS = ChromeLauncher.defaultFlags().filter(
28
28
( flag ) => ! EXCLUDED_CHROME_FLAGS . includes ( flag ) ,
29
29
) ;
30
30
31
+ // This is a client for the Chrome Devtools protocol. The methods and results
32
+ // are documented at https://chromedevtools.github.io/devtools-protocol/tot/
33
+ class ChromeDevtoolsProtocolClient {
34
+ #receivedData = '' ;
35
+ #isProcessingMessage = false ;
36
+ #lastId = 0 ;
37
+ #deferredResponses = new Map ( ) ;
38
+ #disconnected = false ;
39
+ #disconnectedPromise;
40
+ #resolveDisconnectedPromise;
41
+
42
+ // Print all exchanged CDP messages to ease debugging.
43
+ TEST_LOG_VERBOSE_CDP = process . env . TEST_LOG_VERBOSE_CDP ;
44
+
45
+ constructor ( chromiumInstance ) {
46
+ // remoteDebuggingPipes is from chrome-launcher, see
47
+ // https://github.com/GoogleChrome/chrome-launcher/pull/347
48
+ const { incoming, outgoing } = chromiumInstance . remoteDebuggingPipes ;
49
+ this . #disconnectedPromise = new Promise ( ( resolve ) => {
50
+ this . #resolveDisconnectedPromise = resolve ;
51
+ } ) ;
52
+ if ( incoming . closed ) {
53
+ // Strange. Did Chrome fail to start, or exit on startup?
54
+ log . warn ( 'CDP already disconnected at initialization' ) ;
55
+ this . #finalizeDisconnect( ) ;
56
+ return ;
57
+ }
58
+ incoming . on ( 'data' , ( data ) => {
59
+ this . #receivedData += data ;
60
+ this . #processNextMessage( ) ;
61
+ } ) ;
62
+ incoming . on ( 'error' , ( error ) => {
63
+ log . error ( error ) ;
64
+ this . #finalizeDisconnect( ) ;
65
+ } ) ;
66
+ incoming . on ( 'close' , ( ) => this . #finalizeDisconnect( ) ) ;
67
+ this . outgoingPipe = outgoing ;
68
+ }
69
+
70
+ waitUntilDisconnected ( ) {
71
+ return this . #disconnectedPromise;
72
+ }
73
+
74
+ async sendCommand ( method , params , sessionId = undefined ) {
75
+ if ( this . #disconnected) {
76
+ throw new Error ( `CDP disconnected, cannot send: command ${ method } ` ) ;
77
+ }
78
+ const message = {
79
+ id : ++ this . #lastId,
80
+ method,
81
+ params,
82
+ sessionId,
83
+ } ;
84
+ const rawMessage = `${ JSON . stringify ( message ) } \x00` ;
85
+ if ( this . TEST_LOG_VERBOSE_CDP ) {
86
+ process . stderr . write ( `[CDP] [SEND] ${ rawMessage } \n` ) ;
87
+ }
88
+ return new Promise ( ( resolve , reject ) => {
89
+ // CDP will always send a response.
90
+ this . #deferredResponses. set ( message . id , { method, resolve, reject } ) ;
91
+ this . outgoingPipe . write ( rawMessage ) ;
92
+ } ) ;
93
+ }
94
+
95
+ #processNextMessage( ) {
96
+ if ( this . #isProcessingMessage) {
97
+ return ;
98
+ }
99
+ this . #isProcessingMessage = true ;
100
+ let end = this . #receivedData. indexOf ( '\x00' ) ;
101
+ while ( end !== - 1 ) {
102
+ const rawMessage = this . #receivedData. slice ( 0 , end ) ;
103
+ this . #receivedData = this . #receivedData. slice ( end + 1 ) ; // +1 skips \x00.
104
+ try {
105
+ if ( this . TEST_LOG_VERBOSE_CDP ) {
106
+ process . stderr . write ( `[CDP] [RECV] ${ rawMessage } \n` ) ;
107
+ }
108
+ const { id, error, result } = JSON . parse ( rawMessage ) ;
109
+ const deferredResponse = this . #deferredResponses. get ( id ) ;
110
+ if ( deferredResponse ) {
111
+ this . #deferredResponses. delete ( id ) ;
112
+ if ( error ) {
113
+ const err = new Error ( error . message || 'Unexpected CDP response' ) ;
114
+ deferredResponse . reject ( err ) ;
115
+ } else {
116
+ deferredResponse . resolve ( result ) ;
117
+ }
118
+ } else {
119
+ // Dropping events and non-response messages since we don't need it.
120
+ }
121
+ } catch ( e ) {
122
+ log . error ( e ) ;
123
+ }
124
+ end = this . #receivedData. indexOf ( '\x00' ) ;
125
+ }
126
+ this . #isProcessingMessage = false ;
127
+ if ( this . #disconnected) {
128
+ for ( const { method, reject } of this . #deferredResponses. values ( ) ) {
129
+ reject ( new Error ( `CDP connection closed before response to ${ method } ` ) ) ;
130
+ }
131
+ this . #deferredResponses. clear ( ) ;
132
+ this . #resolveDisconnectedPromise( ) ;
133
+ }
134
+ }
135
+
136
+ #finalizeDisconnect( ) {
137
+ if ( ! this . #disconnected) {
138
+ this . #disconnected = true ;
139
+ this . #processNextMessage( ) ;
140
+ }
141
+ }
142
+ }
143
+
31
144
/**
32
145
* Implements an IExtensionRunner which manages a Chromium instance.
33
146
*/
@@ -36,13 +149,17 @@ export class ChromiumExtensionRunner {
36
149
params ;
37
150
chromiumInstance ;
38
151
chromiumLaunch ;
152
+ // --load-extension is deprecated, but only supported in Chrome 126+, see:
153
+ // https://github.com/mozilla/web-ext/issues/3388#issuecomment-2906982117
154
+ forceUseDeprecatedLoadExtension ;
39
155
exiting ;
40
156
_promiseSetupDone ;
41
157
42
158
constructor ( params ) {
43
159
const { chromiumLaunch = defaultChromiumLaunch } = params ;
44
160
this . params = params ;
45
161
this . chromiumLaunch = chromiumLaunch ;
162
+ this . forceUseDeprecatedLoadExtension = true ;
46
163
this . cleanupCallbacks = new Set ( ) ;
47
164
}
48
165
@@ -108,9 +225,7 @@ export class ChromiumExtensionRunner {
108
225
*/
109
226
async setupInstance ( ) {
110
227
// Start chrome pointing it to a given profile dir
111
- const extensions = this . params . extensions
112
- . map ( ( { sourceDir } ) => sourceDir )
113
- . join ( ',' ) ;
228
+ const extensions = this . params . extensions . map ( ( { sourceDir } ) => sourceDir ) ;
114
229
115
230
const { chromiumBinary } = this . params ;
116
231
@@ -121,8 +236,11 @@ export class ChromiumExtensionRunner {
121
236
}
122
237
123
238
const chromeFlags = [ ...DEFAULT_CHROME_FLAGS ] ;
239
+ chromeFlags . push ( '--remote-debugging-pipe' ) ;
124
240
125
- chromeFlags . push ( `--load-extension=${ extensions } ` ) ;
241
+ if ( this . forceUseDeprecatedLoadExtension ) {
242
+ chromeFlags . push ( `--load-extension=${ extensions . join ( ',' ) } ` ) ;
243
+ }
126
244
127
245
if ( this . params . args ) {
128
246
chromeFlags . push ( ...this . params . args ) ;
@@ -189,6 +307,7 @@ export class ChromiumExtensionRunner {
189
307
// Ignore default flags to keep the extension enabled.
190
308
ignoreDefaultFlags : true ,
191
309
} ) ;
310
+ this . cdp = new ChromeDevtoolsProtocolClient ( this . chromiumInstance ) ;
192
311
193
312
this . chromiumInstance . process . once ( 'close' , ( ) => {
194
313
this . chromiumInstance = null ;
@@ -198,6 +317,17 @@ export class ChromiumExtensionRunner {
198
317
this . exit ( ) ;
199
318
}
200
319
} ) ;
320
+
321
+ // Connect with the CDP to verify that we are indeed connected to Chrome.
322
+ // This works with Chrome 69 and later, see
323
+ // https://github.com/mozilla/web-ext/issues/3388#issuecomment-2906982117
324
+ try {
325
+ log . debug ( 'Verifying chrome devtools protocol connection...' ) ;
326
+ const { userAgent } = await this . cdp . sendCommand ( 'Browser.getVersion' ) ;
327
+ log . info ( `Launched Chromium: ${ userAgent } ` ) ;
328
+ } catch ( e ) {
329
+ log . error ( e ) ;
330
+ }
201
331
}
202
332
203
333
/**
@@ -207,7 +337,9 @@ export class ChromiumExtensionRunner {
207
337
async reloadAllExtensions ( ) {
208
338
const runnerName = this . getName ( ) ;
209
339
210
- // TODO: Restore reload functionality using the remote debugging protocol.
340
+ if ( this . forceUseDeprecatedLoadExtension ) {
341
+ this . reloadAllExtensionsFallbackForChrome125andEarlier ( ) ;
342
+ }
211
343
212
344
process . stdout . write (
213
345
`\rLast extension reload: ${ new Date ( ) . toTimeString ( ) } ` ,
@@ -217,6 +349,136 @@ export class ChromiumExtensionRunner {
217
349
return [ { runnerName } ] ;
218
350
}
219
351
352
+ async reloadAllExtensionsFallbackForChrome125andEarlier ( ) {
353
+ // Ideally, we'd like to use the "Extensions.loadUnpacked" CDP command to
354
+ // reload an extension, but that is unsupported in Chrome 125 and earlier.
355
+ //
356
+ // As a fallback, connect to chrome://extensions/ and reload from there.
357
+ // Since we are targeting old Chrome versions, we can safely use the
358
+ // chrome.developerPrivate APIs, because these are never going to change
359
+ // for the old browser versions. Do NOT use this for newer versions!
360
+ //
361
+ // Target.* CDP methods documented at: https://chromedevtools.github.io/devtools-protocol/tot/Target/
362
+ // developerPrivate documented at:
363
+ // https://source.chromium.org/chromium/chromium/src/+/main:chrome/common/extensions/api/developer_private.idl
364
+ //
365
+ // Specific revision that exposed developerPrivate to chrome://extensions/:
366
+ // https://source.chromium.org/chromium/chromium/src/+/main:chrome/common/extensions/api/developer_private.idl;drc=69bf75316e7ae533c0a0dccc1a56ca019aa95a1e
367
+ // https://chromium.googlesource.com/chromium/src.git/+/69bf75316e7ae533c0a0dccc1a56ca019aa95a1e
368
+ //
369
+ // Specific revision that introduced developerPrivate.getExtensionsInfo:
370
+ // https://source.chromium.org/chromium/chromium/src/+/main:chrome/common/extensions/api/developer_private.idl;drc=69bf75316e7ae533c0a0dccc1a56ca019aa95a1e
371
+ //
372
+ // The above changes are from 2015; The --remote-debugging-pipe feature
373
+ // that we rely on for CDP was added in 2018; this is the version of the
374
+ // developerPrivate API at that time:
375
+ // https://source.chromium.org/chromium/chromium/src/+/main:chrome/common/extensions/api/developer_private.idl;drc=c9ae59c8f37d487f1f01c222deb6b7d1f51c99c2
376
+
377
+ // Find an existing chrome://extensions/ tab, if it exists.
378
+ let { targetInfos : targets } = await this . cdp . sendCommand (
379
+ 'Target.getTargets' ,
380
+ { filter : [ { type : 'tab' } ] } ,
381
+ ) ;
382
+ targets = targets . filter ( ( t ) => t . url . startsWith ( 'chrome://extensions/' ) ) ;
383
+ let targetId ;
384
+ const hasExistingTarget = targets . length > 0 ;
385
+ if ( hasExistingTarget ) {
386
+ targetId = targets [ 0 ] . targetId ;
387
+ } else {
388
+ const result = await this . cdp . sendCommand ( 'Target.createTarget' , {
389
+ url : 'chrome://extensions/' ,
390
+ newWindow : true ,
391
+ background : true ,
392
+ windowState : 'minimized' ,
393
+ } ) ;
394
+ targetId = result . targetId ;
395
+ }
396
+ const codeToEvaluateInChrome = async ( ) => {
397
+ // This function is serialized and executed in Chrome. Designed for
398
+ // compatibility with Chrome 69 - 125. Do not use JS syntax of functions
399
+ // that are not supported in these versions!
400
+
401
+ // eslint-disable-next-line no-undef
402
+ const developerPrivate = chrome . developerPrivate ;
403
+ if ( ! developerPrivate || ! developerPrivate . getExtensionsInfo ) {
404
+ // When chrome://extensions/ is still loading, its document URL may be
405
+ // about:blank and the chrome.developerPrivate API is not exposed.
406
+ return 'NOT_READY_PLEASE_RETRY' ;
407
+ }
408
+ const extensionIds = [ ] ;
409
+ await new Promise ( ( resolve ) => {
410
+ developerPrivate . getExtensionsInfo ( ( extensions ) => {
411
+ for ( const extension of extensions || [ ] ) {
412
+ if ( extension . location === 'UNPACKED' ) {
413
+ // We only care about those loaded via --load-extension.
414
+ extensionIds . push ( extension . id ) ;
415
+ }
416
+ }
417
+ resolve ( ) ;
418
+ } ) ;
419
+ } ) ;
420
+ const reloadPromises = extensionIds . map ( ( extensionId ) => {
421
+ return new Promise ( ( resolve , reject ) => {
422
+ developerPrivate . reload (
423
+ extensionId ,
424
+ // Suppress alert dialog when load fails.
425
+ { failQuietly : true , populateErrorForUnpacked : true } ,
426
+ ( loadError ) => {
427
+ if ( loadError ) {
428
+ reject ( new Error ( loadError . error ) ) ;
429
+ } else {
430
+ resolve ( ) ;
431
+ }
432
+ } ,
433
+ ) ;
434
+ } ) ;
435
+ } ) ;
436
+ await Promise . all ( reloadPromises ) ;
437
+ return reloadPromises . length ;
438
+ } ;
439
+ try {
440
+ const targetResult = await this . cdp . sendCommand ( 'Target.attachToTarget' , {
441
+ targetId,
442
+ flatten : true ,
443
+ } ) ;
444
+ if ( ! targetResult . sessionId ) {
445
+ throw new Error ( 'Unexpectedly, no sessionId from attachToTarget' ) ;
446
+ }
447
+ // In practice, we're going to run the logic only once. But if we are
448
+ // unlucky, chrome://extensions is still loading, so we will then retry.
449
+ for ( let i = 0 ; i < 3 ; ++ i ) {
450
+ const evalResult = await this . cdp . sendCommand (
451
+ 'Runtime.evaluate' ,
452
+ {
453
+ expression : `(${ codeToEvaluateInChrome } )();` ,
454
+ awaitPromise : true ,
455
+ } ,
456
+ targetResult . sessionId ,
457
+ ) ;
458
+ const evalResultReturnValue = evalResult . result ?. value ;
459
+ if ( evalResultReturnValue === 'NOT_READY_PLEASE_RETRY' ) {
460
+ await new Promise ( ( r ) => setTimeout ( r , 200 * i ) ) ;
461
+ continue ;
462
+ }
463
+ if ( evalResult . exceptionDetails ) {
464
+ log . error ( `Failed to reload: ${ evalResult . exceptionDetails . text } ` ) ;
465
+ }
466
+ if ( evalResultReturnValue !== this . params . extensions . length ) {
467
+ log . warn ( `Failed to reload extensions: ${ evalResultReturnValue } ` ) ;
468
+ }
469
+ break ;
470
+ }
471
+ } finally {
472
+ if ( ! hasExistingTarget && targetId ) {
473
+ try {
474
+ await this . cdp . sendCommand ( 'Target.closeTarget' , { targetId } ) ;
475
+ } catch ( e ) {
476
+ log . error ( e ) ;
477
+ }
478
+ }
479
+ }
480
+ }
481
+
220
482
/**
221
483
* Reloads a single extension, collect any reload error and resolves to
222
484
* an array composed by a single ExtensionRunnerReloadResult object.
@@ -259,6 +521,11 @@ export class ChromiumExtensionRunner {
259
521
this . chromiumInstance = null ;
260
522
}
261
523
524
+ if ( this . cdp ) {
525
+ await this . cdp . waitUntilDisconnected ( ) ;
526
+ this . cdp = null ;
527
+ }
528
+
262
529
// Call all the registered cleanup callbacks.
263
530
for ( const fn of this . cleanupCallbacks ) {
264
531
try {
0 commit comments