pop-pay 0.3.3 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,20 +1,32 @@
1
1
  "use strict";
2
2
  /**
3
- * PopBrowserInjector: CDP-based browser injector with iframe + Shadow DOM traversal.
3
+ * PopBrowserInjector: Playwright-based browser injector for payment and billing fields.
4
4
  *
5
5
  * Connects to an already-running Chromium browser (via --remote-debugging-port)
6
- * and auto-fills credit card fields on the active page — including fields inside
7
- * Stripe and other third-party payment iframes. Also fills billing detail fields
8
- * (name, address, email) that live in the main page frame.
6
+ * using playwright-core and auto-fills credit card fields across all frames.
9
7
  *
10
- * New in TS port: Shadow DOM piercing support.
8
+ * This version replaces the raw CDP WebSocket implementation with Playwright's
9
+ * connectOverCDP, providing better isolation and cross-origin iframe support.
11
10
  */
12
11
  Object.defineProperty(exports, "__esModule", { value: true });
13
12
  exports.PopBrowserInjector = exports.CITY_SELECTORS = exports.STATE_SELECTORS = exports.COUNTRY_SELECTORS = exports.PHONE_COUNTRY_CODE_SELECTORS = exports.PHONE_SELECTORS = exports.EMAIL_SELECTORS = exports.ZIP_SELECTORS = exports.STREET_SELECTORS = exports.FULL_NAME_SELECTORS = exports.LAST_NAME_SELECTORS = exports.FIRST_NAME_SELECTORS = exports.CVV_SELECTORS = exports.EXPIRY_SELECTORS = exports.CARD_NUMBER_SELECTORS = void 0;
14
13
  exports.ssrfValidateUrl = ssrfValidateUrl;
15
14
  exports.verifyDomainToctou = verifyDomainToctou;
15
+ const playwright_core_1 = require("playwright-core");
16
16
  const known_processors_js_1 = require("./known-processors.js");
17
17
  // ---------------------------------------------------------------------------
18
+ // Structured logger
19
+ // ---------------------------------------------------------------------------
20
+ const LOG_LEVEL = (process.env.POP_LOG_LEVEL ?? "info").toLowerCase();
21
+ const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
22
+ function log(level, msg, data) {
23
+ if ((LEVELS[level] ?? 1) < (LEVELS[LOG_LEVEL] ?? 1))
24
+ return;
25
+ const entry = { ts: new Date().toISOString(), level, component: "PopBrowserInjector", msg, ...data };
26
+ const out = level === "error" ? process.stderr : process.stderr;
27
+ out.write(JSON.stringify(entry) + "\n");
28
+ }
29
+ // ---------------------------------------------------------------------------
18
30
  // ISO 3166-1 alpha-2 -> E.164 dial prefix
19
31
  // ---------------------------------------------------------------------------
