@visa/cli 4.1.0-rc.190 → 4.1.0-rc.192

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.
@@ -43,9 +43,11 @@ export declare function contactNameShapes(contact: Contact, cardholderName?: str
43
43
  };
44
44
  export declare function fillContactFieldMap(page: Page, fields: FieldMap, contact: Contact, opts?: {
45
45
  fillTimeoutMs?: number;
46
+ detect?: (page: Page) => Promise<DetectResult>;
46
47
  }): Promise<FilledField[]>;
47
48
  export declare function fillFieldMap(page: Page, fields: FieldMap, credential: CardCredential, contact: Contact, opts?: {
48
49
  fillTimeoutMs?: number;
50
+ detect?: (page: Page) => Promise<DetectResult>;
49
51
  }): Promise<FilledField[]>;
50
52
  /**
51
53
  * Re-detect and adopt fresh entries for every card field after the panel is
@@ -120,6 +120,90 @@ export function summarizeFillFailure(error) {
120
120
  return error.split('\n')[0].slice(0, 120);
121
121
  }
122
122
  const DEFAULT_FILL_TIMEOUT_MS = 5000;
123
+ /**
124
+ * How many times one fill pass may re-detect after a locator goes stale.
125
+ *
126
+ * One re-mount usually invalidates a whole block of fields at once, so a single
127
+ * pass normally recovers all of them. The cap is here so a page that re-mounts
128
+ * on EVERY keystroke degrades to the old timeout behaviour instead of looping.
129
+ */
130
+ const MAX_FILL_REDETECTS = 3;
131
+ /**
132
+ * True when a detected field's locator no longer resolves to anything.
133
+ *
134
+ * `data-ca-id` is stamped onto the live nodes during detection, so it is an
135
+ * identity that dies with the node. A merchant that re-mounts part of its form
136
+ * mid-fill (address autocomplete is the common one) replaces those nodes with
137
+ * fresh, unstamped ones — the old locator then matches NOTHING and the fill
138
+ * waits out its entire timeout on an element that can never appear.
139
+ */
140
+ async function locatorIsMissing(page, entry) {
141
+ try {
142
+ return (await resolveLocator(page, entry).count()) === 0;
143
+ }
144
+ catch {
145
+ // A frame that went away, a detached context: let the fill run and report
146
+ // the real error rather than inventing a diagnosis here.
147
+ return false;
148
+ }
149
+ }
150
+ /**
151
+ * Contact and address roles — plain inputs whose value can be read straight
152
+ * back. The credential roles are deliberately absent: they sit in PSP iframes
153
+ * that tokenize and reformat what they are given, so a readback there compares
154
+ * against a value the merchant never promised to keep, and they are not what an
155
+ * address re-mount disturbs anyway.
156
+ */
157
+ const READBACK_ROLES = [
158
+ 'name',
159
+ 'nameFirst',
160
+ 'nameLast',
161
+ 'email',
162
+ 'phone',
163
+ 'addressLine1',
164
+ 'addressLine2',
165
+ 'city',
166
+ 'state',
167
+ 'postalCode',
168
+ 'country',
169
+ ];
170
+ /**
171
+ * Downgrade any field whose value did not survive to the end of the pass.
172
+ *
173
+ * Only runs when a re-mount was actually observed, and only for the roles
174
+ * above. A page that rebuilds its address block on EVERY edit erases the field
175
+ * we just filled when we fill the NEXT one, so a fill can report success over a
176
+ * box that is empty by the time anyone submits it. Recovering from a re-mount
177
+ * must not buy that recovery with a false claim: without this, the engine would
178
+ * hand the pre-submit gate a clean bill of health for an empty form — and the
179
+ * gate would let it through. Before the recovery existed these fields failed
180
+ * loudly instead, and a wrong "filled" is worse than an honest refusal.
181
+ */
182
+ async function downgradeFieldsThatDidNotHold(page, fields, filled) {
183
+ for (const record of filled) {
184
+ if (!record.ok)
185
+ continue;
186
+ if (!READBACK_ROLES.includes(record.role))
187
+ continue;
188
+ const entry = fields[record.role];
189
+ if (!entry)
190
+ continue;
191
+ let held;
192
+ try {
193
+ const loc = resolveLocator(page, entry);
194
+ held = (await loc.count()) > 0 && (await loc.inputValue()).trim() !== '';
195
+ }
196
+ catch {
197
+ // Unreadable is not evidence of empty. Leave the record alone rather than
198
+ // failing a field that is probably fine.
199
+ continue;
200
+ }
201
+ if (held)
202
+ continue;
203
+ record.ok = false;
204
+ record.error = 'value did not survive a re-render of the form';
205
+ }
206
+ }
123
207
  async function fillOne(page, role, entry, value, displayValue, fillTimeoutMs) {
124
208
  const base = {
125
209
  role,
@@ -159,23 +243,24 @@ export function contactNameShapes(contact, cardholderName) {
159
243
  }
160
244
  async function fillFields(page, fields, credential, contact, opts = {}) {
161
245
  const fillTimeoutMs = opts.fillTimeoutMs ?? DEFAULT_FILL_TIMEOUT_MS;
246
+ const detect = opts.detect ?? detectFields;
162
247
  const filled = [];
163
248
  const { fullName, first, last } = contactNameShapes(contact, credential?.cardholderName);
164
249
  // Order matters a little: contact/name before card is harmless, but we fill
165
250
  // card fields explicitly per role so order is not load-bearing.
251
+ //
252
+ // A job records the ROLE it fills, never a captured entry: the entry is
253
+ // resolved from the live map at the moment the job runs, because a re-mount
254
+ // part-way through this pass can replace the element a role points at.
255
+ // `value` is derived from that same entry, since whether a role is a <select>
256
+ // (and which options it offers) is a property of the element we end up with.
166
257
  const jobs = [];
167
- const add = (role, entry, value, display) => {
168
- if (!entry)
169
- return;
170
- // Skip fields that are not currently visible (e.g. a collapsed accordion
171
- // panel or a not-yet-reached step). The executor reveals them and re-fills.
172
- if (entry.visible === false)
258
+ const add = (role, detected, value, display) => {
259
+ // Gate on the role having been detected at all. Roles absent from the
260
+ // original map stay out of scope even if a later pass would find them.
261
+ if (!detected)
173
262
  return;
174
- jobs.push({
175
- role,
176
- entry,
177
- run: async () => fillOne(page, role, entry, value(), display(), fillTimeoutMs),
178
- });
263
+ jobs.push({ role, value, display });
179
264
  };
180
265
  if (credential) {
181
266
  add('number', fields.number, () => credential.pan, () => maskPan(credential.pan));
@@ -190,30 +275,22 @@ async function fillFields(page, fields, credential, contact, opts = {}) {
190
275
  // Expiry display values are always redacted: the expiry is part of the
191
276
  // keyable credential (DPAN + expiry + DAVV) and never enters the log.
192
277
  if (credential && fields.expCombined) {
193
- const e = fields.expCombined;
194
- const v = expCombinedValue(e, credential.expMonth, credential.expYear);
195
- add('expCombined', e, () => v, () => maskExpiry());
278
+ add('expCombined', fields.expCombined, (e) => expCombinedValue(e, credential.expMonth, credential.expYear), () => maskExpiry());
196
279
  }
197
280
  if (credential && fields.expMonth) {
198
- const e = fields.expMonth;
199
- const value = e.tag === 'select'
281
+ add('expMonth', fields.expMonth, (e) => e.tag === 'select'
200
282
  ? (monthOptionValue(e.options ?? [], credential.expMonth) ?? pad2(credential.expMonth))
201
- : pad2(credential.expMonth);
202
- add('expMonth', e, () => value, () => maskExpiry());
283
+ : pad2(credential.expMonth), () => maskExpiry());
203
284
  }
204
285
  if (credential && fields.expYear) {
205
- const e = fields.expYear;
206
- let value;
207
- if (e.tag === 'select') {
208
- value = yearOptionValue(e.options ?? [], credential.expYear) ?? String(credential.expYear);
209
- }
210
- else if ((e.maxlength ?? 0) === 2) {
211
- value = pad2(credential.expYear % 100);
212
- }
213
- else {
214
- value = String(credential.expYear);
215
- }
216
- add('expYear', e, () => value, () => maskExpiry());
286
+ add('expYear', fields.expYear, (e) => {
287
+ if (e.tag === 'select') {
288
+ return yearOptionValue(e.options ?? [], credential.expYear) ?? String(credential.expYear);
289
+ }
290
+ if ((e.maxlength ?? 0) === 2)
291
+ return pad2(credential.expYear % 100);
292
+ return String(credential.expYear);
293
+ }, () => maskExpiry());
217
294
  }
218
295
  // Contact / shipping. Display values are always redacted: these are PII and
219
296
  // the evidence log is built to be persistable.
@@ -230,23 +307,52 @@ async function fillFields(page, fields, credential, contact, opts = {}) {
230
307
  if (contact.postalCode)
231
308
  add('postalCode', fields.postalCode, () => contact.postalCode, () => redactContact('postalCode', contact.postalCode));
232
309
  if (fields.state && contact.state) {
233
- const e = fields.state;
234
310
  const wanted = contact.state;
235
- const value = e.tag === 'select' ? (matchOption(e.options ?? [], [wanted]) ?? wanted) : wanted;
236
- add('state', e, () => value, () => redactContact('state', value));
311
+ add('state', fields.state, (e) => (e.tag === 'select' ? (matchOption(e.options ?? [], [wanted]) ?? wanted) : wanted), (value) => redactContact('state', value));
237
312
  }
238
313
  if (fields.country && contact.country) {
239
- const e = fields.country;
240
- const value = e.tag === 'select'
241
- ? (matchOption(e.options ?? [], [contact.country]) ?? contact.country)
242
- : contact.country;
243
- add('country', e, () => value, () => redactContact('country', value));
314
+ const wanted = contact.country;
315
+ add('country', fields.country, (e) => (e.tag === 'select' ? (matchOption(e.options ?? [], [wanted]) ?? wanted) : wanted), (value) => redactContact('country', value));
244
316
  }
245
- for (const j of jobs) {
246
- const r = await j.run();
247
- if (r)
248
- filled.push(r);
317
+ // The map is re-read per job, and a re-detect REPLACES it wholesale rather
318
+ // than patching the one entry that went stale: a detection pass re-stamps
319
+ // every element from scratch, so an id handed out by the previous pass can
320
+ // afterwards name a different element. Adopting the fresh map for every
321
+ // remaining job is what keeps a recovery from filling the wrong box.
322
+ let current = fields;
323
+ let redetects = 0;
324
+ for (const job of jobs) {
325
+ let entry = current[job.role];
326
+ // Skip fields that are not currently visible (e.g. a collapsed accordion
327
+ // panel or a not-yet-reached step). The executor reveals them and re-fills.
328
+ if (!entry || entry.visible === false)
329
+ continue;
330
+ let relocated = false;
331
+ if (redetects < MAX_FILL_REDETECTS && (await locatorIsMissing(page, entry))) {
332
+ redetects++;
333
+ const fresh = await detect(page).then((r) => r.fields, () => null);
334
+ if (fresh) {
335
+ current = fresh;
336
+ const next = current[job.role];
337
+ if (next && next.visible !== false) {
338
+ entry = next;
339
+ relocated = true;
340
+ }
341
+ // If the role is gone from the fresh map, deliberately keep the stale
342
+ // entry and let the fill fail on it. Skipping would leave NO record of
343
+ // the field, and the pre-submit gate reads "never attempted" as
344
+ // "nothing to worry about" — which is how an incomplete form gets
345
+ // submitted. A recorded failure refuses; a silent skip does not.
346
+ }
347
+ }
348
+ const value = job.value(entry);
349
+ const result = await fillOne(page, job.role, entry, value, job.display(value), fillTimeoutMs);
350
+ filled.push(relocated ? { ...result, relocated: true } : result);
249
351
  }
352
+ // Only when a re-mount was actually seen: everywhere else the page never
353
+ // moved under us and the readback would be pure cost.
354
+ if (redetects > 0)
355
+ await downgradeFieldsThatDidNotHold(page, current, filled);
250
356
  return filled;
251
357
  }
252
358
  // Credential-free contact prefill for merchants that must calculate shipping,
@@ -121,6 +121,14 @@ export type SubmitApprovedCheckoutOptions = {
121
121
  onChallengeHold?: (signal: string | null) => void;
122
122
  resolveEmailOtp?: OtpResolver;
123
123
  };
124
+ export type SubmitCandidateMeta = {
125
+ label: string;
126
+ ariaHidden: boolean;
127
+ tabIndex: number | null;
128
+ visible: boolean;
129
+ area: number;
130
+ };
131
+ export declare function preferredSubmitIndex(cands: readonly SubmitCandidateMeta[]): number;
124
132
  export declare function snapshotOrigin(rawUrl: string): string;
125
133
  export type PreparedCheckoutSession = {
126
134
  checkout: PreparedCheckout;
@@ -124,17 +124,52 @@ async function fingerprintSubmitTarget(locator, kind, fallbackLabel) {
124
124
  };
125
125
  }, { targetKind: kind, targetFallbackLabel: fallbackLabel });
126
126
  }
127
+ // Shopify checkouts keep INERT duplicates of the pay control in the DOM —
128
+ // aria-hidden="true", tabindex="-1", and/or zero-size. Playwright still reports
129
+ // those as "visible, enabled and stable", so a bare `.first()` resolves to one
130
+ // and then every click is swallowed by whatever paints on top of it
131
+ // (observed live: `<h3 id="billingAddress"> intercepts pointer events`, retried
132
+ // until the 6s timeout, deterministically, on casper.com). Rank the real
133
+ // controls ahead of the inert ones and prefer a pay-labelled control.
134
+ export function preferredSubmitIndex(cands) {
135
+ const usable = cands
136
+ .map((c, i) => ({ c, i }))
137
+ .filter(({ c }) => c.visible && !c.ariaHidden && c.tabIndex !== -1 && c.area > 0);
138
+ if (usable.length === 0)
139
+ return -1;
140
+ const payLike = usable.find(({ c }) => PAY_LABEL.test(c.label));
141
+ return (payLike ?? usable[0]).i;
142
+ }
143
+ const PAY_LABEL = /pay|place order|complete order|submit order|buy now/i;
127
144
  async function findSubmit(page) {
128
- const submitBtn = page.locator('button[type="submit"], input[type="submit"]').first();
129
- if ((await submitBtn.count().catch(() => 0)) > 0) {
130
- const label = ((await submitBtn.textContent().catch(() => '')) || '').trim() ||
131
- (await submitBtn.getAttribute('value').catch(() => '')) ||
132
- 'submit';
133
- return {
134
- desc: `submit button ("${label}")`,
135
- fingerprint: await fingerprintSubmitTarget(submitBtn, 'submit-control', 'submit'),
136
- click: () => submitBtn.click(),
137
- };
145
+ const controls = page.locator('button[type="submit"], input[type="submit"]');
146
+ const handles = await controls.all().catch(() => []);
147
+ if (handles.length > 0) {
148
+ const metas = await Promise.all(handles.map(async (h) => {
149
+ const text = ((await h.textContent().catch(() => '')) || '').trim();
150
+ const value = text || (await h.getAttribute('value').catch(() => '')) || '';
151
+ const ariaHidden = await h.getAttribute('aria-hidden').catch(() => null);
152
+ const tabIndexRaw = await h.getAttribute('tabindex').catch(() => null);
153
+ const visible = await h.isVisible().catch(() => false);
154
+ const box = await h.boundingBox().catch(() => null);
155
+ return {
156
+ label: value,
157
+ ariaHidden: ariaHidden === 'true',
158
+ tabIndex: tabIndexRaw === null ? null : Number(tabIndexRaw),
159
+ visible,
160
+ area: box ? box.width * box.height : 0,
161
+ };
162
+ }));
163
+ const idx = preferredSubmitIndex(metas);
164
+ if (idx >= 0) {
165
+ const chosen = handles[idx];
166
+ const label = metas[idx].label || 'submit';
167
+ return {
168
+ desc: `submit button ("${label}")`,
169
+ fingerprint: await fingerprintSubmitTarget(chosen, 'submit-control', 'submit'),
170
+ click: () => chosen.click(),
171
+ };
172
+ }
138
173
  }
139
174
  const byText = page.getByRole('button', { name: SUBMIT_TEXT }).first();
140
175
  if ((await byText.count().catch(() => 0)) > 0) {
@@ -1095,6 +1130,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1095
1130
  value: f.value,
1096
1131
  ok: f.ok,
1097
1132
  error: f.error,
1133
+ ...(f.relocated ? { relocated: true } : {}),
1098
1134
  });
1099
1135
  }
1100
1136
  const numberOk = fill.filled.some((f) => f.role === 'number' && f.ok);
@@ -23,6 +23,14 @@ export type FilledField = {
23
23
  value: string;
24
24
  ok: boolean;
25
25
  error?: string;
26
+ /**
27
+ * The detected node was gone by the time this field's turn came (the merchant
28
+ * re-mounted that part of the form mid-fill) and a fresh detection pass was
29
+ * used to find it again. Worth surfacing: a merchant that re-mounts is a
30
+ * merchant whose fills are order-sensitive, which is the first thing to know
31
+ * when one of them later refuses.
32
+ */
33
+ relocated?: boolean;
26
34
  };
27
35
  export type FillResult = {
28
36
  ok: boolean;