Skip to content

Commit 04c40dd

Browse files
committed
feat: port Shadow DOM piercing from TS to Python CDP injector
Add _fill_card_in_shadow_dom() as fallback when iframe traversal finds no card fields. Recursively walks shadowRoot trees via page.evaluate(), fills fields using native setter + event dispatch.
1 parent 41947ad commit 04c40dd

File tree

2 files changed

+132
-0
lines changed

2 files changed

+132
-0
lines changed

pop_pay/injector.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,8 +630,83 @@ async def _fill_across_frames(
630630
logger.debug("Frame %s skipped: %s", frame.url, frame_exc)
631631
continue
632632

633+
# Shadow DOM piercing fallback: search for shadow roots in main page
634+
if not card_filled:
635+
if await self._fill_card_in_shadow_dom(page, card_number, expiry, cvv):
636+
card_filled = True
637+
633638
return card_filled
634639

640+
async def _fill_card_in_shadow_dom(
641+
self, page, card_number: str, expiry: str, cvv: str
642+
) -> bool:
643+
"""
644+
Search for card fields inside Shadow DOM trees using recursive
645+
queryShadowAll and fill them via native setters + event dispatch.
646+
"""
647+
try:
648+
card_selectors = ", ".join(CARD_NUMBER_SELECTORS)
649+
expiry_selectors = ", ".join(EXPIRY_SELECTORS)
650+
cvv_selectors = ", ".join(CVV_SELECTORS)
651+
652+
script = """
653+
([cardNumber, expiry, cvv, cardSels, expSels, cvvSels]) => {
654+
function queryShadowFirst(root, selectors) {
655+
const selectorList = selectors.split(', ');
656+
for (const sel of selectorList) {
657+
try {
658+
const found = root.querySelector(sel);
659+
if (found) return found;
660+
} catch (e) {}
661+
}
662+
const allElements = root.querySelectorAll('*');
663+
for (const el of allElements) {
664+
if (el.shadowRoot) {
665+
const found = queryShadowFirst(el.shadowRoot, selectors);
666+
if (found) return found;
667+
}
668+
}
669+
return null;
670+
}
671+
672+
function fillField(root, selectors, value) {
673+
const el = queryShadowFirst(root, selectors);
674+
if (!el) return false;
675+
try {
676+
const nativeSetter = Object.getOwnPropertyDescriptor(
677+
HTMLInputElement.prototype, 'value'
678+
).set;
679+
if (nativeSetter) {
680+
nativeSetter.call(el, value);
681+
} else {
682+
el.value = value;
683+
}
684+
el.dispatchEvent(new Event('input', { bubbles: true }));
685+
el.dispatchEvent(new Event('change', { bubbles: true }));
686+
el.dispatchEvent(new Event('blur', { bubbles: true }));
687+
return true;
688+
} catch (e) {
689+
return false;
690+
}
691+
}
692+
693+
const cardFilled = fillField(document, cardSels, cardNumber);
694+
if (cardFilled) {
695+
fillField(document, expSels, expiry);
696+
fillField(document, cvvSels, cvv);
697+
}
698+
return cardFilled;
699+
}
700+
"""
701+
result = await page.evaluate(script, [
702+
card_number, expiry, cvv,
703+
card_selectors, expiry_selectors, cvv_selectors
704+
])
705+
return bool(result)
706+
except Exception as e:
707+
logger.debug("PopBrowserInjector: Shadow DOM piercing failed: %s", e)
708+
return False
709+
635710
async def _fill_in_frame(
636711
self, frame, card_number: str, expiry: str, cvv: str
637712
) -> bool:

tests/test_cdp_regression.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,63 @@ async def mock_fill_in_frame(frame, cn, exp, cvv):
436436
assert call_count["iframe"] == 1
437437

438438

439+
class TestShadowDomPiercing:
440+
"""Test Shadow DOM piercing fallback when frame traversal fails."""
441+
442+
@pytest.mark.asyncio
443+
async def test_shadow_dom_fallback_called_when_frames_fail(self):
444+
"""Verify _fill_card_in_shadow_dom is called if _fill_in_frame returns False."""
445+
injector = _make_injector()
446+
page = MagicMock()
447+
frame = MagicMock()
448+
frame.url = "https://example.com"
449+
page.frames = [frame]
450+
451+
with patch.object(injector, "_fill_in_frame", return_value=False):
452+
with patch.object(
453+
injector, "_fill_card_in_shadow_dom", AsyncMock(return_value=True)
454+
) as mock_shadow:
455+
result = await injector._fill_across_frames(
456+
page, "4242424242424242", "12/28", "123"
457+
)
458+
459+
assert result is True
460+
mock_shadow.assert_called_once()
461+
462+
@pytest.mark.asyncio
463+
async def test_shadow_dom_not_called_when_frames_succeed(self):
464+
"""Verify Shadow DOM piercing is NOT called if a frame already filled the card."""
465+
injector = _make_injector()
466+
page = MagicMock()
467+
frame = MagicMock()
468+
frame.url = "https://example.com"
469+
page.frames = [frame]
470+
471+
with patch.object(injector, "_fill_in_frame", return_value=True):
472+
with patch.object(
473+
injector, "_fill_card_in_shadow_dom", AsyncMock()
474+
) as mock_shadow:
475+
result = await injector._fill_across_frames(
476+
page, "4242424242424242", "12/28", "123"
477+
)
478+
479+
assert result is True
480+
mock_shadow.assert_not_called()
481+
482+
@pytest.mark.asyncio
483+
async def test_shadow_dom_returns_false_when_no_fields(self):
484+
"""Verify _fill_card_in_shadow_dom returns False if page.evaluate returns False."""
485+
injector = _make_injector()
486+
page = MagicMock()
487+
# Mock page.evaluate to simulate no fields found in shadow DOM
488+
page.evaluate = AsyncMock(return_value=False)
489+
490+
result = await injector._fill_card_in_shadow_dom(
491+
page, "4242424242424242", "12/28", "123"
492+
)
493+
assert result is False
494+
495+
439496
# ---------------------------------------------------------------------------
440497
# Compatibility matrix documentation
441498
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)