20
32
  const COUNTRY_DIAL_CODES = {
@@ -44,7 +56,7 @@ function nationalNumber(phoneE164, countryCode) {
44
56
  return phoneE164;
45
57
  }
46
58
  // ---------------------------------------------------------------------------
47
- // US state abbreviation -> full name (for dropdowns that use full names)
59
+ // US state abbreviation -> full name
48
60
  // ---------------------------------------------------------------------------
49
61
  const US_STATE_CODES = {
50
62
  AL: "Alabama", AK: "Alaska", AZ: "Arizona", AR: "Arkansas",
@@ -62,7 +74,7 @@ const US_STATE_CODES = {
62
74
  WV: "West Virginia", WI: "Wisconsin", WY: "Wyoming",
63
75
  };
64
76
  // ---------------------------------------------------------------------------
65
- // CSS selectors for credit card fields across major payment providers
77
+ // Selectors
66
78
  // ---------------------------------------------------------------------------
67
79
  exports.CARD_NUMBER_SELECTORS = [
68
80
  "input[autocomplete='cc-number']",
@@ -72,8 +84,8 @@ exports.CARD_NUMBER_SELECTORS = [
72
84
  "input[id*='card'][id*='number']",
73
85
  "input[placeholder*='Card number']",
74
86
  "input[placeholder*='card number']",
75
- "input[data-elements-stable-field-name='cardNumber']", // Stripe Elements
76
- "input.__PrivateStripeElement", // Stripe v2
87
+ "input[data-elements-stable-field-name='cardNumber']",
88
+ "input.__PrivateStripeElement",
77
89
  ];
78
90
  exports.EXPIRY_SELECTORS = [
79
91
  "input[autocomplete='cc-exp']",
@@ -83,7 +95,7 @@ exports.EXPIRY_SELECTORS = [
83
95
  "input[placeholder*='MM / YY']",
84
96
  "input[placeholder*='MM/YY']",
85
97
  "input[placeholder*='Expiry']",
86
- "input[data-elements-stable-field-name='cardExpiry']", // Stripe Elements
98
+ "input[data-elements-stable-field-name='cardExpiry']",
87
99
  ];
88
100
  exports.CVV_SELECTORS = [
89
101
  "input[autocomplete='cc-csc']",
@@ -94,11 +106,8 @@ exports.CVV_SELECTORS = [
94
106
  "input[placeholder*='CVC']",
95
107
  "input[placeholder*='CVV']",
96
108
  "input[placeholder*='Security code']",
97
- "input[data-elements-stable-field-name='cardCvc']", // Stripe Elements
109
+ "input[data-elements-stable-field-name='cardCvc']",
98
110
  ];
99
- // ---------------------------------------------------------------------------
100
- // CSS selectors for billing detail fields
101
- // ---------------------------------------------------------------------------
102
111
  exports.FIRST_NAME_SELECTORS = [
103
112
  "input[autocomplete='given-name']",
104
113
  "input[name='first_name']", "input[name='firstName']", "input[name='first-name']",
@@ -187,9 +196,6 @@ exports.CITY_SELECTORS = [
187
196
  "input[aria-label*='City']",
188
197
  "select[autocomplete='address-level2']", "select[name='city']",
189
198
  ];
190
- // ---------------------------------------------------------------------------
191
- // Known vendor domains (shared with guardrails)
192
- // ---------------------------------------------------------------------------
193
199
  const KNOWN_VENDOR_DOMAINS = {
194
200
  aws: ["amazonaws.com", "aws.amazon.com"],
195
201
  amazon: ["amazon.com", "amazon.co.uk", "amazon.co.jp"],
@@ -215,7 +221,6 @@ function ssrfValidateUrl(url) {
215
221
  if (!["http:", "https:"].includes(parsed.protocol)) {
216
222
  return "Only http/https URLs are allowed.";
217
223
  }
218
- // Block private/reserved IPs
219
224
  const hostname = parsed.hostname;
220
225
  if (hostname === "localhost" ||
221
226
  hostname === "127.0.0.1" ||
@@ -225,7 +230,6 @@ function ssrfValidateUrl(url) {
225
230
  /^172\.(1[6-9]|2\d|3[01])\./.test(hostname) ||
226
231
  hostname === "[::1]" ||
227
232
  hostname.endsWith(".local")) {
228
- // Allow localhost only for CDP URLs (checked separately)
229
233
  return "Private/reserved IP addresses are not allowed.";
230
234
  }
231
235
  }
@@ -235,7 +239,7 @@ function ssrfValidateUrl(url) {
235
239
  return null;
236
240
  }
237
241
  // ---------------------------------------------------------------------------
238
- // TOCTOU domain verification (shared between payment + billing injection)
242
+ // TOCTOU domain verification
239
243
  // ---------------------------------------------------------------------------
240
244
  function verifyDomainToctou(pageUrl, approvedVendor) {
241
245
  if (!pageUrl || !approvedVendor)
@@ -251,7 +255,6 @@ function verifyDomainToctou(pageUrl, approvedVendor) {
251
255
  const vendorTokens = new Set(vendorLower.split(/[\s\-_./]+/).filter(Boolean));
252
256
  let domainOk = false;
253
257
  let vendorIsKnown = false;
254
- // Check against KNOWN_VENDOR_DOMAINS using strict suffix matching
255
258
  for (const [knownVendor, knownDomains] of Object.entries(KNOWN_VENDOR_DOMAINS)) {
256
259
  if (vendorTokens.has(knownVendor) || knownVendor === vendorLower) {
257
260
  vendorIsKnown = true;
@@ -261,7 +264,6 @@ function verifyDomainToctou(pageUrl, approvedVendor) {
261
264
  break;
262
265
  }
263
266
  }
264
- // Fallback for unknown vendors
265
267
  if (!domainOk && !vendorIsKnown) {
266
268
  const commonTlds = new Set(["com", "org", "net", "io", "co", "uk", "jp", "de", "fr"]);
267
269
  const domainLabels = new Set(actualDomain.split(".").filter((l) => !commonTlds.has(l)));
@@ -270,7 +272,6 @@ function verifyDomainToctou(pageUrl, approvedVendor) {
270
272
  [...vendorTokens].some((tok) => tok.length >= 4 &&
271
273
  [...domainLabels].some((label) => label.includes(tok)));
272
274
  }
273
- // Payment processor passthrough
274
275
  if (!domainOk) {
275
276
  let userProcessors = [];
276
277
  try {
@@ -288,699 +289,447 @@ function verifyDomainToctou(pageUrl, approvedVendor) {
288
289
  return null;
289
290
  }
290
291
  // ---------------------------------------------------------------------------
291
- // CDP connection helper using raw WebSocket
292
- // ---------------------------------------------------------------------------
293
- async function fetchJSON(url) {
294
- const resp = await fetch(url, { signal: AbortSignal.timeout(5000) });
295
- return resp.json();
296
- }
297
- // Minimal WebSocket-based CDP client
298
- class CDPClient {
299
- ws;
300
- msgId = 0;
301
- pending = new Map();
302
- eventHandlers = new Map();
303
- static async connect(wsUrl) {
304
- const client = new CDPClient();
305
- const { WebSocket } = await import("ws").catch(() => {
306
- // Fallback: use global WebSocket if available (Node 21+)
307
- return { WebSocket: globalThis.WebSocket };
308
- });
309
- return new Promise((resolve, reject) => {
310
- const ws = new WebSocket(wsUrl);
311
- client.ws = ws;
312
- ws.onopen = () => resolve(client);
313
- ws.onerror = (e) => reject(new Error(`CDP WebSocket error: ${e.message ?? e}`));
314
- ws.onmessage = (event) => {
315
- const data = typeof event.data === "string" ? event.data : event.data.toString();
316
- const msg = JSON.parse(data);
317
- if (msg.id !== undefined) {
318
- const p = client.pending.get(msg.id);
319
- if (p) {
320
- client.pending.delete(msg.id);
321
- if (msg.error)
322
- p.reject(new Error(msg.error.message));
323
- else
324
- p.resolve(msg.result);
325
- }
326
- }
327
- else if (msg.method) {
328
- const handlers = client.eventHandlers.get(msg.method);
329
- if (handlers)
330
- handlers.forEach((h) => h(msg.params));
331
- }
332
- };
333
- ws.onclose = () => {
334
- for (const p of client.pending.values()) {
335
- p.reject(new Error("CDP connection closed"));
336
- }
337
- client.pending.clear();
338
- };
339
- });
340
- }
341
- async send(method, params = {}) {
342
- const id = ++this.msgId;
343
- return new Promise((resolve, reject) => {
344
- this.pending.set(id, { resolve, reject });
345
- const msg = JSON.stringify({ id, method, params });
346
- this.ws.send(msg);
347
- });
348
- }
349
- on(event, handler) {
350
- const handlers = this.eventHandlers.get(event) ?? [];
351
- handlers.push(handler);
352
- this.eventHandlers.set(event, handlers);
353
- }
354
- close() {
355
- try {
356
- this.ws.close();
357
- }
358
- catch { }
359
- }
360
- }
361
- // ---------------------------------------------------------------------------
362
292
  // PopBrowserInjector
363
293
  // ---------------------------------------------------------------------------
364
294
  class PopBrowserInjector {
365
295
  cdpUrl;
366
- headless;
367
- constructor(cdpUrl = "http://localhost:9222", headless = false) {
296
+ defaultBillingInfo;
297
+ browser = null;
298
+ constructor(cdpUrl = "http://localhost:9222", billingInfoOrHeadless) {
368
299
  this.cdpUrl = cdpUrl;
369
- this.headless = headless;
300
+ if (typeof billingInfoOrHeadless === "object") {
301
+ this.defaultBillingInfo = billingInfoOrHeadless;
302
+ }
370
303
  }
371
- // ------------------------------------------------------------------
372
- // Public API: inject payment info (card + billing)
373
- // ------------------------------------------------------------------
374
- async injectPaymentInfo(opts) {
304
+ /**
305
+ * Inject payment info into the current page.
306
+ * Supports both positional and object-based signatures for compatibility.
307
+ */
308
+ async injectPaymentInfo(optsOrCard, expiry, cvv, vendor, pageUrl, billingInfo) {
309
+ let cardNumber;
310
+ let exp;
311
+ let cv;
312
+ let vend;
313
+ let url;
314
+ let billing;
315
+ if (typeof optsOrCard === "object") {
316
+ cardNumber = optsOrCard.cardNumber;
317
+ exp = optsOrCard.expiry || optsOrCard.expirationDate || "";
318
+ cv = optsOrCard.cvv;
319
+ vend = optsOrCard.vendor || optsOrCard.approvedVendor || "";
320
+ url = optsOrCard.pageUrl || "";
321
+ billing = optsOrCard.billingInfo || billingInfo;
322
+ }
323
+ else {
324
+ cardNumber = optsOrCard;
325
+ exp = expiry || "";
326
+ cv = cvv || "";
327
+ vend = vendor || "";
328
+ url = pageUrl || "";
329
+ billing = billingInfo;
330
+ }
375
331
  const result = {
376
332
  cardFilled: false,
377
333
  billingFilled: false,
378
334
  blockedReason: "",
379
335
  };
380
336
  // TOCTOU guard
381
- const blocked = verifyDomainToctou(opts.pageUrl ?? "", opts.approvedVendor ?? "");
337
+ const blocked = verifyDomainToctou(url, vend);
382
338
  if (blocked) {
383
339
  result.blockedReason = blocked;
384
340
  return result;
385
341
  }
386
- const billingInfo = this.loadBillingInfo();
387
- const hasBilling = Object.values(billingInfo).some((v) => v !== "");
388
- let client = null;
342
+ const finalBilling = billing || this.defaultBillingInfo || this.loadBillingFromEnv();
343
+ const hasBilling = Object.values(finalBilling).some((v) => v !== "");
389
344
  try {
390
- const target = await this.findBestTarget(opts.pageUrl);
391
- if (!target?.webSocketDebuggerUrl) {
392
- result.blockedReason = "no_target_found";
345
+ this.browser = await playwright_core_1.chromium.connectOverCDP(this.cdpUrl);
346
+ const page = this.findBestPage(this.browser);
347
+ if (!page) {
348
+ result.blockedReason = "no_active_page";
393
349
  return result;
394
350
  }
395
- client = await CDPClient.connect(target.webSocketDebuggerUrl);
396
- // Enable DOM + Runtime
397
- await client.send("Runtime.enable");
398
- await client.send("DOM.enable");
399
- await client.send("Page.enable");
351
+ await page.bringToFront();
400
352
  // Blackout mode
401
353
  const blackoutMode = (process.env.POP_BLACKOUT_MODE ?? "after").toLowerCase();
402
354
  if (blackoutMode === "before") {
403
- await this.enableBlackout(client);
355
+ await this.enableBlackout(page);
404
356
  }
405
- // Fill card fields across all frames (including iframes + shadow DOM)
406
- result.cardFilled = await this.fillCardAcrossFrames(client, opts.cardNumber, opts.expirationDate, opts.cvv);
357
+ // Fill card fields across all frames
358
+ result.cardFilled = await this.fillAcrossFrames(page, cardNumber, exp, cv);
407
359
  // Fill billing fields
408
360
  if (hasBilling) {
409
- const billingResult = await this.fillBillingAcrossFrames(client, billingInfo);
361
+ const billingResult = await this.fillBillingFields(page, finalBilling);
410
362
  result.billingFilled = billingResult.filled.length > 0;
411
363
  result.billingDetails = billingResult;
412
364
  }
413
365
  if (blackoutMode === "after") {
414
- await this.enableBlackout(client);
366
+ await this.enableBlackout(page);
415
367
  }
416
368
  return result;
417
369
  }
418
370
  catch (err) {
419
- process.stderr.write(`PopBrowserInjector error: ${err.message}\n`);
371
+ log("error", "injection failed", { error: err.message });
420
372
  return result;
421
373
  }
422
374
  finally {
423
- client?.close();
375
+ await this.close();
424
376
  }
425
377
  }
426
- // ------------------------------------------------------------------
427
- // Public API: inject billing info only (no card)
428
- // ------------------------------------------------------------------
378
+ /**
379
+ * Internal method used by mcp-server.ts. Kept for compatibility but marked internal.
380
+ * @internal
381
+ */
429
382
  async injectBillingOnly(opts) {
430
- const result = {
431
- cardFilled: false,
432
- billingFilled: false,
433
- blockedReason: "",
434
- };
383
+ const result = { cardFilled: false, billingFilled: false, blockedReason: "" };
435
384
  const blocked = verifyDomainToctou(opts.pageUrl ?? "", opts.approvedVendor ?? "");
436
385
  if (blocked) {
437
386
  result.blockedReason = blocked;
438
387
  return result;
439
388
  }
440
- const billingInfo = this.loadBillingInfo();
441
- let client = null;
389
+ const billing = this.defaultBillingInfo || this.loadBillingFromEnv();
442
390
  try {
443
- const target = await this.findBestTarget(opts.pageUrl);
444
- if (!target?.webSocketDebuggerUrl) {
445
- result.blockedReason = "no_target_found";
391
+ this.browser = await playwright_core_1.chromium.connectOverCDP(this.cdpUrl);
392
+ const page = this.findBestPage(this.browser);
393
+ if (!page) {
394
+ result.blockedReason = "no_active_page";
446
395
  return result;
447
396
  }
448
- client = await CDPClient.connect(target.webSocketDebuggerUrl);
449
- await client.send("Runtime.enable");
450
- await client.send("DOM.enable");
451
- const billingResult = await this.fillBillingAcrossFrames(client, billingInfo);
397
+ const billingResult = await this.fillBillingFields(page, billing);
452
398
  result.billingFilled = billingResult.filled.length > 0;
453
399
  result.billingDetails = billingResult;
454
400
  return result;
455
401
  }
456
402
  catch (err) {
457
- process.stderr.write(`PopBrowserInjector billing error: ${err.message}\n`);
403
+ log("error", "billing injection failed", { error: err.message });
458
404
  return result;
459
405
  }
460
406
  finally {
461
- client?.close();
407
+ await this.close();
462
408
  }
463
409
  }
464
- // ------------------------------------------------------------------
465
- // Public API: page snapshot
466
- // ------------------------------------------------------------------
467
- async pageSnapshot(pageUrl) {
468
- let client = null;
410
+ async pageSnapshot(url) {
469
411
  try {
470
- const target = await this.findBestTarget(pageUrl);
471
- if (!target?.webSocketDebuggerUrl)
412
+ this.browser = await playwright_core_1.chromium.connectOverCDP(this.cdpUrl);
413
+ const page = this.findBestPage(this.browser);
414
+ if (!page)
472
415
  return null;
473
- client = await CDPClient.connect(target.webSocketDebuggerUrl);
474
- await client.send("Runtime.enable");
475
- await client.send("DOM.enable");
476
- await client.send("Page.enable");
477
- // Get main frame info
478
- const { result: titleResult } = await client.send("Runtime.evaluate", {
479
- expression: "document.title",
480
- });
481
- const { result: urlResult } = await client.send("Runtime.evaluate", {
482
- expression: "window.location.href",
483
- });
484
- const { result: htmlResult } = await client.send("Runtime.evaluate", {
485
- expression: "document.documentElement.outerHTML",
486
- });
487
- // Get frame tree for iframe content
488
- const { frameTree } = await client.send("Page.getFrameTree");
416
+ const title = await page.title();
417
+ const pageUrl = page.url();
418
+ const html = await page.content();
489
419
  const frames = [];
490
- const collectFrames = async (tree) => {
491
- if (tree.childFrames) {
492
- for (const child of tree.childFrames) {
493
- try {
494
- const { result: frameHtml } = await client.send("Runtime.evaluate", {
495
- expression: "document.documentElement.outerHTML",
496
- contextId: undefined, // Would need execution context for each frame
497
- });
498
- frames.push({ url: child.frame.url, html: frameHtml?.value ?? "" });
499
- }
500
- catch { }
501
- await collectFrames(child);
502
- }
420
+ for (const frame of page.frames()) {
421
+ if (frame === page.mainFrame())
422
+ continue;
423
+ try {
424
+ const frameHtml = await frame.content();
425
+ frames.push({ url: frame.url(), html: frameHtml });
503
426
  }
504
- };
505
- await collectFrames(frameTree);
506
- return {
507
- url: urlResult?.value ?? target.url,
508
- title: titleResult?.value ?? target.title,
509
- html: htmlResult?.value ?? "",
510
- frames,
511
- };
427
+ catch { }
428
+ }
429
+ return { url: pageUrl, title, html, frames };
512
430
  }
513
431
  catch (err) {
514
- process.stderr.write(`PopBrowserInjector snapshot error: ${err.message}\n`);
432
+ log("error", "snapshot failed", { error: err.message });
515
433
  return null;
516
434
  }
517
435
  finally {
518
- client?.close();
436
+ await this.close();
519
437
  }
520
438
  }
439
+ async close() {
440
+ if (this.browser) {
441
+ try {
442
+ await this.browser.close();
443
+ }
444
+ catch { }
445
+ this.browser = null;
446
+ }
447
+ }
448
+ static maskedCard(cardNumber) {
449
+ const last4 = cardNumber.slice(-4);
450
+ return `****-****-****-${last4}`;
451
+ }
521
452
  // ------------------------------------------------------------------
522
- // Internal: find the best CDP target (prefer checkout pages)
453
+ // Internal Helpers
523
454
  // ------------------------------------------------------------------
524
- async findBestTarget(pageUrl) {
525
- let targets;
526
- try {
527
- targets = await fetchJSON(`${this.cdpUrl}/json/list`);
528
- }
529
- catch {
530
- return null;
531
- }
532
- const pageTargets = targets.filter((t) => t.type === "page");
533
- if (pageTargets.length === 0)
455
+ findBestPage(browser) {
456
+ const CHECKOUT_KEYWORDS = ["checkout", "payment", "donate", "pay", "purchase", "order", "gateway", "cart"];
457
+ const allPages = browser.contexts().flatMap((ctx) => ctx.pages());
458
+ if (allPages.length === 0)
534
459
  return null;
535
- const checkoutKeywords = [
536
- "checkout", "payment", "donate", "pay", "purchase", "order", "gateway", "cart",
537
- ];
538
- // Prefer pages whose URL looks like a checkout/payment page
539
- for (const t of pageTargets) {
540
- const urlLower = t.url.toLowerCase();
541
- if (checkoutKeywords.some((kw) => urlLower.includes(kw))) {
542
- return t;
460
+ for (const page of allPages) {
461
+ const url = page.url().toLowerCase();
462
+ if (CHECKOUT_KEYWORDS.some((kw) => url.includes(kw))) {
463
+ return page;
543
464
  }
544
465
  }
545
- // If pageUrl is provided, try to find a matching target
546
- if (pageUrl) {
547
- for (const t of pageTargets) {
548
- if (t.url === pageUrl)
549
- return t;
550
- }
551
- }
552
- // Fallback: last target
553
- return pageTargets[pageTargets.length - 1];
466
+ return allPages[allPages.length - 1];
554
467
  }
555
- // ------------------------------------------------------------------
556
- // Internal: fill card fields across all frames (iframes + shadow DOM)
557
- // ------------------------------------------------------------------
558
- async fillCardAcrossFrames(client, cardNumber, expiry, cvv) {
559
- // Track each field independently — Stripe splits fields across sibling iframes
468
+ async fillAcrossFrames(page, cardNumber, expiry, cvv) {
469
+ const allFrames = page.frames();
560
470
  let cardFilled = false;
561
- let expiryFilled = false;
562
- let cvvFilled = false;
563
- // Get frame tree
564
- const { frameTree } = await client.send("Page.getFrameTree");
565
- const merge = (r) => {
566
- if (r.card)
567
- cardFilled = true;
568
- if (r.expiry)
569
- expiryFilled = true;
570
- if (r.cvv)
571
- cvvFilled = true;
572
- };
573
- // Process main frame
574
- merge(await this.fillCardInContext(client, undefined, cardNumber, expiry, cvv));
575
- // Process child frames (iframes) — keep going even after card found
576
- // (expiry/CVV may be in sibling iframes, common in Stripe's multi-iframe layout)
577
- const processFrame = async (tree) => {
578
- if (tree.childFrames) {
579
- for (const child of tree.childFrames) {
580
- try {
581
- // Create isolated world for cross-origin iframe access
582
- const { executionContextId } = await client.send("Page.createIsolatedWorld", { frameId: child.frame.id, worldName: "pop-pay-injector" });
583
- merge(await this.fillCardInContext(client, executionContextId, cardNumber, expiry, cvv));
584
- }
585
- catch {
586
- // Cross-origin frame access may fail — continue
587
- }
588
- await processFrame(child);
471
+ for (const frame of allFrames) {
472
+ try {
473
+ log("debug", "scanning frame", { frameUrl: frame.url() });
474
+ if (await this.fillInFrame(frame, cardNumber, expiry, cvv)) {
475
+ log("info", "card fields filled in frame", { frameUrl: frame.url() });
476
+ cardFilled = true;
477
+ // Keep going for expiry/CVV in sibling iframes (Stripe)
589
478
  }
590
479
  }
591
- };
592
- await processFrame(frameTree);
593
- // Shadow DOM piercing: fallback if card not found in any frame
480
+ catch { }
481
+ }
482
+ // Shadow DOM piercing fallback
594
483
  if (!cardFilled) {
595
- const shadowFilled = await this.fillCardInShadowDom(client, cardNumber, expiry, cvv);
596
- if (shadowFilled)
597
- cardFilled = true;
484
+ cardFilled = await this.fillCardInShadowDom(page, cardNumber, expiry, cvv);
598
485
  }
599
486
  return cardFilled;
600
487
  }
601
- // ------------------------------------------------------------------
602
- // Internal: fill billing fields across all frames (iframes)
603
- // ------------------------------------------------------------------
604
- async fillBillingAcrossFrames(client, info) {
605
- const result = {
606
- filled: [],
607
- failed: [],
608
- skipped: [],
609
- };
610
- const merge = (frameResult) => {
611
- result.filled.push(...frameResult.filled);
612
- result.failed.push(...frameResult.failed);
613
- result.skipped.push(...frameResult.skipped);
614
- };
615
- // Try main frame first
616
- merge(await this.fillBillingFields(client, info, {}));
617
- // Get frame tree
618
- const { frameTree } = await client.send("Page.getFrameTree");
619
- // Process child frames (iframes)
620
- const processFrame = async (tree) => {
621
- if (tree.childFrames) {
622
- for (const child of tree.childFrames) {
623
- try {
624
- // Create isolated world for cross-origin iframe access
625
- const { executionContextId } = await client.send("Page.createIsolatedWorld", { frameId: child.frame.id, worldName: "pop-pay-billing-injector" });
626
- merge(await this.fillBillingFields(client, info, { contextId: executionContextId }));
627
- }
628
- catch {
629
- // Cross-origin frame access may fail — continue
630
- }
631
- await processFrame(child);
488
+ async fillInFrame(frame, cardNumber, expiry, cvv) {
489
+ const cardLocator = await this.findVisibleLocator(frame, exports.CARD_NUMBER_SELECTORS);
490
+ if (!cardLocator)
491
+ return false;
492
+ await cardLocator.fill(cardNumber);
493
+ const expiryLocator = await this.findVisibleLocator(frame, exports.EXPIRY_SELECTORS);
494
+ if (expiryLocator)
495
+ await expiryLocator.fill(expiry);
496
+ const cvvLocator = await this.findVisibleLocator(frame, exports.CVV_SELECTORS);
497
+ if (cvvLocator)
498
+ await cvvLocator.fill(cvv);
499
+ return true;
500
+ }
501
+ async findVisibleLocator(frame, selectors) {
502
+ for (const selector of selectors) {
503
+ try {
504
+ const locator = frame.locator(selector).first();
505
+ if (await locator.count() > 0) {
506
+ return locator;
632
507
  }
633
508
  }
634
- };
635
- await processFrame(frameTree);
636
- // Deduplicate: if a field was filled in any frame, remove from failed/skipped
637
- result.filled = [...new Set(result.filled)];
638
- result.failed = result.failed.filter((f) => !result.filled.some((filled) => f.startsWith(filled)));
639
- result.skipped = result.skipped.filter((s) => !result.filled.includes(s));
640
- return result;
641
- }
642
- // ------------------------------------------------------------------
643
- // Internal: fill card fields in a single execution context
644
- // ------------------------------------------------------------------
645
- async fillCardInContext(client, contextId, cardNumber, expiry, cvv) {
646
- const evalOpts = contextId !== undefined
647
- ? { contextId }
648
- : {};
649
- // Try each field independently — Stripe uses separate iframes per field
650
- const cardSelector = exports.CARD_NUMBER_SELECTORS.join(", ");
651
- const card = await this.fillInputViaEval(client, evalOpts, cardSelector, cardNumber);
652
- const expirySelector = exports.EXPIRY_SELECTORS.join(", ");
653
- const exp = await this.fillInputViaEval(client, evalOpts, expirySelector, expiry);
654
- const cvvSelector = exports.CVV_SELECTORS.join(", ");
655
- const cvvFilled = await this.fillInputViaEval(client, evalOpts, cvvSelector, cvv);
656
- return { card, expiry: exp, cvv: cvvFilled };
509
+ catch { }
510
+ }
511
+ return null;
657
512
  }
658
- // ------------------------------------------------------------------
659
- // Internal: Shadow DOM piercing support (new feature!)
660
- // ------------------------------------------------------------------
661
- async fillCardInShadowDom(client, cardNumber, expiry, cvv) {
513
+ async fillCardInShadowDom(page, cardNumber, expiry, cvv) {
662
514
  try {
663
- const { result } = await client.send("Runtime.evaluate", {
664
- expression: `
665
- (function() {
666
- function queryShadowAll(root, selectors) {
667
- const results = [];
668
- const selectorList = selectors.split(', ');
669
- for (const sel of selectorList) {
670
- const found = root.querySelector(sel);
671
- if (found) results.push(found);
672
- }
673
- // Recurse into shadow roots
674
- const allElements = root.querySelectorAll('*');
675
- for (const el of allElements) {
676
- if (el.shadowRoot) {
677
- results.push(...queryShadowAll(el.shadowRoot, selectors));
678
- }
679
- }
680
- return results;
515
+ const script = `
516
+ ([cardNumber, expiry, cvv, cardSels, expSels, cvvSels]) => {
517
+ function queryShadowFirst(root, selectors) {
518
+ const selectorList = selectors.split(', ');
519
+ for (const sel of selectorList) {
520
+ const found = root.querySelector(sel);
521
+ if (found) return found;
681
522
  }
682
-
683
- const cardSelectors = ${JSON.stringify(exports.CARD_NUMBER_SELECTORS.join(", "))};
684
- const cardFields = queryShadowAll(document, cardSelectors);
685
- return cardFields.length > 0;
686
- })()
687
- `,
688
- returnByValue: true,
689
- });
690
- if (!result?.value)
691
- return false;
692
- // Found shadow DOM card fields — fill them
693
- const { result: fillResult } = await client.send("Runtime.evaluate", {
694
- expression: `
695
- (function() {
696
- function queryShadowFirst(root, selectors) {
697
- const selectorList = selectors.split(', ');
698
- for (const sel of selectorList) {
699
- const found = root.querySelector(sel);
523
+ const allElements = root.querySelectorAll('*');
524
+ for (const el of allElements) {
525
+ if (el.shadowRoot) {
526
+ const found = queryShadowFirst(el.shadowRoot, selectors);
700
527
  if (found) return found;
701
528
  }
702
- const allElements = root.querySelectorAll('*');
703
- for (const el of allElements) {
704
- if (el.shadowRoot) {
705
- const found = queryShadowFirst(el.shadowRoot, selectors);
706
- if (found) return found;
707
- }
708
- }
709
- return null;
710
- }
711
-
712
- function fillField(root, selectors, value) {
713
- const el = queryShadowFirst(root, selectors);
714
- if (!el) return false;
715
- const nativeSetter = Object.getOwnPropertyDescriptor(
716
- HTMLInputElement.prototype, 'value'
717
- ).set;
718
- nativeSetter.call(el, value);
719
- el.dispatchEvent(new Event('input', { bubbles: true }));
720
- el.dispatchEvent(new Event('change', { bubbles: true }));
721
- el.dispatchEvent(new Event('blur', { bubbles: true }));
722
- return true;
723
529
  }
530
+ return null;
531
+ }
724
532
 
725
- const cardFilled = fillField(
726
- document,
727
- ${JSON.stringify(exports.CARD_NUMBER_SELECTORS.join(", "))},
728
- ${JSON.stringify(cardNumber)}
729
- );
730
- if (cardFilled) {
731
- fillField(document, ${JSON.stringify(exports.EXPIRY_SELECTORS.join(", "))}, ${JSON.stringify(expiry)});
732
- fillField(document, ${JSON.stringify(exports.CVV_SELECTORS.join(", "))}, ${JSON.stringify(cvv)});
733
- }
734
- return cardFilled;
735
- })()
736
- `,
737
- returnByValue: true,
738
- });
739
- return fillResult?.value === true;
740
- }
741
- catch {
742
- return false;
743
- }
744
- }
745
- // ------------------------------------------------------------------
746
- // Internal: fill a single input field via Runtime.evaluate
747
- // ------------------------------------------------------------------
748
- async fillInputViaEval(client, evalOpts, selector, value) {
749
- try {
750
- const { result } = await client.send("Runtime.evaluate", {
751
- expression: `
752
- (function() {
753
- const el = document.querySelector(${JSON.stringify(selector)});
533
+ function fillField(root, selectors, value) {
534
+ const el = queryShadowFirst(root, selectors);
754
535
  if (!el) return false;
755
- // Use native setter to bypass framework interception
756
- const proto = el.tagName === 'SELECT'
757
- ? HTMLSelectElement.prototype
758
- : HTMLInputElement.prototype;
759
- const nativeSetter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
536
+ const nativeSetter = Object.getOwnPropertyDescriptor(
537
+ HTMLInputElement.prototype, 'value'
538
+ ).set;
760
539
  if (nativeSetter) {
761
- nativeSetter.call(el, ${JSON.stringify(value)});
540
+ nativeSetter.call(el, value);
762
541
  } else {
763
- el.value = ${JSON.stringify(value)};
542
+ el.value = value;
764
543
  }
765
544
  el.dispatchEvent(new Event('input', { bubbles: true }));
766
545
  el.dispatchEvent(new Event('change', { bubbles: true }));
767
- el.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
546
+ el.dispatchEvent(new Event('blur', { bubbles: true }));
768
547
  return true;
769
- })()
770
- `,
771
- returnByValue: true,
772
- ...evalOpts,
773
- });
774
- return result?.value === true;
775
- }
776
- catch {
777
- return false;
778
- }
779
- }
780
- // ------------------------------------------------------------------
781
- // Internal: select an option from a <select> dropdown
782
- // ------------------------------------------------------------------
783
- async selectOption(client, evalOpts, selector, value) {
784
- try {
785
- const { result } = await client.send("Runtime.evaluate", {
786
- expression: `
787
- (function() {
788
- const el = document.querySelector(${JSON.stringify(selector)});
789
- if (!el || el.tagName !== 'SELECT') return false;
790
-
791
- const options = Array.from(el.options).map(o => ({
792
- value: o.value, text: o.text.trim()
793
- }));
794
- const valueLower = ${JSON.stringify(value.toLowerCase())};
795
-
796
- let matchedValue = null;
797
- // Exact value match
798
- for (const opt of options) {
799
- if (opt.value.toLowerCase() === valueLower) { matchedValue = opt.value; break; }
800
- }
801
- // Exact text match
802
- if (!matchedValue) {
803
- for (const opt of options) {
804
- if (opt.text.toLowerCase() === valueLower) { matchedValue = opt.value; break; }
805
- }
806
- }
807
- // Partial match
808
- if (!matchedValue) {
809
- for (const opt of options) {
810
- const optText = opt.text.toLowerCase();
811
- const optVal = opt.value.toLowerCase();
812
- if ((valueLower.includes(optText) || optText.includes(valueLower) ||
813
- valueLower.includes(optVal) || optVal.includes(valueLower)) && opt.value) {
814
- matchedValue = opt.value; break;
815
- }
816
- }
817
- }
818
- if (!matchedValue) return false;
819
-
820
- // Native setter trick for React/Angular/Vue/Zoho
821
- const nativeSetter = Object.getOwnPropertyDescriptor(
822
- HTMLSelectElement.prototype, 'value'
823
- ).set;
824
- nativeSetter.call(el, matchedValue);
548
+ }
825
549
 
826
- el.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
827
- el.dispatchEvent(new FocusEvent('focus', { bubbles: false }));
828
- el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
829
- el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
830
- el.dispatchEvent(new MouseEvent('click', { bubbles: true }));
831
- el.dispatchEvent(new Event('input', { bubbles: true }));
832
- el.dispatchEvent(new Event('change', { bubbles: true }));
833
- el.dispatchEvent(new FocusEvent('blur', { bubbles: false }));
834
- el.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
835
-
836
- return el.value === matchedValue;
837
- })()
838
- `,
839
- returnByValue: true,
840
- ...evalOpts,
841
- });
842
- return result?.value === true;
550
+ const cardFilled = fillField(document, cardSels, cardNumber);
551
+ if (cardFilled) {
552
+ fillField(document, expSels, expiry);
553
+ fillField(document, cvvSels, cvv);
554
+ }
555
+ return cardFilled;
556
+ }
557
+ `;
558
+ const result = await page.evaluate(script, [
559
+ cardNumber, expiry, cvv,
560
+ exports.CARD_NUMBER_SELECTORS.join(", "),
561
+ exports.EXPIRY_SELECTORS.join(", "),
562
+ exports.CVV_SELECTORS.join(", ")
563
+ ]);
564
+ return !!result;
843
565
  }
844
566
  catch {
845
567
  return false;
846
568
  }
847
569
  }
848
- // ------------------------------------------------------------------
849
- // Internal: fill a billing field (input or select)
850
- // ------------------------------------------------------------------
851
- async fillBillingField(client, evalOpts, selectors, value, fieldName) {
852
- if (!value)
853
- return false;
854
- // Detect if first matching element is a <select> or <input>
855
- const allSelector = selectors.join(", ");
856
- try {
857
- const { result } = await client.send("Runtime.evaluate", {
858
- expression: `
859
- (function() {
860
- const el = document.querySelector(${JSON.stringify(allSelector)});
861
- if (!el) return null;
862
- return el.tagName.toLowerCase();
863
- })()
864
- `,
865
- returnByValue: true,
866
- ...evalOpts,
867
- });
868
- if (!result?.value)
869
- return false;
870
- if (result.value === "select") {
871
- return await this.selectOption(client, evalOpts, allSelector, value);
872
- }
873
- else {
874
- return await this.fillInputViaEval(client, evalOpts, allSelector, value);
875
- }
876
- }
877
- catch {
878
- return false;
879
- }
880
- }
881
- // ------------------------------------------------------------------
882
- // Internal: fill all billing fields
883
- // ------------------------------------------------------------------
884
- async fillBillingFields(client, info, evalOpts) {
570
+ async fillBillingFields(page, info) {
885
571
  const filled = [];
886
572
  const failed = [];
887
573
  const skipped = [];
888
- // Auto-expand US state abbreviations
889
- const state = info.state.length === 2
890
- ? US_STATE_CODES[info.state.toUpperCase()] ?? info.state
891
- : info.state;
892
- const tryFill = async (selectors, value, name) => {
574
+ const state = info.state.length === 2 ? US_STATE_CODES[info.state.toUpperCase()] ?? info.state : info.state;
575
+ const tryFill = async (selectors, value, name, label) => {
893
576
  if (!value) {
894
577
  skipped.push(name);
895
578
  return;
896
579
  }
897
- const ok = await this.fillBillingField(client, evalOpts, selectors, value, name);
580
+ const ok = await this.fillField(page, selectors, value, name, label);
898
581
  if (ok)
899
582
  filled.push(name);
900
583
  else
901
584
  failed.push(`${name} (value='${value}')`);
902
585
  };
903
- // Fill ORDER matters: input fields first, then select dropdowns last.
904
- // Reason: filling inputs can trigger framework re-renders (React, Zoho)
905
- // which reset previously selected dropdowns. Selects go last to survive.
906
- const fieldConfigs = [
907
- { selectors: exports.FIRST_NAME_SELECTORS, value: info.firstName, name: "first_name" },
908
- { selectors: exports.LAST_NAME_SELECTORS, value: info.lastName, name: "last_name" },
909
- { selectors: exports.STREET_SELECTORS, value: info.street, name: "street" },
910
- { selectors: exports.CITY_SELECTORS, value: info.city, name: "city" },
911
- { selectors: exports.STATE_SELECTORS, value: state, name: "state" },
912
- { selectors: exports.COUNTRY_SELECTORS, value: info.country, name: "country" },
913
- { selectors: exports.ZIP_SELECTORS, value: info.zip, name: "zip" },
914
- { selectors: exports.EMAIL_SELECTORS, value: info.email, name: "email" },
915
- ];
916
- // Full name fallback
586
+ // Input fields first
587
+ await tryFill(exports.FIRST_NAME_SELECTORS, info.firstName, "first_name", "First name");
588
+ await tryFill(exports.LAST_NAME_SELECTORS, info.lastName, "last_name", "Last name");
917
589
  if (info.firstName || info.lastName) {
918
590
  const fullName = [info.firstName, info.lastName].filter(Boolean).join(" ");
919
- fieldConfigs.push({ selectors: exports.FULL_NAME_SELECTORS, value: fullName, name: "full_name" });
591
+ await tryFill(exports.FULL_NAME_SELECTORS, fullName, "full_name", "Full name");
592
+ }
593
+ await tryFill(exports.STREET_SELECTORS, info.street, "street", "Address");
594
+ await tryFill(exports.CITY_SELECTORS, info.city, "city", "City");
595
+ await tryFill(exports.ZIP_SELECTORS, info.zip, "zip", "Zip");
596
+ await tryFill(exports.EMAIL_SELECTORS, info.email, "email", "Email");
597
+ // Selects last
598
+ await tryFill(exports.COUNTRY_SELECTORS, info.country, "country", "Country");
599
+ await tryFill(exports.STATE_SELECTORS, state, "state", "State");
600
+ // Phone
601
+ let ccFilled = false;
602
+ if (info.phoneCountryCode) {
603
+ ccFilled = await this.fillField(page, exports.PHONE_COUNTRY_CODE_SELECTORS, info.phoneCountryCode, "phone_country_code", "Country code");
604
+ if (ccFilled)
605
+ filled.push("phone_country_code");
920
606
  }
921
- // Detect tagName for each non-empty field
922
- const detections = [];
923
- for (const config of fieldConfigs) {
924
- if (!config.value) {
925
- skipped.push(config.name);
926
- continue;
607
+ const phoneValue = ccFilled ? nationalNumber(info.phone, info.phoneCountryCode) : info.phone;
608
+ await tryFill(exports.PHONE_SELECTORS, phoneValue, "phone", "Phone");
609
+ return { filled, failed, skipped };
610
+ }
611
+ async fillField(page, selectors, value, name, label) {
612
+ // Strategy 1: getByLabel
613
+ try {
614
+ const labelLocator = page.getByLabel(label, { exact: false });
615
+ if (await labelLocator.count() > 0) {
616
+ const tag = await labelLocator.first().evaluate("el => el.tagName.toLowerCase()");
617
+ if (tag === "select") {
618
+ return await this.selectOption(labelLocator.first(), value);
619
+ }
620
+ else {
621
+ await labelLocator.first().fill(value);
622
+ await this.dispatchEvents(labelLocator.first());
623
+ return true;
624
+ }
927
625
  }
928
- let tagName = null;
929
- try {
930
- const allSelector = config.selectors.join(", ");
931
- const { result } = await client.send("Runtime.evaluate", {
932
- expression: `
933
- (function() {
934
- const el = document.querySelector(${JSON.stringify(allSelector)});
935
- return el ? el.tagName.toLowerCase() : null;
936
- })()
937
- `,
938
- returnByValue: true,
939
- ...evalOpts,
940
- });
941
- tagName = result?.value || null;
626
+ }
627
+ catch { }
628
+ // Strategy 2: CSS selectors
629
+ const frame = page.mainFrame();
630
+ const locator = await this.findVisibleLocator(frame, selectors);
631
+ if (!locator)
632
+ return false;
633
+ try {
634
+ const tag = await locator.evaluate("el => el.tagName.toLowerCase()");
635
+ if (tag === "select") {
636
+ return await this.selectOption(locator, value);
942
637
  }
943
- catch {
944
- // Detection failed — treat as input (non-select)
638
+ else {
639
+ await locator.fill(value);
640
+ await this.dispatchEvents(locator);
641
+ return true;
945
642
  }
946
- detections.push({ ...config, tagName });
947
643
  }
948
- const doFill = async (d) => {
949
- const ok = await this.fillBillingField(client, evalOpts, d.selectors, d.value, d.name);
950
- if (ok)
951
- filled.push(d.name);
952
- else
953
- failed.push(`${d.name} (value='${d.value}')`);
954
- };
955
- // Round 1: fill all non-select fields (inputs, textareas, etc.)
956
- for (const d of detections) {
957
- if (d.tagName !== "select") {
958
- await doFill(d);
959
- }
644
+ catch {
645
+ return false;
960
646
  }
961
- // Round 2: fill all select dropdowns (survive re-renders)
962
- for (const d of detections) {
963
- if (d.tagName === "select") {
964
- await doFill(d);
647
+ }
648
+ async selectOption(locator, value) {
649
+ try {
650
+ const options = await locator.evaluate(`el =>
651
+ Array.from(el.options).map(o => ({ value: o.value, text: o.text.trim() }))
652
+ `);
653
+ const valueLower = value.toLowerCase();
654
+ let matchedValue = null;
655
+ for (const opt of options) {
656
+ if (opt.value.toLowerCase() === valueLower) {
657
+ matchedValue = opt.value;
658
+ break;
659
+ }
660
+ }
661
+ if (!matchedValue) {
662
+ for (const opt of options) {
663
+ if (opt.text.toLowerCase() === valueLower) {
664
+ matchedValue = opt.value;
665
+ break;
666
+ }
667
+ }
965
668
  }
669
+ if (!matchedValue) {
670
+ for (const opt of options) {
671
+ if ((valueLower.includes(opt.text.toLowerCase()) || opt.text.toLowerCase().includes(valueLower)) && opt.value) {
672
+ matchedValue = opt.value;
673
+ break;
674
+ }
675
+ }
676
+ }
677
+ if (!matchedValue)
678
+ return false;
679
+ await locator.selectOption(matchedValue);
680
+ const actual = await locator.evaluate((el) => el.value);
681
+ if (actual === matchedValue) {
682
+ await this.dispatchEvents(locator);
683
+ return true;
684
+ }
685
+ return await locator.evaluate(`(el, val) => {
686
+ const nativeSetter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set;
687
+ if (!nativeSetter) return false;
688
+ nativeSetter.call(el, val);
689
+ const events = ["focusin", "focus", "mousedown", "mouseup", "click", "input", "change", "blur", "focusout"];
690
+ events.forEach((evt) => el.dispatchEvent(new Event(evt, { bubbles: true })));
691
+ return el.value === val;
692
+ }`, matchedValue);
966
693
  }
967
- // Phone: country code dropdown first, then number
968
- let ccFilled = false;
969
- if (info.phoneCountryCode) {
970
- ccFilled = await this.fillBillingField(client, evalOpts, exports.PHONE_COUNTRY_CODE_SELECTORS, info.phoneCountryCode, "phone_country_code");
971
- if (ccFilled)
972
- filled.push("phone_country_code");
694
+ catch {
695
+ return false;
973
696
  }
974
- const phoneValue = ccFilled
975
- ? nationalNumber(info.phone, info.phoneCountryCode)
976
- : info.phone;
977
- await tryFill(exports.PHONE_SELECTORS, phoneValue, "phone");
978
- return { filled, failed, skipped };
979
697
  }
980
- // ------------------------------------------------------------------
981
- // Internal: load billing info from env vars
982
- // ------------------------------------------------------------------
983
- loadBillingInfo() {
698
+ async dispatchEvents(locator) {
699
+ try {
700
+ await locator.dispatchEvent("input");
701
+ await locator.dispatchEvent("change");
702
+ await locator.evaluate("el => el.dispatchEvent(new Event('blur', { bubbles: true }))");
703
+ }
704
+ catch { }
705
+ }
706
+ async enableBlackout(page) {
707
+ try {
708
+ for (const frame of page.frames()) {
709
+ try {
710
+ await frame.addStyleTag({
711
+ content: `
712
+ input[autocomplete*="cc-"],
713
+ input[name*="card"], input[name*="Card"],
714
+ input[name*="expir"], input[name*="cvc"], input[name*="cvv"],
715
+ input[data-elements-stable-field-name],
716
+ input.__PrivateStripeElement,
717
+ input[name="cardnumber"], input[name="cc-exp"],
718
+ input[name="security_code"], input[name="card_number"],
719
+ input[name="card_expiry"], input[name="card_cvc"] {
720
+ -webkit-text-security: disc !important;
721
+ color: transparent !important;
722
+ text-shadow: 0 0 8px rgba(0,0,0,0.5) !important;
723
+ }
724
+ `,
725
+ });
726
+ }
727
+ catch { }
728
+ }
729
+ }
730
+ catch { }
731
+ }
732
+ loadBillingFromEnv() {
984
733
  return {
985
734
  firstName: (process.env.POP_BILLING_FIRST_NAME ?? "").trim(),
986
735
  lastName: (process.env.POP_BILLING_LAST_NAME ?? "").trim(),
@@ -994,61 +743,6 @@ class PopBrowserInjector {
994
743
  phoneCountryCode: (process.env.POP_BILLING_PHONE_COUNTRY_CODE ?? "").trim(),
995
744
  };
996
745
  }
997
- // ------------------------------------------------------------------
998
- // Internal: blackout mode (mask card fields)
999
- // ------------------------------------------------------------------
1000
- async enableBlackout(client) {
1001
- try {
1002
- await client.send("Runtime.evaluate", {
1003
- expression: `
1004
- (function() {
1005
- // Inject into main frame
1006
- function addBlackout(doc) {
1007
- if (doc.getElementById('pop-pay-blackout')) return;
1008
- const style = doc.createElement('style');
1009
- style.id = 'pop-pay-blackout';
1010
- style.textContent = \`
1011
- input[autocomplete*="cc-"],
1012
- input[name*="card"], input[name*="Card"],
1013
- input[name*="expir"], input[name*="cvc"], input[name*="cvv"],
1014
- input[data-elements-stable-field-name],
1015
- input.__PrivateStripeElement,
1016
- input[name="cardnumber"], input[name="cc-exp"],
1017
- input[name="security_code"], input[name="card_number"],
1018
- input[name="card_expiry"], input[name="card_cvc"] {
1019
- -webkit-text-security: disc !important;
1020
- color: transparent !important;
1021
- text-shadow: 0 0 8px rgba(0,0,0,0.5) !important;
1022
- }
1023
- \`;
1024
- doc.head.appendChild(style);
1025
- }
1026
- addBlackout(document);
1027
-
1028
- // Try iframes (same-origin only)
1029
- try {
1030
- const iframes = document.querySelectorAll('iframe');
1031
- for (const iframe of iframes) {
1032
- try {
1033
- if (iframe.contentDocument) {
1034
- addBlackout(iframe.contentDocument);
1035
- }
1036
- } catch {}
1037
- }
1038
- } catch {}
1039
- })()
1040
- `,
1041
- });
1042
- }
1043
- catch { }
1044
- }
1045
- // ------------------------------------------------------------------
1046
- // Masked card display helper
1047
- // ------------------------------------------------------------------
1048
- static maskedCard(cardNumber) {
1049
- const last4 = cardNumber.slice(-4);
1050
- return `****-****-****-${last4}`;
1051
- }
1052
746
  }
1053
747
  exports.PopBrowserInjector = PopBrowserInjector;
1054
748
  //# sourceMappingURL=injector.js.map