Skip to content

Commit 234844b

Browse files
authored
feat(playground): add MWP deeplink failure repro panel (#300)
Adds a collapsible 'QA: MWP deeplink failure repros' card to the browser-playground that surfaces one metamask://connect/mwp?... deeplink per failure branch in the mobile app's ConnectionRegistry.handleConnectDeeplink: - No payload param - Payload is not valid JSON - Payload parses but is not a ConnectionRequest - sessionRequest.id is not a UUID - dapp.url claims to be an internal origin - c=1 flag set but payload is plain (decompression error) - Payload over 1MB - Control happy-path request (for baseline comparison) Each row renders the URL, a Copy button, and a 'Tap on mobile' anchor that resolves on devices with MetaMask Mobile installed. This is the verification surface for the Sentry coverage added in MetaMask/metamask-mobile#30343. Lets QA reproducibly trigger each failure branch without needing a misconfigured dapp, and confirm that each one emits both a REMOTE_CONNECTION_REQUEST_FAILED MetaMetrics event and a Sentry event tagged feature: mm-connect. The panel is collapsed by default behind an explicit toggle so it does not clutter the default playground UX.
1 parent 0aa0395 commit 234844b

3 files changed

Lines changed: 240 additions & 0 deletions

File tree

playground/browser-playground/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Added an **MWP deeplink failure repros** collapsible panel (`MwpDeeplinkReproCard`) that surfaces one `metamask://connect/mwp?…` deeplink per failure branch in the mobile app's `ConnectionRegistry.handleConnectDeeplink`. Lets QA reproducibly trigger each branch (parse failures, internal-origin block, decompression mismatch, payload-too-large, etc.) without needing a misconfigured dapp, and acts as the verification surface for the Sentry coverage added in MetaMask/metamask-mobile#30343.
13+
1014
## [0.7.4]
1115

1216
### Changed

playground/browser-playground/src/App.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { ScopeCard } from './components/ScopeCard';
1515
import { LegacyEVMCard } from './components/LegacyEVMCard';
1616
import { WagmiCard } from './components/WagmiCard';
1717
import { SolanaWalletCard } from './components/SolanaWalletCard';
18+
import { MwpDeeplinkReproCard } from './components/MwpDeeplinkReproCard';
1819
import { AnalyticsTestBench } from './components/AnalyticsTestBench';
1920
import { useSolanaSDK } from './sdk/SolanaProvider';
2021
import { Buffer } from 'buffer';
@@ -471,6 +472,9 @@ function App() {
471472
</div>
472473
</section>
473474
)}
475+
<div className="mt-8">
476+
<MwpDeeplinkReproCard />
477+
</div>
474478
</div>
475479
</div>
476480
);
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
/**
2+
* MwpDeeplinkReproCard
3+
*
4+
* A QA-focused debug panel that surfaces a curated set of MWP (Mobile Wallet
5+
* Protocol) deeplinks designed to exercise both the happy path and every known
6+
* failure path inside the mobile app's `ConnectionRegistry.handleConnectDeeplink`
7+
* (`metamask-mobile/app/core/SDKConnectV2/services/connection-registry.ts`).
8+
*
9+
* Goals:
10+
* 1. Give QA / SDETs a reproducible way to trigger each failure branch without
11+
* needing a real dapp that happens to be misconfigured in just the right way.
12+
* 2. Provide a target surface for verifying Sentry coverage of those failure
13+
* branches (see MetaMask/metamask-mobile PR #30343).
14+
*
15+
* Each row renders:
16+
* - A human-readable label and the failure branch it targets
17+
* - The literal deeplink URL (so it can be copied)
18+
* - A "Tap on mobile" anchor (only useful on a mobile browser, since
19+
* `metamask://` URLs only resolve when the OS has MetaMask installed)
20+
*
21+
* NOTE: This panel is for QA only. It is collapsed by default and gated behind
22+
* an explicit toggle so it does not clutter the default playground UX.
23+
*/
24+
25+
import { useMemo, useState } from 'react';
26+
27+
type ReproRow = {
28+
id: string;
29+
label: string;
30+
expectedFailure: string;
31+
buildUrl: () => string;
32+
};
33+
34+
const VALID_REQUEST = {
35+
sessionRequest: {
36+
id: '11111111-2222-3333-4444-555555555555',
37+
publicKeyB64: 'AoBDLWxRbJNe8yUv5bmmoVnNo8DCilzbFz/nWD+RKC2V',
38+
channel: 'handshake:aabbccdd-1122-3344-5566-778899aabbcc',
39+
mode: 'trusted',
40+
// `expiresAt` is intentionally far in the future so the request itself is
41+
// not the reason a downstream check fails.
42+
expiresAt: Date.now() + 1000 * 60 * 60,
43+
},
44+
metadata: {
45+
dapp: {
46+
name: 'MMC Playground (QA Repro)',
47+
url: 'https://playground.metamask.io',
48+
},
49+
sdk: {
50+
version: '0.0.0-qa-repro',
51+
platform: 'JavaScript',
52+
},
53+
},
54+
};
55+
56+
const encode = (obj: unknown) => encodeURIComponent(JSON.stringify(obj));
57+
58+
const ROWS: ReproRow[] = [
59+
{
60+
id: 'control-happy-path',
61+
label: 'Control: well-formed connect deeplink',
62+
expectedFailure:
63+
'No failure — the mobile app should reach Connection.create and then fail at the relay handshake (which is the only step we cannot fake from a dapp). Useful as a baseline.',
64+
buildUrl: () =>
65+
`metamask://connect/mwp?p=${encode(VALID_REQUEST)}`,
66+
},
67+
{
68+
id: 'no-payload',
69+
label: 'No payload param',
70+
expectedFailure: 'parseConnectionRequest throws "No payload found in URL."',
71+
buildUrl: () => 'metamask://connect/mwp',
72+
},
73+
{
74+
id: 'malformed-json',
75+
label: 'Payload is not valid JSON',
76+
expectedFailure:
77+
'JSON.parse inside parseConnectionRequest throws SyntaxError.',
78+
buildUrl: () => 'metamask://connect/mwp?p=not-json',
79+
},
80+
{
81+
id: 'invalid-shape',
82+
label: 'Payload parses as JSON but is not a ConnectionRequest',
83+
expectedFailure:
84+
'isConnectionRequest() returns false → "Invalid connection request structure."',
85+
buildUrl: () => `metamask://connect/mwp?p=${encode({ hello: 'world' })}`,
86+
},
87+
{
88+
id: 'invalid-uuid',
89+
label: 'sessionRequest.id is not a UUID',
90+
expectedFailure:
91+
'isConnectionRequest() rejects the non-UUID id → "Invalid connection request structure."',
92+
buildUrl: () =>
93+
`metamask://connect/mwp?p=${encode({
94+
...VALID_REQUEST,
95+
sessionRequest: {
96+
...VALID_REQUEST.sessionRequest,
97+
id: 'not-a-uuid',
98+
},
99+
})}`,
100+
},
101+
{
102+
id: 'internal-origin-dapp-url',
103+
label: 'dapp.url claims to be an internal origin',
104+
expectedFailure:
105+
'INTERNAL_ORIGINS check throws "External transactions cannot use internal origins". (In practice isConnectionRequest already rejects non-https dapp.url values, so this hits the upstream guard first.)',
106+
buildUrl: () =>
107+
`metamask://connect/mwp?p=${encode({
108+
...VALID_REQUEST,
109+
metadata: {
110+
...VALID_REQUEST.metadata,
111+
dapp: { ...VALID_REQUEST.metadata.dapp, url: 'metamask' },
112+
},
113+
})}`,
114+
},
115+
{
116+
id: 'compression-flag-mismatch',
117+
label: 'c=1 (compressed) flag set, but payload is plain JSON',
118+
expectedFailure:
119+
'decompressPayloadB64 throws while trying to inflate non-deflated bytes.',
120+
buildUrl: () =>
121+
`metamask://connect/mwp?p=${encode(VALID_REQUEST)}&c=1`,
122+
},
123+
{
124+
id: 'payload-too-large',
125+
label: 'Payload over 1MB',
126+
expectedFailure: 'parseConnectionRequest throws "Payload too large (max 1MB)."',
127+
buildUrl: () => {
128+
// 1MB + 1 byte after URL encoding. Repeating "a" stays the same length
129+
// when URI-encoded, so length === byte count for the `p` value.
130+
const oneMbPlusOne = 'a'.repeat(1024 * 1024 + 1);
131+
return `metamask://connect/mwp?p=${oneMbPlusOne}`;
132+
},
133+
},
134+
];
135+
136+
export function MwpDeeplinkReproCard() {
137+
const [expanded, setExpanded] = useState(false);
138+
const rows = useMemo(() => ROWS, []);
139+
140+
if (!expanded) {
141+
return (
142+
<button
143+
type="button"
144+
onClick={() => setExpanded(true)}
145+
className="text-sm text-gray-500 underline hover:text-gray-700"
146+
>
147+
Show QA: MWP deeplink failure repros
148+
</button>
149+
);
150+
}
151+
152+
return (
153+
<section className="bg-white rounded-lg p-8 mb-6 shadow-sm border border-dashed border-gray-300">
154+
<div className="flex items-start justify-between mb-4">
155+
<div>
156+
<h2 className="text-2xl font-bold text-gray-800">
157+
QA: MWP deeplink failure repros
158+
</h2>
159+
<p className="text-sm text-gray-600 mt-1 max-w-2xl">
160+
Each row below produces a <code>metamask://connect/mwp?…</code>{' '}
161+
deeplink that targets a specific branch inside the mobile app&apos;s{' '}
162+
<code>ConnectionRegistry.handleConnectDeeplink</code> error
163+
handling. Open this page on a mobile device with MetaMask Mobile
164+
installed and tap a row to exercise that branch. Each failure
165+
should produce both a <code>REMOTE_CONNECTION_REQUEST_FAILED</code>{' '}
166+
MetaMetrics event and a Sentry event tagged{' '}
167+
<code>feature: mm-connect</code>.
168+
</p>
169+
</div>
170+
<button
171+
type="button"
172+
onClick={() => setExpanded(false)}
173+
className="text-xs text-gray-400 hover:text-gray-600 ml-4"
174+
>
175+
Hide
176+
</button>
177+
</div>
178+
179+
<div className="space-y-4">
180+
{rows.map((row) => {
181+
const url = (() => {
182+
try {
183+
return row.buildUrl();
184+
} catch (e) {
185+
return `<failed to build URL: ${
186+
e instanceof Error ? e.message : String(e)
187+
}>`;
188+
}
189+
})();
190+
return (
191+
<div
192+
key={row.id}
193+
className="border border-gray-200 rounded p-4"
194+
data-testid={`mwp-repro-${row.id}`}
195+
>
196+
<div className="flex items-start justify-between gap-4">
197+
<div className="flex-1 min-w-0">
198+
<p className="font-semibold text-gray-800">{row.label}</p>
199+
<p className="text-xs text-gray-500 mt-1">
200+
{row.expectedFailure}
201+
</p>
202+
</div>
203+
<div className="flex flex-col gap-2 shrink-0">
204+
<a
205+
href={url}
206+
className="text-center bg-blue-500 text-white px-3 py-1 rounded text-sm hover:bg-blue-600 transition-colors"
207+
>
208+
Tap on mobile
209+
</a>
210+
<button
211+
type="button"
212+
onClick={() => {
213+
navigator.clipboard?.writeText(url).catch(() => {
214+
/* clipboard unavailable; ignore */
215+
});
216+
}}
217+
className="bg-gray-200 text-gray-700 px-3 py-1 rounded text-sm hover:bg-gray-300 transition-colors"
218+
>
219+
Copy URL
220+
</button>
221+
</div>
222+
</div>
223+
<p className="font-mono text-[11px] text-gray-500 mt-3 break-all">
224+
{url.length > 200 ? `${url.slice(0, 200)}… (${url.length} chars)` : url}
225+
</p>
226+
</div>
227+
);
228+
})}
229+
</div>
230+
</section>
231+
);
232+
}

0 commit comments

Comments
 (0)