|
| 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'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