@@ -290,8 +290,18 @@ async function driveUpgradeToCheckout(page: Page): Promise<UpgradeOutcome> {
290290 try {
291291 await expect ( redirecting . or ( fallback ) . or ( errorPanel ) . or ( emailGate ) ) . toBeVisible ( { timeout : 30_000 } )
292292 } catch {
293+ // When the api is ARMED (test keys live), CheckoutPage gets a real short_url
294+ // and calls window.location.assign immediately — our route() aborts that
295+ // top-level nav, which can tear the React tree down before the
296+ // checkout-redirecting testid stabilises, so toBeVisible can time out even
297+ // though the redirect DID happen. If the route captured a Razorpay URL,
298+ // that's a successful 'razorpay' outcome, not 'stuck'.
299+ if ( capturedShortUrl ) return { kind : 'razorpay' , detail : capturedShortUrl }
293300 return { kind : 'stuck' , detail : 'no terminal checkout state within 30s (still loading?)' }
294301 }
302+ // Same race even on the success branch: the route may have fired before any
303+ // panel rendered. Prefer the captured URL the instant we have it.
304+ if ( capturedShortUrl ) return { kind : 'razorpay' , detail : capturedShortUrl }
295305
296306 if ( await fallback . isVisible ( ) . catch ( ( ) => false ) ) {
297307 return { kind : 'fallback' , detail : 'billing-not-configured fallback panel' }
@@ -366,39 +376,63 @@ async function driveRazorpayTestCard(page: Page): Promise<boolean> {
366376 await popup . waitForLoadState ( 'domcontentloaded' ) . catch ( ( ) => { } )
367377 }
368378
369- // Razorpay sometimes shows a "Card" payment-method tab first. Try to click it.
379+ // The flow, as observed driving a real TEST-mode INR subscription checkout
380+ // (2026-06-07): the short_url lands on a Razorpay "Subscription Details" page
381+ // with a "Start Subscription" button → that opens the checkout iframe listing
382+ // UPI / Cards / EMandate → pick Cards → fill card → "Continue" → an RBI
383+ // "Save your card as per RBI guidelines?" mandate modal (required for
384+ // recurring) → "Yes, secure my card" → a mock-bank OTP step → enter 1234 →
385+ // "Continue". Every step is best-effort (clickIfPresent never throws) so a
386+ // markup change still soft-fails rather than redding the nightly.
387+
388+ // 0) Subscription details page → "Start Subscription" opens the checkout iframe.
389+ await clickIfPresent ( target , [
390+ 'button:has-text("Start Subscription")' ,
391+ 'button:has-text("Start subscription")' ,
392+ ] )
393+ // Give the checkout iframe a beat to mount before scanning frames.
394+ await target . waitForTimeout ( 2000 ) . catch ( ( ) => { } )
395+
396+ // 1) Select the "Cards" payment method (the INR checkout defaults to UPI).
370397 await clickIfPresent ( target , [
371- 'text=/^ card$/i ' ,
372- 'text=/cards?/i' ,
398+ '[role="radio"][aria-label*=" card" i] ' ,
399+ 'text=/^ cards?$ /i' ,
373400 '[data-testid="card"]' ,
374401 'button:has-text("Card")' ,
375402 ] )
376403
377- // Find the card-number field across the page + its frames. Razorpay nests the
378- // PCI card fields in iframes; we scan candidate frames for a recognisable
379- // number input.
404+ // 2) Find + fill the card fields across the page + its (cross-origin) frames.
380405 const cardFilled = await fillCardAcrossFrames ( target )
381406 if ( ! cardFilled ) {
382407 // eslint-disable-next-line no-console
383408 console . warn ( '[live-ui-payment] could not locate the Razorpay card-number field in any frame — markup changed?' )
384409 return false
385410 }
386411
387- // Submit (Pay). The button label varies (Pay, Pay Now, ₹…).
412+ // 3) Submit the card. For SUBSCRIPTIONS the button is "Continue" (one-time
413+ // payments say "Pay"); try both.
388414 const submitted = await clickIfPresent ( target , [
415+ 'button:has-text("Continue")' ,
389416 'button:has-text("Pay")' ,
390417 'button:has-text("Subscribe")' ,
391418 'button[type="submit"]' ,
392419 '[data-testid="submit"]' ,
393420 ] )
394421 if ( ! submitted ) {
395422 // eslint-disable-next-line no-console
396- console . warn ( '[live-ui-payment] could not find the Razorpay Pay/Submit button — markup changed?' )
423+ console . warn ( '[live-ui-payment] could not find the Razorpay Continue/Pay button — markup changed?' )
397424 return false
398425 }
399426
400- // Mock-bank / 3DS OTP step. Enter OTP 1234 and click Success/Submit. Both the
401- // OTP field and the success button are in the bank-simulator frame on TEST.
427+ // 4) RBI save-card mandate modal ("Save your card as per RBI guidelines?") —
428+ // required for recurring. Best-effort; absent on some flows.
429+ await target . waitForTimeout ( 1000 ) . catch ( ( ) => { } )
430+ await clickIfPresent ( target , [
431+ 'button:has-text("Yes, secure my card")' ,
432+ 'button:has-text("secure my card")' ,
433+ ] )
434+
435+ // 5) Mock-bank / 3DS OTP step. Enter OTP 1234 and click Continue/Success.
402436 const otpDone = await enterOtpAcrossFrames ( target , TEST_OTP )
403437 if ( ! otpDone ) {
404438 // The OTP step may be skipped for some test flows; don't fail solely on it.
@@ -501,9 +535,10 @@ async function enterOtpAcrossFrames(page: Page, otp: string): Promise<boolean> {
501535 }
502536 }
503537 }
504- // Click Success / Submit on the bank simulator ( TEST mode renders a "Success"
505- // button; OTP 1234 is also valid) .
538+ // Submit the OTP. The TEST bank simulator's button is "Continue" (newer
539+ // checkout) or "Success"/"Submit" (older) — try all .
506540 await clickIfPresent ( page , [
541+ 'button:has-text("Continue")' ,
507542 'button:has-text("Success")' ,
508543 'button:has-text("Submit")' ,
509544 'button[type="submit"]' ,
0 commit comments