Skip to content

Commit 658ce5e

Browse files
fix(test): capture MMConnect deeplink from SDK session_request
Location.href hooks miss Chrome-sealed setters; rebuild metamask:// from the SDK session payload and wait longer for transport session_request. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 086baa0 commit 658ce5e

1 file changed

Lines changed: 115 additions & 73 deletions

File tree

tests/framework/ChromeCdpHelpers.ts

Lines changed: 115 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ const logger = createPlaywrightLogger('ChromeCdpHelpers');
1313
const CDP_FORWARD_PORT = 9222;
1414
const CDP_READY_TIMEOUT_MS = 20_000;
1515
const 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;
1718
const POLL_MS = 500;
1819

1920
interface 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

5149
class 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

Comments
 (0)