@@ -13,7 +13,8 @@ const logger = createPlaywrightLogger('ChromeCdpHelpers');
1313const CDP_FORWARD_PORT = 9222 ;
1414const CDP_READY_TIMEOUT_MS = 20_000 ;
1515const DAPP_PAGE_TIMEOUT_MS = 30_000 ;
16- const DEEPLINK_CAPTURE_TIMEOUT_MS = 8_000 ;
16+ /** SDK opens metamask:// only after transport `session_request` (can take several seconds). */
17+ const DEEPLINK_CAPTURE_TIMEOUT_MS = 30_000 ;
1718const POLL_MS = 500 ;
1819
1920interface ChromeContextInfo {
@@ -43,10 +44,7 @@ interface CdpEvaluateResult {
4344 exceptionDetails ?: { text ?: string ; exception ?: { description ?: string } } ;
4445}
4546
46- interface ElementCenter {
47- x : number ;
48- y : number ;
49- }
47+ type CdpEventHandler = ( params : Record < string , unknown > ) => void ;
5048
5149class CdpSession {
5250 private readonly ws : WsClient ;
@@ -55,16 +53,28 @@ class CdpSession {
5553 number ,
5654 { resolve : ( value : unknown ) => void ; reject : ( error : Error ) => void }
5755 > ( ) ;
56+ private readonly eventHandlers = new Map < string , Set < CdpEventHandler > > ( ) ;
5857
5958 private constructor ( ws : WsClient ) {
6059 this . ws = ws ;
6160 this . ws . on ( 'message' , ( data ) => {
6261 try {
6362 const msg = JSON . parse ( String ( data ) ) as {
6463 id ?: number ;
64+ method ?: string ;
65+ params ?: Record < string , unknown > ;
6566 result ?: unknown ;
6667 error ?: { message ?: string } ;
6768 } ;
69+ if ( msg . method ) {
70+ const handlers = this . eventHandlers . get ( msg . method ) ;
71+ if ( handlers ) {
72+ for ( const handler of handlers ) {
73+ handler ( msg . params ?? { } ) ;
74+ }
75+ }
76+ return ;
77+ }
6878 if ( msg . id == null ) return ;
6979 const waiter = this . pending . get ( msg . id ) ;
7080 if ( ! waiter ) return ;
@@ -89,6 +99,15 @@ class CdpSession {
8999 } ) ;
90100 }
91101
102+ on ( method : string , handler : CdpEventHandler ) : void {
103+ let handlers = this . eventHandlers . get ( method ) ;
104+ if ( ! handlers ) {
105+ handlers = new Set ( ) ;
106+ this . eventHandlers . set ( method , handlers ) ;
107+ }
108+ handlers . add ( handler ) ;
109+ }
110+
92111 async send (
93112 method : string ,
94113 params : Record < string , unknown > = { } ,
@@ -140,6 +159,7 @@ class CdpSession {
140159 waiter . reject ( new Error ( 'CDP session closed' ) ) ;
141160 }
142161 this . pending . clear ( ) ;
162+ this . eventHandlers . clear ( ) ;
143163 this . ws . close ( ) ;
144164 }
145165}
@@ -227,9 +247,12 @@ export default class ChromeCdpHelpers {
227247 /**
228248 * Click a connect control that should open MetaMask via deeplink.
229249 *
230- * Captures the SDK's `metamask://` / app-link URL (navigation is often blocked
231- * without lasting user activation after `setTimeout`) and opens it with
232- * Appium `mobile: deepLink` scoped to the MetaMask package.
250+ * The Multichain SDK emits `display_uri` / builds `metamask://connect/mwp?…`
251+ * only after transport `session_request` — not synchronously on click. Chrome
252+ * often seals `Location.prototype`, so we capture via:
253+ * - CDP Page navigation events
254+ * - reconstructing the URL when the SDK JSON.stringifies the session request
255+ * then open it with Appium `mobile: deepLink`.
233256 */
234257 static async waitAndClickTestIdOpeningMetaMask (
235258 dappUrl : string ,
@@ -238,10 +261,31 @@ export default class ChromeCdpHelpers {
238261 ) : Promise < void > {
239262 await this . waitForTestId ( dappUrl , testId , timeoutMs ) ;
240263 await this . withCdpSession ( dappUrl , async ( session ) => {
264+ const captured : string [ ] = [ ] ;
265+ const remember = ( url : unknown ) => {
266+ if ( url == null ) return ;
267+ const value = String ( url ) ;
268+ if ( ! value || value === 'about:blank' ) return ;
269+ if ( ! captured . includes ( value ) ) {
270+ captured . push ( value ) ;
271+ }
272+ } ;
273+
274+ await session . send ( 'Page.enable' ) ;
275+ session . on ( 'Page.frameRequestedNavigation' , ( params ) => {
276+ remember ( params . url ) ;
277+ } ) ;
278+ session . on ( 'Page.windowOpen' , ( params ) => {
279+ remember ( params . url ) ;
280+ } ) ;
281+ session . on ( 'Page.navigatedWithinDocument' , ( params ) => {
282+ remember ( params . url ) ;
283+ } ) ;
284+
241285 await this . installDeeplinkCapture ( session ) ;
242286 await this . dispatchTrustedClick ( session , testId ) ;
243287
244- const deeplink = await this . waitForCapturedDeeplink ( session ) ;
288+ const deeplink = await this . waitForCapturedDeeplink ( session , captured ) ;
245289 logger . debug (
246290 `Opening captured MM Connect deeplink via Appium: ${ deeplink } ` ,
247291 ) ;
@@ -254,20 +298,46 @@ export default class ChromeCdpHelpers {
254298 private static async installDeeplinkCapture (
255299 session : CdpSession ,
256300 ) : Promise < void > {
301+ // Capture URL when the SDK builds it (createConnectionDeeplink), which
302+ // happens on session_request — before (and even if) location.href is blocked.
257303 await session . evaluate ( `(() => {
258304 const w = window;
259- if (w.__mmCdpDeeplinkHookInstalled) {
260- w.__mmCdpDeeplinks = [];
261- return true;
262- }
263305 w.__mmCdpDeeplinks = [];
264- w.__mmCdpDeeplinkHookInstalled = true;
265306 const push = (url) => {
266307 if (url == null) return;
267308 const value = String(url);
268309 if (!value || value === 'about:blank') return;
269- w.__mmCdpDeeplinks.push(value);
310+ if (!w.__mmCdpDeeplinks.includes(value)) {
311+ w.__mmCdpDeeplinks.push(value);
312+ }
313+ };
314+
315+ const toMwpUrl = (scheme, payloadJson) => {
316+ const bytes = new TextEncoder().encode(payloadJson);
317+ let binary = '';
318+ bytes.forEach((b) => { binary += String.fromCharCode(b); });
319+ const b64 = btoa(binary);
320+ return scheme + '/mwp?p=' + encodeURIComponent(b64) + '&c=1';
321+ };
322+
323+ const origStringify = JSON.stringify.bind(JSON);
324+ JSON.stringify = (value, ...args) => {
325+ const out = origStringify(value, ...args);
326+ try {
327+ if (
328+ value &&
329+ typeof value === 'object' &&
330+ value.sessionRequest &&
331+ value.metadata &&
332+ value.metadata.dapp
333+ ) {
334+ push(toMwpUrl('metamask://connect', out));
335+ push(toMwpUrl('https://metamask.app.link/connect', out));
336+ }
337+ } catch (_) {}
338+ return out;
270339 };
340+
271341 try {
272342 const desc = Object.getOwnPropertyDescriptor(Location.prototype, 'href');
273343 if (desc && desc.set && desc.get) {
@@ -281,62 +351,56 @@ export default class ChromeCdpHelpers {
281351 },
282352 });
283353 }
284- } catch (_) {
285- // Location.prototype may be non-configurable on some Chromium builds.
286- }
287- try {
288- const loc = window.location;
289- const origAssign = loc.assign.bind(loc);
290- loc.assign = (url) => {
291- push(url);
292- return origAssign(url);
293- };
294- const origReplace = loc.replace.bind(loc);
295- loc.replace = (url) => {
296- push(url);
297- return origReplace(url);
298- };
299- } catch (_) {
300- // location.assign/replace may be non-writable.
301- }
354+ } catch (_) {}
355+
302356 try {
303357 const origOpen = window.open.bind(window);
304358 window.open = (url, ...args) => {
305359 push(url);
306360 return origOpen(url, ...args);
307361 };
308- } catch (_) {
309- // ignore
310- }
362+ } catch (_) {}
363+
311364 try {
312365 const origClick = HTMLAnchorElement.prototype.click;
313366 HTMLAnchorElement.prototype.click = function (...args) {
314367 push(this.href);
315368 return origClick.apply(this, args);
316369 };
317- } catch (_) {
318- // ignore
319- }
370+ } catch (_) {}
371+
320372 return true;
321373 })()` ) ;
322374 }
323375
324376 private static async waitForCapturedDeeplink (
325377 session : CdpSession ,
378+ cdpCaptured : string [ ] ,
326379 ) : Promise < string > {
327380 const deadline = Date . now ( ) + DEEPLINK_CAPTURE_TIMEOUT_MS ;
328381 while ( Date . now ( ) < deadline ) {
329- const urls = await session . evaluate < string [ ] > (
382+ const pageUrls = await session . evaluate < string [ ] > (
330383 `Array.isArray(window.__mmCdpDeeplinks) ? window.__mmCdpDeeplinks.slice() : []` ,
331384 ) ;
332- const match = urls . find ( ( url ) => this . isMetaMaskConnectUrl ( url ) ) ;
385+ const all = [ ...cdpCaptured , ...pageUrls ] ;
386+ // Prefer native scheme for Appium deepLink package scoping.
387+ const native = all . find ( ( url ) => url . startsWith ( 'metamask://' ) ) ;
388+ if ( native ) {
389+ return native ;
390+ }
391+ const match = all . find ( ( url ) => this . isMetaMaskConnectUrl ( url ) ) ;
333392 if ( match ) {
334393 return match ;
335394 }
336395 await new Promise ( ( r ) => setTimeout ( r , POLL_MS ) ) ;
337396 }
397+
398+ const pageUrls = await session . evaluate < string [ ] > (
399+ `Array.isArray(window.__mmCdpDeeplinks) ? window.__mmCdpDeeplinks.slice() : []` ,
400+ ) ;
338401 throw new Error (
339- `CDP: MetaMask connect deeplink was not captured within ${ DEEPLINK_CAPTURE_TIMEOUT_MS } ms` ,
402+ `CDP: MetaMask connect deeplink was not captured within ${ DEEPLINK_CAPTURE_TIMEOUT_MS } ms` +
403+ ` (cdp=${ JSON . stringify ( cdpCaptured ) } ; page=${ JSON . stringify ( pageUrls ) } )` ,
340404 ) ;
341405 }
342406
@@ -359,43 +423,21 @@ export default class ChromeCdpHelpers {
359423 session : CdpSession ,
360424 testId : string ,
361425 ) : Promise < void > {
362- const center = await session . evaluate < ElementCenter | null > (
426+ // Single activation only — a second click while CONNECTING fails the dapp.
427+ // userGesture:true matches the path that previously reached CONNECTING in CI.
428+ const clicked = await session . evaluate < boolean > (
363429 `(() => {
364430 const el = document.querySelector('[data-testid=${ JSON . stringify ( testId ) } ]');
365- if (!el) return null ;
431+ if (!el || typeof el.click !== 'function' ) return false ;
366432 el.scrollIntoView({ block: 'center', inline: 'center' });
367- const rect = el.getBoundingClientRect();
368- if (rect.width <= 0 || rect.height <= 0) return null;
369- return {
370- x: rect.left + rect.width / 2,
371- y: rect.top + rect.height / 2,
372- };
433+ el.click();
434+ return true;
373435 })()` ,
436+ { userGesture : true } ,
374437 ) ;
375- if ( ! center ) {
376- throw new Error (
377- `CDP: could not resolve click target for [data-testid="${ testId } "]` ,
378- ) ;
438+ if ( ! clicked ) {
439+ throw new Error ( `CDP: could not click [data-testid="${ testId } "]` ) ;
379440 }
380-
381- // Real input events preserve transient user activation across the SDK's
382- // setTimeout(..., 10) before window.location.href = metamask://...
383- await session . send ( 'Input.dispatchMouseEvent' , {
384- type : 'mousePressed' ,
385- x : center . x ,
386- y : center . y ,
387- button : 'left' ,
388- buttons : 1 ,
389- clickCount : 1 ,
390- } ) ;
391- await session . send ( 'Input.dispatchMouseEvent' , {
392- type : 'mouseReleased' ,
393- x : center . x ,
394- y : center . y ,
395- button : 'left' ,
396- buttons : 0 ,
397- clickCount : 1 ,
398- } ) ;
399441 }
400442
401443 private static async withCdpSession < T > (
0 commit comments