@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.
- package/dist/checkout-engine/adapters/generic.d.ts +2 -0
- package/dist/checkout-engine/adapters/generic.js +148 -42
- package/dist/checkout-engine/executor.d.ts +8 -0
- package/dist/checkout-engine/executor.js +46 -10
- package/dist/checkout-engine/types.d.ts +8 -0
- package/dist/cli.js +8 -8
- package/dist/mcp-server/index.js +6 -6
- package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -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,
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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
|
|
240
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
'
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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;
|