@visa/cli 4.1.0-rc.26 → 4.1.0-rc.260
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/README.md +202 -46
- package/dist/checkout-engine/adapters/generic.d.ts +69 -0
- package/dist/checkout-engine/adapters/generic.js +383 -58
- package/dist/checkout-engine/adapters/index.d.ts +4 -1
- package/dist/checkout-engine/adapters/index.js +10 -3
- package/dist/checkout-engine/adapters/shopify.d.ts +80 -0
- package/dist/checkout-engine/adapters/shopify.js +688 -0
- package/dist/checkout-engine/amount.d.ts +15 -0
- package/dist/checkout-engine/amount.js +72 -0
- package/dist/checkout-engine/browser-launch.d.ts +9 -4
- package/dist/checkout-engine/browser-launch.js +19 -4
- package/dist/checkout-engine/browserbase-browser.d.ts +24 -0
- package/dist/checkout-engine/browserbase-browser.js +186 -0
- package/dist/checkout-engine/cli-engine.d.ts +206 -32
- package/dist/checkout-engine/cli-engine.js +772 -216
- package/dist/checkout-engine/confirmed-merchants.d.ts +31 -0
- package/dist/checkout-engine/confirmed-merchants.js +165 -0
- package/dist/checkout-engine/detect.d.ts +1 -1
- package/dist/checkout-engine/detect.js +6 -0
- package/dist/checkout-engine/evidence.d.ts +1 -1
- package/dist/checkout-engine/executor.d.ts +61 -3
- package/dist/checkout-engine/executor.js +550 -144
- package/dist/checkout-engine/hosted-approval.d.ts +69 -9
- package/dist/checkout-engine/hosted-approval.js +211 -21
- package/dist/checkout-engine/index.d.ts +6 -2
- package/dist/checkout-engine/index.js +5 -1
- package/dist/checkout-engine/instrument.d.ts +6 -0
- package/dist/checkout-engine/known-merchants.d.ts +10 -0
- package/dist/checkout-engine/known-merchants.js +38 -0
- package/dist/checkout-engine/live-fill-approval.d.ts +5 -11
- package/dist/checkout-engine/live-fill-approval.js +20 -34
- package/dist/checkout-engine/mandate/card-mandate.d.ts +6 -2
- package/dist/checkout-engine/mandate/card-mandate.js +10 -5
- package/dist/checkout-engine/mandate/mandate-ledger.d.ts +63 -23
- package/dist/checkout-engine/mandate/mandate-ledger.js +124 -17
- package/dist/checkout-engine/receipt-dir.d.ts +6 -0
- package/dist/checkout-engine/receipt-dir.js +8 -0
- package/dist/checkout-engine/receipt.d.ts +56 -2
- package/dist/checkout-engine/receipt.js +55 -16
- package/dist/checkout-engine/shopify-primary-domain.d.ts +25 -0
- package/dist/checkout-engine/shopify-primary-domain.js +96 -0
- package/dist/checkout-engine/trace-handles.d.ts +8 -0
- package/dist/checkout-engine/trace-handles.js +12 -0
- package/dist/checkout-engine/types.d.ts +15 -2
- package/dist/checkout-engine/unresolved-charges.d.ts +34 -0
- package/dist/checkout-engine/unresolved-charges.js +134 -0
- package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +26 -7
- package/dist/checkout-engine/vgs-gateway/server-mint-client.js +43 -3
- package/dist/checkout-engine/vgs-live-instrument.d.ts +11 -35
- package/dist/checkout-engine/vgs-live-instrument.js +14 -74
- package/dist/checkout-engine/vic-confirmation.d.ts +18 -0
- package/dist/checkout-engine/vic-confirmation.js +9 -3
- package/dist/checkout-engine/web-bot-auth.d.ts +98 -0
- package/dist/checkout-engine/web-bot-auth.js +218 -0
- package/dist/cli.js +899 -387
- package/dist/mcp-apps/ucp-checkout.html +280 -0
- package/dist/mcp-server/index.js +725 -254
- package/dist/merchant-ucp-mcp/index.js +6 -0
- package/dist/skills/pair-visa-agent/RUNTIMES.md +122 -79
- package/dist/skills/pair-visa-agent/SKILL.md +433 -318
- package/dist/skills/pair-visa-agent/scripts/__tests__/setup.test.mjs +407 -0
- package/dist/skills/pair-visa-agent/scripts/setup.mjs +310 -30
- package/dist/skills/visa-shopify-checkout/SKILL.md +122 -0
- package/dist/skills/visa-shopify-checkout/references/evidence-and-states.md +37 -0
- package/dist/skills/visa-ucp-shopping/SKILL.md +86 -0
- package/dist/subway-direct.mjs +1 -0
- package/install.ps1 +7 -6
- package/install.sh +3 -3
- package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
- package/package.json +31 -28
- package/server.json +4 -4
- package/dist/checkout-engine/inline-target.d.ts +0 -13
- package/dist/checkout-engine/inline-target.js +0 -37
- package/dist/checkout-engine/pay-args.d.ts +0 -14
- package/dist/checkout-engine/pay-args.js +0 -44
- package/dist/checkout-engine/pay.d.ts +0 -1
- package/dist/checkout-engine/pay.js +0 -13
- package/dist/checkout-engine/repo-env.d.ts +0 -11
- package/dist/checkout-engine/repo-env.js +0 -23
- package/dist/checkout-engine/run-live-fill.d.ts +0 -1
- package/dist/checkout-engine/run-live-fill.js +0 -493
- package/dist/checkout-engine/vgs-gateway/fetch-credential.d.mts +0 -74
- package/dist/checkout-engine/vgs-gateway/fetch-credential.mjs +0 -248
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// <select> dropdowns, handles split vs combined expiry, two- vs four-digit
|
|
3
3
|
// years, and split first/last name. It is the fallback that should beat any
|
|
4
4
|
// well-behaved guest checkout on its own.
|
|
5
|
+
import { detectFields } from '../detect.js';
|
|
5
6
|
import { maskCvc, maskExpiry, maskPan, redactContact } from '../evidence.js';
|
|
6
7
|
function pad2(n) {
|
|
7
8
|
return String(n).padStart(2, '0');
|
|
@@ -73,7 +74,136 @@ export function scrubFillErrorMessage(message, value) {
|
|
|
73
74
|
scrubbed = scrubbed.split(value).join('<redacted>');
|
|
74
75
|
return scrubbed;
|
|
75
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* A fill failure in a few words, for a message a human reads.
|
|
79
|
+
*
|
|
80
|
+
* Playwright's error is a multi-line call log — useful in the evidence file,
|
|
81
|
+
* unreadable in a refusal message and in the receipt an operator opens a week
|
|
82
|
+
* later. The refusal names WHICH fields refused; without this it never says
|
|
83
|
+
* WHY, so diagnosing a merchant we cannot drive means either reproducing it or
|
|
84
|
+
* reading someone's evidence JSON. Each cause maps to a different fix:
|
|
85
|
+
*
|
|
86
|
+
* not editable — the input exists but is readonly/disabled at fill time
|
|
87
|
+
* (a custom widget owning the value, or a not-yet-ready
|
|
88
|
+
* form). Typing will not help; the field needs an adapter
|
|
89
|
+
* or a longer wait.
|
|
90
|
+
* not a text field — a non-input element pretending to be one. Needs an
|
|
91
|
+
* adapter that drives the widget.
|
|
92
|
+
* not visible /
|
|
93
|
+
* detached — a re-render race. The reveal loop is the lever.
|
|
94
|
+
*
|
|
95
|
+
* Input is already scrubbed by scrubFillErrorMessage; this only ever shortens.
|
|
96
|
+
*/
|
|
97
|
+
export function summarizeFillFailure(error) {
|
|
98
|
+
if (!error)
|
|
99
|
+
return 'no reason recorded';
|
|
100
|
+
if (/not an? <input>|not.*\[contenteditable\]/i.test(error))
|
|
101
|
+
return 'not a text field';
|
|
102
|
+
if (/element is not visible/i.test(error))
|
|
103
|
+
return 'not visible';
|
|
104
|
+
if (/not attached to the DOM|detached/i.test(error))
|
|
105
|
+
return 'detached from the page';
|
|
106
|
+
if (/element is not enabled/i.test(error))
|
|
107
|
+
return 'disabled';
|
|
108
|
+
if (/not editable/i.test(error)) {
|
|
109
|
+
const timeout = /Timeout (\d+)ms exceeded/i.exec(error);
|
|
110
|
+
return timeout
|
|
111
|
+
? `not editable within ${Math.round(Number(timeout[1]) / 1000)}s`
|
|
112
|
+
: 'not editable';
|
|
113
|
+
}
|
|
114
|
+
if (/Timeout (\d+)ms exceeded/i.test(error)) {
|
|
115
|
+
const timeout = /Timeout (\d+)ms exceeded/i.exec(error);
|
|
116
|
+
return `timed out after ${Math.round(Number(timeout[1]) / 1000)}s`;
|
|
117
|
+
}
|
|
118
|
+
// Unrecognized: the first line, bounded. Better a clipped real message than a
|
|
119
|
+
// confident wrong summary.
|
|
120
|
+
return error.split('\n')[0].slice(0, 120);
|
|
121
|
+
}
|
|
76
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
|
+
}
|
|
77
207
|
async function fillOne(page, role, entry, value, displayValue, fillTimeoutMs) {
|
|
78
208
|
const base = {
|
|
79
209
|
role,
|
|
@@ -97,66 +227,77 @@ async function fillOne(page, role, entry, value, displayValue, fillTimeoutMs) {
|
|
|
97
227
|
return { ...base, ok: false, error: scrubFillErrorMessage(err.message, value) };
|
|
98
228
|
}
|
|
99
229
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
230
|
+
/**
|
|
231
|
+
* The contact record and the page rarely agree on name shape: the record may
|
|
232
|
+
* carry fullName while the page wants first/last inputs, or vice versa. Derive
|
|
233
|
+
* the missing shape so either page can be filled from either record.
|
|
234
|
+
*/
|
|
235
|
+
export function contactNameShapes(contact, cardholderName) {
|
|
236
|
+
const fullName = contact.fullName ??
|
|
237
|
+
(contact.firstName && contact.lastName
|
|
238
|
+
? `${contact.firstName} ${contact.lastName}`
|
|
239
|
+
: cardholderName);
|
|
240
|
+
const first = contact.firstName ?? (fullName?.split(/\s+/)[0] || undefined);
|
|
241
|
+
const last = contact.lastName ?? (fullName?.split(/\s+/).slice(1).join(' ') || undefined);
|
|
242
|
+
return { fullName, first, last };
|
|
243
|
+
}
|
|
244
|
+
async function fillFields(page, fields, credential, contact, opts = {}) {
|
|
103
245
|
const fillTimeoutMs = opts.fillTimeoutMs ?? DEFAULT_FILL_TIMEOUT_MS;
|
|
246
|
+
const detect = opts.detect ?? detectFields;
|
|
104
247
|
const filled = [];
|
|
105
|
-
const first = contact
|
|
106
|
-
const last = contact.lastName ?? credential.cardholderName.split(/\s+/).slice(1).join(' ') ?? '';
|
|
248
|
+
const { fullName, first, last } = contactNameShapes(contact, credential?.cardholderName);
|
|
107
249
|
// Order matters a little: contact/name before card is harmless, but we fill
|
|
108
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.
|
|
109
257
|
const jobs = [];
|
|
110
|
-
const add = (role,
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
// panel or a not-yet-reached step). The executor reveals them and re-fills.
|
|
115
|
-
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)
|
|
116
262
|
return;
|
|
117
|
-
jobs.push({
|
|
118
|
-
role,
|
|
119
|
-
entry,
|
|
120
|
-
run: async () => fillOne(page, role, entry, value(), display(), fillTimeoutMs),
|
|
121
|
-
});
|
|
263
|
+
jobs.push({ role, value, display });
|
|
122
264
|
};
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
265
|
+
if (credential) {
|
|
266
|
+
add('number', fields.number, () => credential.pan, () => maskPan(credential.pan));
|
|
267
|
+
add('cvc', fields.cvc, () => credential.cvc, () => maskCvc(credential.cvc));
|
|
268
|
+
}
|
|
269
|
+
if (fullName)
|
|
270
|
+
add('name', fields.name, () => fullName, () => redactContact('name', fullName));
|
|
271
|
+
if (first)
|
|
272
|
+
add('nameFirst', fields.nameFirst, () => first, () => redactContact('nameFirst', first));
|
|
273
|
+
if (last)
|
|
274
|
+
add('nameLast', fields.nameLast, () => last, () => redactContact('nameLast', last));
|
|
128
275
|
// Expiry display values are always redacted: the expiry is part of the
|
|
129
276
|
// keyable credential (DPAN + expiry + DAVV) and never enters the log.
|
|
130
|
-
if (fields.expCombined) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if (fields.expMonth) {
|
|
136
|
-
const e = fields.expMonth;
|
|
137
|
-
const value = e.tag === 'select'
|
|
277
|
+
if (credential && fields.expCombined) {
|
|
278
|
+
add('expCombined', fields.expCombined, (e) => expCombinedValue(e, credential.expMonth, credential.expYear), () => maskExpiry());
|
|
279
|
+
}
|
|
280
|
+
if (credential && fields.expMonth) {
|
|
281
|
+
add('expMonth', fields.expMonth, (e) => e.tag === 'select'
|
|
138
282
|
? (monthOptionValue(e.options ?? [], credential.expMonth) ?? pad2(credential.expMonth))
|
|
139
|
-
: pad2(credential.expMonth);
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
151
|
-
else {
|
|
152
|
-
value = String(credential.expYear);
|
|
153
|
-
}
|
|
154
|
-
add('expYear', e, () => value, () => maskExpiry());
|
|
283
|
+
: pad2(credential.expMonth), () => maskExpiry());
|
|
284
|
+
}
|
|
285
|
+
if (credential && fields.expYear) {
|
|
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());
|
|
155
294
|
}
|
|
156
295
|
// Contact / shipping. Display values are always redacted: these are PII and
|
|
157
296
|
// the evidence log is built to be persistable.
|
|
158
297
|
if (contact.email)
|
|
159
298
|
add('email', fields.email, () => contact.email, () => redactContact('email', contact.email));
|
|
299
|
+
if (contact.phone)
|
|
300
|
+
add('phone', fields.phone, () => contact.phone, () => redactContact('phone', contact.phone));
|
|
160
301
|
if (contact.addressLine1)
|
|
161
302
|
add('addressLine1', fields.addressLine1, () => contact.addressLine1, () => redactContact('addressLine1', contact.addressLine1));
|
|
162
303
|
if (contact.addressLine2)
|
|
@@ -166,25 +307,202 @@ export async function fillFieldMap(page, fields, credential, contact, opts = {})
|
|
|
166
307
|
if (contact.postalCode)
|
|
167
308
|
add('postalCode', fields.postalCode, () => contact.postalCode, () => redactContact('postalCode', contact.postalCode));
|
|
168
309
|
if (fields.state && contact.state) {
|
|
169
|
-
const e = fields.state;
|
|
170
310
|
const wanted = contact.state;
|
|
171
|
-
|
|
172
|
-
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));
|
|
173
312
|
}
|
|
174
313
|
if (fields.country && contact.country) {
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
? (matchOption(e.options ?? [], [contact.country]) ?? contact.country)
|
|
178
|
-
: contact.country;
|
|
179
|
-
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));
|
|
180
316
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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);
|
|
185
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);
|
|
186
356
|
return filled;
|
|
187
357
|
}
|
|
358
|
+
// Credential-free contact prefill for merchants that must calculate shipping,
|
|
359
|
+
// tax, and the final total before a human reviews the payment.
|
|
360
|
+
export async function fillContactFieldMap(page, fields, contact, opts = {}) {
|
|
361
|
+
return fillFields(page, fields, null, contact, opts);
|
|
362
|
+
}
|
|
363
|
+
// The shared post-approval fill routine used by every adapter.
|
|
364
|
+
export async function fillFieldMap(page, fields, credential, contact, opts = {}) {
|
|
365
|
+
return fillFields(page, fields, credential, contact, opts);
|
|
366
|
+
}
|
|
367
|
+
/** Option text that identifies a card-paying choice, most specific first. */
|
|
368
|
+
const CARD_OPTION_PATTERNS = [
|
|
369
|
+
/^\s*visa\s*$/i,
|
|
370
|
+
/credit\s*card|card\s*payment/i,
|
|
371
|
+
/^\s*(mastercard|master\s*card)\s*$/i,
|
|
372
|
+
/\bcard\b/i,
|
|
373
|
+
];
|
|
374
|
+
/**
|
|
375
|
+
* Attribute selectors for a card-number input that survive a panel re-render,
|
|
376
|
+
* tried in order. The originally detected locator is tried first so a page that
|
|
377
|
+
* does NOT re-render keeps its higher-confidence match.
|
|
378
|
+
*/
|
|
379
|
+
const CARD_NUMBER_FALLBACK_SELECTORS = [
|
|
380
|
+
'input[autocomplete="cc-number"]',
|
|
381
|
+
'input[name*="creditcardnumber" i]',
|
|
382
|
+
'input[name*="cardnumber" i]',
|
|
383
|
+
'input[id*="cardnumber" i]',
|
|
384
|
+
'input[name*="cc-number" i]',
|
|
385
|
+
];
|
|
386
|
+
/** First selector that resolves to a visible input, or null if none do. */
|
|
387
|
+
async function firstVisibleCardNumberLocator(page, detectedLocator, timeoutMs) {
|
|
388
|
+
for (const selector of [detectedLocator, ...CARD_NUMBER_FALLBACK_SELECTORS]) {
|
|
389
|
+
try {
|
|
390
|
+
await page
|
|
391
|
+
.locator(selector)
|
|
392
|
+
.first()
|
|
393
|
+
.waitFor({ state: 'visible', timeout: Math.max(500, Math.floor(timeoutMs / 3)) });
|
|
394
|
+
return selector;
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
/** Card-credential roles that share the panel a payment select unfolds. */
|
|
403
|
+
const CARD_GROUP_ROLES = ['number', 'cvc', 'expCombined', 'expMonth', 'expYear'];
|
|
404
|
+
/**
|
|
405
|
+
* Re-detect and adopt fresh entries for every card field after the panel is
|
|
406
|
+
* unfolded. Injected for tests; the executor's own detector is used in
|
|
407
|
+
* production.
|
|
408
|
+
*/
|
|
409
|
+
export async function refreshCardGroupFromPage(page, fields, detect = detectFields) {
|
|
410
|
+
let fresh;
|
|
411
|
+
try {
|
|
412
|
+
fresh = (await detect(page)).fields;
|
|
413
|
+
}
|
|
414
|
+
catch {
|
|
415
|
+
return [];
|
|
416
|
+
}
|
|
417
|
+
const adopted = [];
|
|
418
|
+
for (const role of CARD_GROUP_ROLES) {
|
|
419
|
+
const next = fresh[role];
|
|
420
|
+
if (!next || next.visible === false)
|
|
421
|
+
continue;
|
|
422
|
+
const current = fields[role];
|
|
423
|
+
// Only ever replace an entry we could not have filled anyway. A field that
|
|
424
|
+
// is already visible was detected against the live DOM and keeps its
|
|
425
|
+
// higher-confidence match.
|
|
426
|
+
if (current && current.visible !== false)
|
|
427
|
+
continue;
|
|
428
|
+
fields[role] = next;
|
|
429
|
+
adopted.push(role);
|
|
430
|
+
}
|
|
431
|
+
return adopted;
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Reveal card fields that a checkout keeps collapsed until a payment method is
|
|
435
|
+
* chosen.
|
|
436
|
+
*
|
|
437
|
+
* `fillFields` skips any entry with `visible === false`, so a card-number input
|
|
438
|
+
* sitting inside a folded panel is never even attempted — the generic adapter
|
|
439
|
+
* then reports `ok: false` ("fill incomplete") without having typed anything.
|
|
440
|
+
* That is the correct default: filling an invisible input is how a credential
|
|
441
|
+
* gets typed into the wrong place. But a payment-method `<select>` guarding the
|
|
442
|
+
* card panel is common enough to be worth handling, and the recovery is a
|
|
443
|
+
* single deterministic interaction rather than a guess.
|
|
444
|
+
*
|
|
445
|
+
* We only ever SELECT a card option — never a wallet, bank transfer, or
|
|
446
|
+
* anything else — and we only act when the card field is already detected but
|
|
447
|
+
* hidden. If nothing changes, the caller proceeds exactly as before and still
|
|
448
|
+
* fails closed.
|
|
449
|
+
*
|
|
450
|
+
* Mutates `fields.number.visible` on success so the subsequent fill attempts
|
|
451
|
+
* the field it just revealed.
|
|
452
|
+
*/
|
|
453
|
+
export async function revealCollapsedCardSection(page, fields, opts = {}) {
|
|
454
|
+
const number = fields.number;
|
|
455
|
+
if (!number || number.visible !== false)
|
|
456
|
+
return { revealed: false, via: null };
|
|
457
|
+
const timeoutMs = opts.timeoutMs ?? 5_000;
|
|
458
|
+
const selects = page.locator('select');
|
|
459
|
+
const count = await selects.count().catch(() => 0);
|
|
460
|
+
for (let i = 0; i < Math.min(count, 12); i++) {
|
|
461
|
+
const select = selects.nth(i);
|
|
462
|
+
// Read option labels through the locator API rather than page.evaluate.
|
|
463
|
+
// A bundled build rewrites the function passed to evaluate() and the
|
|
464
|
+
// injected helper is not defined in page scope, so it throws at runtime —
|
|
465
|
+
// silently, once a catch treats it as "this select didn't match". Staying
|
|
466
|
+
// on the locator API keeps this working in source and bundled alike.
|
|
467
|
+
let labels;
|
|
468
|
+
try {
|
|
469
|
+
labels = await select.locator('option').allTextContents();
|
|
470
|
+
}
|
|
471
|
+
catch {
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
for (const pattern of CARD_OPTION_PATTERNS) {
|
|
475
|
+
const label = labels.map((l) => l.trim()).find((l) => l && pattern.test(l));
|
|
476
|
+
if (!label)
|
|
477
|
+
continue;
|
|
478
|
+
try {
|
|
479
|
+
await select.selectOption({ label }, { timeout: timeoutMs });
|
|
480
|
+
}
|
|
481
|
+
catch {
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
// Re-acquire the field instead of waiting on the detected locator.
|
|
485
|
+
// Unfolding the panel typically re-renders it, and the detector's
|
|
486
|
+
// synthetic `data-ca-id` attribute does not survive that — waiting on the
|
|
487
|
+
// old locator times out even though the field is now on screen and
|
|
488
|
+
// fillable. Stable attribute selectors survive the re-render.
|
|
489
|
+
const revealedLocator = await firstVisibleCardNumberLocator(page, number.locator, timeoutMs);
|
|
490
|
+
if (!revealedLocator)
|
|
491
|
+
continue;
|
|
492
|
+
number.locator = revealedLocator;
|
|
493
|
+
number.visible = true;
|
|
494
|
+
// The number is not alone in that panel: cvc and expiry were re-rendered
|
|
495
|
+
// with it and still carry stale, invisible entries. Filling only the
|
|
496
|
+
// number would trade "adapter fill incomplete" for "credential fill
|
|
497
|
+
// incomplete: missing cvc, expiry" — still a failed purchase, still after
|
|
498
|
+
// a credential was minted. Re-detect and adopt fresh entries for the
|
|
499
|
+
// whole card group.
|
|
500
|
+
await refreshCardGroupFromPage(page, fields);
|
|
501
|
+
return { revealed: true, via: label };
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return { revealed: false, via: null };
|
|
505
|
+
}
|
|
188
506
|
export class GenericAdapter {
|
|
189
507
|
name = 'generic';
|
|
190
508
|
matches(_detected) {
|
|
@@ -192,10 +510,17 @@ export class GenericAdapter {
|
|
|
192
510
|
return true;
|
|
193
511
|
}
|
|
194
512
|
async fill(page, fields, credential, contact) {
|
|
513
|
+
const reveal = await revealCollapsedCardSection(page, fields);
|
|
195
514
|
const filled = await fillFieldMap(page, fields, credential, contact);
|
|
515
|
+
const ok = filled.some((f) => f.role === 'number' && f.ok);
|
|
196
516
|
return {
|
|
197
|
-
ok
|
|
517
|
+
ok,
|
|
198
518
|
filled,
|
|
519
|
+
...(ok || !reveal.revealed
|
|
520
|
+
? {}
|
|
521
|
+
: {
|
|
522
|
+
detail: `revealed the card section via "${reveal.via}" but the number field still did not fill`,
|
|
523
|
+
}),
|
|
199
524
|
};
|
|
200
525
|
}
|
|
201
526
|
}
|
|
@@ -2,6 +2,9 @@ import type { DetectResult } from '../detect.js';
|
|
|
2
2
|
import type { CheckoutAdapter } from './generic.js';
|
|
3
3
|
export type { CheckoutAdapter } from './generic.js';
|
|
4
4
|
export { GenericAdapter } from './generic.js';
|
|
5
|
+
export { ShopifyAdapter } from './shopify.js';
|
|
5
6
|
export { StripeLikeAdapter } from './stripe-like.js';
|
|
6
7
|
export { fillFieldMap, resolveLocator } from './generic.js';
|
|
7
|
-
export declare function selectAdapter(detected: DetectResult
|
|
8
|
+
export declare function selectAdapter(detected: DetectResult, pageKind?: {
|
|
9
|
+
shopify?: boolean;
|
|
10
|
+
}): CheckoutAdapter;
|
|
@@ -2,14 +2,21 @@
|
|
|
2
2
|
// the universal fallback and always matches last. Selection is a pure
|
|
3
3
|
// function of an already-run detection — adapters never re-detect.
|
|
4
4
|
import { GenericAdapter } from './generic.js';
|
|
5
|
+
import { ShopifyAdapter } from './shopify.js';
|
|
5
6
|
import { StripeLikeAdapter } from './stripe-like.js';
|
|
6
7
|
export { GenericAdapter } from './generic.js';
|
|
8
|
+
export { ShopifyAdapter } from './shopify.js';
|
|
7
9
|
export { StripeLikeAdapter } from './stripe-like.js';
|
|
8
10
|
export { fillFieldMap, resolveLocator } from './generic.js';
|
|
9
|
-
const SPECIFIC = [new StripeLikeAdapter()];
|
|
10
11
|
const FALLBACK = new GenericAdapter();
|
|
11
|
-
export function selectAdapter(detected) {
|
|
12
|
-
|
|
12
|
+
export function selectAdapter(detected, pageKind = {}) {
|
|
13
|
+
// Shopify owns the outer checkout form and can still delegate card fields to
|
|
14
|
+
// an iframe, so it gets first crack and reuses fillFieldMap for both.
|
|
15
|
+
const shopify = new ShopifyAdapter(detected);
|
|
16
|
+
if (pageKind.shopify === true)
|
|
17
|
+
return shopify;
|
|
18
|
+
const specific = [shopify, new StripeLikeAdapter()];
|
|
19
|
+
for (const a of specific) {
|
|
13
20
|
if (a.matches(detected))
|
|
14
21
|
return a;
|
|
15
22
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Page } from 'playwright-core';
|
|
2
|
+
import { type PageAmountRead } from '../amount.js';
|
|
3
|
+
import { type DetectResult, type FieldMap } from '../detect.js';
|
|
4
|
+
import type { CardCredential } from '../instrument.js';
|
|
5
|
+
import type { Contact, FillResult, FilledField } from '../types.js';
|
|
6
|
+
import { type CheckoutAdapter } from './generic.js';
|
|
7
|
+
type ShopifySummary = {
|
|
8
|
+
subtotalMinor: number | null;
|
|
9
|
+
shippingMinor: number | null;
|
|
10
|
+
taxMinor: number | null;
|
|
11
|
+
discountMinor: number;
|
|
12
|
+
totalMinor: number | null;
|
|
13
|
+
currency: string | null;
|
|
14
|
+
verified: boolean;
|
|
15
|
+
};
|
|
16
|
+
export declare function parseShopifySummary(text: string): ShopifySummary;
|
|
17
|
+
export declare function readShopifyAmount(page: Page, requireVerified: boolean): Promise<PageAmountRead>;
|
|
18
|
+
export declare function readStableShopifyAmount(page: Page, timeoutMs?: number): Promise<PageAmountRead>;
|
|
19
|
+
export declare function isShopifyCheckoutPage(page: Page): Promise<boolean>;
|
|
20
|
+
export type ShopifyCheckoutSurface = {
|
|
21
|
+
signal: string;
|
|
22
|
+
subtype: 'recognition' | 'authentication' | 'wallet';
|
|
23
|
+
};
|
|
24
|
+
export type ShopifyGuestCheckoutResult = {
|
|
25
|
+
status: 'guest-ready';
|
|
26
|
+
signal: 'guest-card-form';
|
|
27
|
+
} | {
|
|
28
|
+
status: 'transitioned';
|
|
29
|
+
signal: string;
|
|
30
|
+
} | {
|
|
31
|
+
status: 'action-required';
|
|
32
|
+
signal: string;
|
|
33
|
+
detail: string;
|
|
34
|
+
};
|
|
35
|
+
export declare function detectShopifyCheckoutSurface(page: Page): Promise<ShopifyCheckoutSurface | null>;
|
|
36
|
+
/**
|
|
37
|
+
* Resolve Shopify's reversible Shop-wallet takeover before review. The helper
|
|
38
|
+
* only clicks an exact guest action or a scoped accessible close control inside
|
|
39
|
+
* a positively identified Shop surface. It never solves authentication, uses a
|
|
40
|
+
* Shop-saved payment method, force-clicks, or mutates the DOM.
|
|
41
|
+
*/
|
|
42
|
+
export declare function ensureShopifyGuestCheckout(page: Page, options?: {
|
|
43
|
+
timeoutMs?: number;
|
|
44
|
+
stableMs?: number;
|
|
45
|
+
}): Promise<ShopifyGuestCheckoutResult>;
|
|
46
|
+
/** Read-only assertion for the irreversible credential boundary. */
|
|
47
|
+
export declare function assertShopifyGuestCheckout(page: Page): Promise<ShopifyGuestCheckoutResult>;
|
|
48
|
+
/**
|
|
49
|
+
* The en-US variant of a localized Shopify checkout URL, or null when it is
|
|
50
|
+
* already English (or not locale-suffixed). Shopify renders the checkout in
|
|
51
|
+
* the URL's trailing locale segment, and amount reconciliation reads the
|
|
52
|
+
* order summary by its ENGLISH labels — a store whose primary market is not
|
|
53
|
+
* English serves /checkouts/cn/<token>/<locale> and the total never parses
|
|
54
|
+
* (observed live 2026-08-16: /es-us rendered "Precio total" and the review
|
|
55
|
+
* refused fail-closed on a good checkout). The locale segment is
|
|
56
|
+
* presentation-only: swapping it keeps the same checkout session and token.
|
|
57
|
+
*/
|
|
58
|
+
export declare function shopifyEnglishCheckoutUrl(current: string): string | null;
|
|
59
|
+
export declare function missingContactRoles(filled: FilledField[], expected: string[]): string[];
|
|
60
|
+
/**
|
|
61
|
+
* The contact surface to prefill. Shipping fields when the checkout has them —
|
|
62
|
+
* but a digital-goods (no-shipping) Shopify checkout renders exactly one
|
|
63
|
+
* address block and marks every field autocomplete="billing ..." (observed
|
|
64
|
+
* live 2026-08-16: all 18 candidates billing-classified, so the shipping map
|
|
65
|
+
* came back empty and the prefill reported every role missing). That billing
|
|
66
|
+
* block IS the primary contact surface, under its base roles.
|
|
67
|
+
*/
|
|
68
|
+
export declare function contactPrefillFieldMap(detected: DetectResult): {
|
|
69
|
+
fields: FieldMap;
|
|
70
|
+
surface: 'shipping' | 'billing-only';
|
|
71
|
+
};
|
|
72
|
+
export declare class ShopifyAdapter implements CheckoutAdapter {
|
|
73
|
+
private readonly detected;
|
|
74
|
+
name: string;
|
|
75
|
+
constructor(detected: DetectResult);
|
|
76
|
+
matches(detected: DetectResult): boolean;
|
|
77
|
+
prepareContact(page: Page, contact: Contact): Promise<FillResult>;
|
|
78
|
+
fill(page: Page, _fields: FieldMap, credential: CardCredential, _contact: Contact): Promise<FillResult>;
|
|
79
|
+
}
|
|
80
|
+
export {};
|