@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
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
// Shopify checkout adapter. Shopify commonly renders shipping first and keeps
|
|
2
|
+
// a distinct billing address collapsed behind a radio/checkbox. Detection runs
|
|
3
|
+
// once before adapter selection, so this adapter reuses the detected candidate
|
|
4
|
+
// locators, reveals the billing surface, and fills it without a second scan.
|
|
5
|
+
import { minorFromDecimal, pageCurrency } from '../amount.js';
|
|
6
|
+
import { detectFields, } from '../detect.js';
|
|
7
|
+
import { fillContactFieldMap, fillFieldMap } from './generic.js';
|
|
8
|
+
const SUMMARY_LABELS = /^(Subtotal|Shipping|Estimated taxes|Taxes|Tax|Discounts?|Total)(?:\s*:?\s+((?:[A-Z]{3}\s+)?-?[$€£]?\s*\d[\d.,]*|Free))?$/i;
|
|
9
|
+
function valueAfterLabel(lines, index, inline) {
|
|
10
|
+
if (inline)
|
|
11
|
+
return inline;
|
|
12
|
+
return lines.slice(index + 1, index + 4).find((line) => /\d|free/i.test(line)) ?? '';
|
|
13
|
+
}
|
|
14
|
+
export function parseShopifySummary(text) {
|
|
15
|
+
const lines = text
|
|
16
|
+
.split(/\n+/)
|
|
17
|
+
.map((line) => line.replace(/\s+/g, ' ').trim())
|
|
18
|
+
.filter(Boolean);
|
|
19
|
+
const values = new Map();
|
|
20
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
21
|
+
const match = lines[index].match(SUMMARY_LABELS);
|
|
22
|
+
if (!match)
|
|
23
|
+
continue;
|
|
24
|
+
const key = match[1].toLowerCase();
|
|
25
|
+
const value = valueAfterLabel(lines, index, match[2]);
|
|
26
|
+
values.set(key, [...(values.get(key) ?? []), value]);
|
|
27
|
+
}
|
|
28
|
+
const uniqueValue = (...keys) => {
|
|
29
|
+
const found = keys.flatMap((key) => values.get(key) ?? []).filter(Boolean);
|
|
30
|
+
const unique = [...new Set(found)];
|
|
31
|
+
return unique.length === 1 ? unique[0] : undefined;
|
|
32
|
+
};
|
|
33
|
+
const parse = (value) => {
|
|
34
|
+
if (!value)
|
|
35
|
+
return null;
|
|
36
|
+
if (/\bfree\b/i.test(value))
|
|
37
|
+
return 0;
|
|
38
|
+
return minorFromDecimal(value);
|
|
39
|
+
};
|
|
40
|
+
const subtotalMinor = parse(uniqueValue('subtotal'));
|
|
41
|
+
const shippingMinor = parse(uniqueValue('shipping'));
|
|
42
|
+
const taxMinor = parse(uniqueValue('estimated taxes', 'taxes', 'tax'));
|
|
43
|
+
const discountMinor = parse(uniqueValue('discount', 'discounts')) ?? 0;
|
|
44
|
+
const totalText = uniqueValue('total');
|
|
45
|
+
const totalMinor = parse(totalText);
|
|
46
|
+
// A digital-goods checkout renders no component rows at all — just a total
|
|
47
|
+
// ("Cost summary / Total / USD $6.00", observed live 2026-08-16), so there
|
|
48
|
+
// is nothing to reconcile against and a parsed total stands on its own. The
|
|
49
|
+
// moment ANY component label parsed by SUMMARY_LABELS appears — including a
|
|
50
|
+
// pending "Calculated at next step" — the full reconciliation is required again,
|
|
51
|
+
// so a mid-render pre-tax total still refuses.
|
|
52
|
+
// The discount-code entry form puts a bare "Discount" label on every
|
|
53
|
+
// checkout, value or not, and discount already defaults to 0 — only the
|
|
54
|
+
// additive components force reconciliation.
|
|
55
|
+
const componentLabelPresent = [...values.keys()].some((key) => key !== 'total' && key !== 'discount' && key !== 'discounts');
|
|
56
|
+
const verified = componentLabelPresent
|
|
57
|
+
? subtotalMinor != null &&
|
|
58
|
+
shippingMinor != null &&
|
|
59
|
+
taxMinor != null &&
|
|
60
|
+
totalMinor != null &&
|
|
61
|
+
subtotalMinor + shippingMinor + taxMinor - discountMinor === totalMinor
|
|
62
|
+
: totalMinor != null;
|
|
63
|
+
return {
|
|
64
|
+
subtotalMinor,
|
|
65
|
+
shippingMinor,
|
|
66
|
+
taxMinor,
|
|
67
|
+
discountMinor,
|
|
68
|
+
totalMinor,
|
|
69
|
+
currency: pageCurrency(totalText ?? ''),
|
|
70
|
+
verified,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export async function readShopifyAmount(page, requireVerified) {
|
|
74
|
+
const text = await page.evaluate(() => document.body?.innerText ?? '').catch(() => '');
|
|
75
|
+
const summary = parseShopifySummary(text);
|
|
76
|
+
if (summary.totalMinor == null) {
|
|
77
|
+
return /\btotal\b/i.test(text)
|
|
78
|
+
? { kind: 'unreadable', reason: 'Shopify total is present but unreadable' }
|
|
79
|
+
: { kind: 'none' };
|
|
80
|
+
}
|
|
81
|
+
if (requireVerified && !summary.verified) {
|
|
82
|
+
return {
|
|
83
|
+
kind: 'unreadable',
|
|
84
|
+
reason: 'Shopify tax and total do not form a complete, reconciled summary',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
kind: 'ok',
|
|
89
|
+
amountMinor: summary.totalMinor,
|
|
90
|
+
currency: summary.currency,
|
|
91
|
+
source: 'shopify-summary',
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
export async function readStableShopifyAmount(page, timeoutMs = 6_000) {
|
|
95
|
+
const startedAt = Date.now();
|
|
96
|
+
let previous = '';
|
|
97
|
+
let stableReads = 0;
|
|
98
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
99
|
+
const latest = await readShopifyAmount(page, true);
|
|
100
|
+
const fingerprint = JSON.stringify(latest);
|
|
101
|
+
stableReads = fingerprint === previous ? stableReads + 1 : 0;
|
|
102
|
+
if (latest.kind === 'ok' && stableReads >= 1)
|
|
103
|
+
return latest;
|
|
104
|
+
previous = fingerprint;
|
|
105
|
+
await page.waitForTimeout(200);
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
kind: 'unreadable',
|
|
109
|
+
reason: 'Shopify tax and total did not settle before the review deadline',
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
export async function isShopifyCheckoutPage(page) {
|
|
113
|
+
return page
|
|
114
|
+
.evaluate(() => {
|
|
115
|
+
const payButton = document.querySelector('#checkout-pay-button');
|
|
116
|
+
const shopifyAddress = document.querySelector('[name^="checkout[shipping_address]"], [name^="checkout[billing_address]"]');
|
|
117
|
+
const shippingAddress = document.querySelector('[autocomplete~="shipping"][autocomplete~="address-line1"]');
|
|
118
|
+
return (Boolean(payButton) ||
|
|
119
|
+
Boolean(shopifyAddress) ||
|
|
120
|
+
(Boolean(window.Shopify) &&
|
|
121
|
+
(/\/checkouts?\//i.test(window.location.pathname) || Boolean(shippingAddress))));
|
|
122
|
+
})
|
|
123
|
+
.catch(() => false);
|
|
124
|
+
}
|
|
125
|
+
const SHOP_SURFACE_SELECTOR = [
|
|
126
|
+
'dialog',
|
|
127
|
+
'[role="dialog"]',
|
|
128
|
+
'[aria-modal="true"]',
|
|
129
|
+
'[data-shop-pay-modal]',
|
|
130
|
+
'[data-shopify-pay-modal]',
|
|
131
|
+
].join(', ');
|
|
132
|
+
const SHOP_CODE_TEXT = /enter (?:the )?(?:verification|one[- ]?time)?\s*code|code (?:was |has been )?sent|verification code/i;
|
|
133
|
+
const SHOP_AUTH_TEXT = /sign in|log in|confirm (?:it(?:'|’)s )?you|continue with shop|shop account/i;
|
|
134
|
+
const SHOP_RECOGNITION_TEXT = /recognized|faster checkout|remembered|saved (?:payment|details)/i;
|
|
135
|
+
async function classifyBodyShopSurface(page) {
|
|
136
|
+
const roots = page.locator(SHOP_SURFACE_SELECTOR);
|
|
137
|
+
for (let index = 0; index < (await roots.count().catch(() => 0)); index += 1) {
|
|
138
|
+
const root = roots.nth(index);
|
|
139
|
+
if (!(await root.isVisible().catch(() => false)))
|
|
140
|
+
continue;
|
|
141
|
+
const [text, attrs, shopMark] = await Promise.all([
|
|
142
|
+
root.innerText().catch(() => ''),
|
|
143
|
+
Promise.all(['id', 'class', 'aria-label', 'data-testid'].map((name) => root.getAttribute(name).catch(() => ''))).then((values) => values.join(' ')),
|
|
144
|
+
root
|
|
145
|
+
.locator('img[alt="Shop" i], img[alt="Shop Pay" i], [aria-label="Shop" i]')
|
|
146
|
+
.count()
|
|
147
|
+
.then((count) => count > 0)
|
|
148
|
+
.catch(() => false),
|
|
149
|
+
]);
|
|
150
|
+
// A generic dialog, generic Close button, or marketing use of "shop" is
|
|
151
|
+
// not enough. Positive identification requires Shop Pay copy/attributes,
|
|
152
|
+
// or a Shop mark paired with authentication/guest-checkout language.
|
|
153
|
+
const positiveShopSignal = /\bshop\s*pay\b/i.test(`${text} ${attrs}`) ||
|
|
154
|
+
/shop[-_ ]?(?:pay|ify-pay)/i.test(attrs) ||
|
|
155
|
+
(shopMark && (SHOP_AUTH_TEXT.test(text) || /checkout as guest/i.test(text)));
|
|
156
|
+
if (!positiveShopSignal)
|
|
157
|
+
continue;
|
|
158
|
+
const subtype = SHOP_CODE_TEXT.test(text)
|
|
159
|
+
? 'authentication'
|
|
160
|
+
: /checkout as guest/i.test(text)
|
|
161
|
+
? 'wallet'
|
|
162
|
+
: SHOP_AUTH_TEXT.test(text)
|
|
163
|
+
? 'authentication'
|
|
164
|
+
: SHOP_RECOGNITION_TEXT.test(text)
|
|
165
|
+
? 'recognition'
|
|
166
|
+
: 'wallet';
|
|
167
|
+
return {
|
|
168
|
+
root,
|
|
169
|
+
subtype,
|
|
170
|
+
signal: `body:shop-${subtype}`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
async function classifyFrameShopSurface(page) {
|
|
176
|
+
const frames = page.locator('iframe');
|
|
177
|
+
for (let index = 0; index < (await frames.count().catch(() => 0)); index += 1) {
|
|
178
|
+
const frame = frames.nth(index);
|
|
179
|
+
if (!(await frame.isVisible().catch(() => false)))
|
|
180
|
+
continue;
|
|
181
|
+
const src = (await frame.getAttribute('src').catch(() => '')) ?? '';
|
|
182
|
+
if (!/^https:\/\/(?:[^/]+\.)?shop\.app\/(?:accounts\/login|pay|checkout)\b/i.test(src))
|
|
183
|
+
continue;
|
|
184
|
+
const accessibleName = [
|
|
185
|
+
await frame.getAttribute('title').catch(() => ''),
|
|
186
|
+
await frame.getAttribute('aria-label').catch(() => ''),
|
|
187
|
+
await frame.getAttribute('name').catch(() => ''),
|
|
188
|
+
].join(' ');
|
|
189
|
+
const subtype = SHOP_CODE_TEXT.test(accessibleName) ? 'authentication' : 'wallet';
|
|
190
|
+
return { frame, subtype, signal: `frame:shop-${subtype}` };
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
export async function detectShopifyCheckoutSurface(page) {
|
|
195
|
+
const located = (await classifyBodyShopSurface(page)) ?? (await classifyFrameShopSurface(page));
|
|
196
|
+
return located ? { signal: located.signal, subtype: located.subtype } : null;
|
|
197
|
+
}
|
|
198
|
+
async function locateShopifyCheckoutSurface(page) {
|
|
199
|
+
return (await classifyBodyShopSurface(page)) ?? (await classifyFrameShopSurface(page));
|
|
200
|
+
}
|
|
201
|
+
async function visibleGuestCardForm(page) {
|
|
202
|
+
const detected = await detectFields(page).catch(() => null);
|
|
203
|
+
return Boolean(detected?.fields.number?.visible);
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Resolve Shopify's reversible Shop-wallet takeover before review. The helper
|
|
207
|
+
* only clicks an exact guest action or a scoped accessible close control inside
|
|
208
|
+
* a positively identified Shop surface. It never solves authentication, uses a
|
|
209
|
+
* Shop-saved payment method, force-clicks, or mutates the DOM.
|
|
210
|
+
*/
|
|
211
|
+
export async function ensureShopifyGuestCheckout(page, options = {}) {
|
|
212
|
+
const timeoutMs = options.timeoutMs ?? 1_800;
|
|
213
|
+
const stableMs = options.stableMs ?? 650;
|
|
214
|
+
const startedAt = Date.now();
|
|
215
|
+
let guestStableSince = null;
|
|
216
|
+
let located = null;
|
|
217
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
218
|
+
located = await locateShopifyCheckoutSurface(page);
|
|
219
|
+
if (located)
|
|
220
|
+
break;
|
|
221
|
+
if (await visibleGuestCardForm(page)) {
|
|
222
|
+
guestStableSince ??= Date.now();
|
|
223
|
+
if (Date.now() - guestStableSince >= stableMs) {
|
|
224
|
+
return { status: 'guest-ready', signal: 'guest-card-form' };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
guestStableSince = null;
|
|
229
|
+
}
|
|
230
|
+
await page.waitForTimeout(75);
|
|
231
|
+
}
|
|
232
|
+
if (!located) {
|
|
233
|
+
return (await visibleGuestCardForm(page))
|
|
234
|
+
? { status: 'guest-ready', signal: 'guest-card-form' }
|
|
235
|
+
: {
|
|
236
|
+
status: 'action-required',
|
|
237
|
+
signal: 'shopify:guest-card-form-unavailable',
|
|
238
|
+
detail: 'Shopify guest card form did not become stable before review; no payment credential was requested',
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
const originBefore = new URL(page.url()).origin;
|
|
242
|
+
let action = null;
|
|
243
|
+
let actionSignal = '';
|
|
244
|
+
if (located.root) {
|
|
245
|
+
const exactGuest = located.root
|
|
246
|
+
.getByRole('button', { name: /^checkout as guest$/i })
|
|
247
|
+
.or(located.root.getByRole('link', { name: /^checkout as guest$/i }))
|
|
248
|
+
.first();
|
|
249
|
+
if ((await exactGuest.count().catch(() => 0)) > 0 &&
|
|
250
|
+
(await exactGuest.isVisible().catch(() => false))) {
|
|
251
|
+
action = exactGuest;
|
|
252
|
+
actionSignal = `${located.signal}:checkout-as-guest`;
|
|
253
|
+
}
|
|
254
|
+
else if (located.subtype !== 'authentication' ||
|
|
255
|
+
!(await located.root.innerText()).match(SHOP_CODE_TEXT)) {
|
|
256
|
+
const scopedClose = located.root
|
|
257
|
+
.getByRole('button', { name: /^close(?: shop(?: pay)?)?$/i })
|
|
258
|
+
.first();
|
|
259
|
+
if ((await scopedClose.count().catch(() => 0)) > 0 &&
|
|
260
|
+
(await scopedClose.isVisible().catch(() => false))) {
|
|
261
|
+
action = scopedClose;
|
|
262
|
+
actionSignal = `${located.signal}:scoped-close`;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
else if (located.frame) {
|
|
267
|
+
const frame = located.frame.contentFrame();
|
|
268
|
+
const exactGuest = frame.getByRole('button', { name: /^checkout as guest$/i }).first();
|
|
269
|
+
if ((await exactGuest.count().catch(() => 0)) > 0 &&
|
|
270
|
+
(await exactGuest.isVisible().catch(() => false))) {
|
|
271
|
+
action = exactGuest;
|
|
272
|
+
actionSignal = `${located.signal}:checkout-as-guest`;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (!action) {
|
|
276
|
+
return {
|
|
277
|
+
status: 'action-required',
|
|
278
|
+
signal: located.signal,
|
|
279
|
+
detail: `Shopify Shop surface could not be safely exited (${located.signal}); no payment credential was requested`,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
await action.click();
|
|
283
|
+
const surfaceGone = await page
|
|
284
|
+
.waitForFunction((selector) => !Array.from(document.querySelectorAll(selector)).some((element) => {
|
|
285
|
+
const style = getComputedStyle(element);
|
|
286
|
+
const text = element.innerText ?? '';
|
|
287
|
+
const attrs = [
|
|
288
|
+
element.id,
|
|
289
|
+
element.className,
|
|
290
|
+
element.getAttribute('aria-label'),
|
|
291
|
+
element.getAttribute('data-testid'),
|
|
292
|
+
].join(' ');
|
|
293
|
+
const visible = element.offsetParent !== null &&
|
|
294
|
+
style.display !== 'none' &&
|
|
295
|
+
style.visibility !== 'hidden';
|
|
296
|
+
return visible && /\bshop\s*pay\b|shop[-_ ]?(?:pay|ify-pay)/i.test(`${text} ${attrs}`);
|
|
297
|
+
}), SHOP_SURFACE_SELECTOR, { timeout: 2_000 })
|
|
298
|
+
.then(() => true)
|
|
299
|
+
.catch(() => false);
|
|
300
|
+
const sameOrigin = new URL(page.url()).origin === originBefore;
|
|
301
|
+
const surfaceRemains = Boolean(await locateShopifyCheckoutSurface(page));
|
|
302
|
+
if (!surfaceGone || surfaceRemains || !sameOrigin || !(await visibleGuestCardForm(page))) {
|
|
303
|
+
const signal = !sameOrigin
|
|
304
|
+
? 'shopify:guest-transition-origin-changed'
|
|
305
|
+
: !surfaceGone || surfaceRemains
|
|
306
|
+
? 'shopify:shop-surface-remained'
|
|
307
|
+
: 'shopify:guest-card-form-unavailable';
|
|
308
|
+
return {
|
|
309
|
+
status: 'action-required',
|
|
310
|
+
signal,
|
|
311
|
+
detail: `Shopify guest transition could not be verified (${signal}); no payment credential was requested`,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
return { status: 'transitioned', signal: actionSignal };
|
|
315
|
+
}
|
|
316
|
+
/** Read-only assertion for the irreversible credential boundary. */
|
|
317
|
+
export async function assertShopifyGuestCheckout(page) {
|
|
318
|
+
const surface = await locateShopifyCheckoutSurface(page);
|
|
319
|
+
if (surface) {
|
|
320
|
+
return {
|
|
321
|
+
status: 'action-required',
|
|
322
|
+
signal: surface.signal,
|
|
323
|
+
detail: `Shopify Shop surface reappeared after review (${surface.signal}); no payment credential was requested`,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
return (await visibleGuestCardForm(page))
|
|
327
|
+
? { status: 'guest-ready', signal: 'guest-card-form' }
|
|
328
|
+
: {
|
|
329
|
+
status: 'action-required',
|
|
330
|
+
signal: 'shopify:guest-card-form-unavailable',
|
|
331
|
+
detail: 'Shopify guest card form is no longer available after review; no payment credential was requested',
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* The en-US variant of a localized Shopify checkout URL, or null when it is
|
|
336
|
+
* already English (or not locale-suffixed). Shopify renders the checkout in
|
|
337
|
+
* the URL's trailing locale segment, and amount reconciliation reads the
|
|
338
|
+
* order summary by its ENGLISH labels — a store whose primary market is not
|
|
339
|
+
* English serves /checkouts/cn/<token>/<locale> and the total never parses
|
|
340
|
+
* (observed live 2026-08-16: /es-us rendered "Precio total" and the review
|
|
341
|
+
* refused fail-closed on a good checkout). The locale segment is
|
|
342
|
+
* presentation-only: swapping it keeps the same checkout session and token.
|
|
343
|
+
*/
|
|
344
|
+
export function shopifyEnglishCheckoutUrl(current) {
|
|
345
|
+
let url;
|
|
346
|
+
try {
|
|
347
|
+
url = new URL(current);
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
const match = url.pathname.match(/^(\/checkouts\/[^?#]+\/)([a-z]{2,3}(?:-[a-z0-9]+)*)(\/?)$/i);
|
|
353
|
+
if (!match || match[2].toLowerCase().startsWith('en'))
|
|
354
|
+
return null;
|
|
355
|
+
url.pathname = `${match[1]}en-us${match[3]}`;
|
|
356
|
+
return url.toString();
|
|
357
|
+
}
|
|
358
|
+
const BILLING_ROLES = new Set([
|
|
359
|
+
'name',
|
|
360
|
+
'nameFirst',
|
|
361
|
+
'nameLast',
|
|
362
|
+
'addressLine1',
|
|
363
|
+
'addressLine2',
|
|
364
|
+
'city',
|
|
365
|
+
'state',
|
|
366
|
+
'postalCode',
|
|
367
|
+
'country',
|
|
368
|
+
]);
|
|
369
|
+
const CONTACT_ROLES = new Set([
|
|
370
|
+
'email',
|
|
371
|
+
'phone',
|
|
372
|
+
'name',
|
|
373
|
+
'nameFirst',
|
|
374
|
+
'nameLast',
|
|
375
|
+
'addressLine1',
|
|
376
|
+
'addressLine2',
|
|
377
|
+
'city',
|
|
378
|
+
'state',
|
|
379
|
+
'postalCode',
|
|
380
|
+
'country',
|
|
381
|
+
]);
|
|
382
|
+
const PAYMENT_ROLES = new Set([
|
|
383
|
+
'number',
|
|
384
|
+
'cvc',
|
|
385
|
+
'name',
|
|
386
|
+
'expCombined',
|
|
387
|
+
'expMonth',
|
|
388
|
+
'expYear',
|
|
389
|
+
]);
|
|
390
|
+
const BILLING_ROLE_NAMES = {
|
|
391
|
+
name: 'billingName',
|
|
392
|
+
nameFirst: 'billingNameFirst',
|
|
393
|
+
nameLast: 'billingNameLast',
|
|
394
|
+
addressLine1: 'billingAddressLine1',
|
|
395
|
+
addressLine2: 'billingAddressLine2',
|
|
396
|
+
city: 'billingCity',
|
|
397
|
+
state: 'billingState',
|
|
398
|
+
postalCode: 'billingPostalCode',
|
|
399
|
+
country: 'billingCountry',
|
|
400
|
+
};
|
|
401
|
+
function candidateText(candidate) {
|
|
402
|
+
const meta = candidate.meta;
|
|
403
|
+
return [meta.name, meta.id, meta.autocomplete, meta.labelText, meta.placeholder, meta.ariaLabel]
|
|
404
|
+
.join(' ')
|
|
405
|
+
.toLowerCase();
|
|
406
|
+
}
|
|
407
|
+
function isShopifyCandidate(candidate) {
|
|
408
|
+
const text = candidateText(candidate);
|
|
409
|
+
return (/(?:^|[^a-z])shopify(?:[^a-z]|$)/i.test(text) ||
|
|
410
|
+
/checkout\[(?:shipping|billing)_address\]/i.test(text));
|
|
411
|
+
}
|
|
412
|
+
function isBillingCandidate(candidate) {
|
|
413
|
+
return /billing[_ -]?address|billingaddress|(?:^|[^a-z])billing(?:[^a-z]|$)/i.test(candidateText(candidate));
|
|
414
|
+
}
|
|
415
|
+
function isPaymentCandidate(candidate) {
|
|
416
|
+
return /cc-|cardholder|card holder|payment/i.test(candidateText(candidate));
|
|
417
|
+
}
|
|
418
|
+
function entryFromCandidate(candidate) {
|
|
419
|
+
if (!candidate.source)
|
|
420
|
+
return null;
|
|
421
|
+
return {
|
|
422
|
+
locator: `[data-ca-id="${candidate.meta.idx}"]`,
|
|
423
|
+
confidence: candidate.confidence,
|
|
424
|
+
source: candidate.source,
|
|
425
|
+
frame: candidate.frame,
|
|
426
|
+
tag: candidate.meta.tag,
|
|
427
|
+
inputType: candidate.meta.type,
|
|
428
|
+
// The billing control is revealed before these entries are filled.
|
|
429
|
+
visible: true,
|
|
430
|
+
options: candidate.meta.options.length ? candidate.meta.options : undefined,
|
|
431
|
+
maxlength: candidate.meta.maxlength ? Number.parseInt(candidate.meta.maxlength, 10) : undefined,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
function billingFieldMap(detected) {
|
|
435
|
+
const fields = {};
|
|
436
|
+
for (const candidate of detected.candidates) {
|
|
437
|
+
const role = candidate.role;
|
|
438
|
+
if (!role || !BILLING_ROLES.has(role) || !isBillingCandidate(candidate))
|
|
439
|
+
continue;
|
|
440
|
+
const entry = entryFromCandidate(candidate);
|
|
441
|
+
if (!entry)
|
|
442
|
+
continue;
|
|
443
|
+
const existing = fields[role];
|
|
444
|
+
if (!existing || entry.confidence > existing.confidence)
|
|
445
|
+
fields[role] = entry;
|
|
446
|
+
}
|
|
447
|
+
return fields;
|
|
448
|
+
}
|
|
449
|
+
function shippingFieldMap(detected) {
|
|
450
|
+
const fields = {};
|
|
451
|
+
for (const candidate of detected.candidates) {
|
|
452
|
+
const role = candidate.role;
|
|
453
|
+
if (!role ||
|
|
454
|
+
!CONTACT_ROLES.has(role) ||
|
|
455
|
+
!candidate.meta.visible ||
|
|
456
|
+
isBillingCandidate(candidate) ||
|
|
457
|
+
isPaymentCandidate(candidate))
|
|
458
|
+
continue;
|
|
459
|
+
const entry = entryFromCandidate(candidate);
|
|
460
|
+
if (!entry)
|
|
461
|
+
continue;
|
|
462
|
+
const existing = fields[role];
|
|
463
|
+
if (!existing || entry.confidence > existing.confidence)
|
|
464
|
+
fields[role] = entry;
|
|
465
|
+
}
|
|
466
|
+
return fields;
|
|
467
|
+
}
|
|
468
|
+
function paymentFieldMap(detected) {
|
|
469
|
+
const fields = {};
|
|
470
|
+
for (const candidate of detected.candidates) {
|
|
471
|
+
const role = candidate.role;
|
|
472
|
+
if (!role || !PAYMENT_ROLES.has(role) || isBillingCandidate(candidate))
|
|
473
|
+
continue;
|
|
474
|
+
if (role === 'name' && !isPaymentCandidate(candidate))
|
|
475
|
+
continue;
|
|
476
|
+
const entry = entryFromCandidate(candidate);
|
|
477
|
+
if (!entry)
|
|
478
|
+
continue;
|
|
479
|
+
const existing = fields[role];
|
|
480
|
+
if (!existing || entry.confidence > existing.confidence)
|
|
481
|
+
fields[role] = entry;
|
|
482
|
+
}
|
|
483
|
+
return fields;
|
|
484
|
+
}
|
|
485
|
+
async function clickFirstVisible(locators) {
|
|
486
|
+
for (const locator of locators) {
|
|
487
|
+
const first = locator.first();
|
|
488
|
+
if ((await first.count().catch(() => 0)) < 1)
|
|
489
|
+
continue;
|
|
490
|
+
if (!(await first.isVisible().catch(() => false)))
|
|
491
|
+
continue;
|
|
492
|
+
await first.click();
|
|
493
|
+
return true;
|
|
494
|
+
}
|
|
495
|
+
return false;
|
|
496
|
+
}
|
|
497
|
+
async function hasVisibleBillingField(page) {
|
|
498
|
+
const fields = page.locator('input:not([type="radio"]):not([type="checkbox"])[name*="billing_address" i], select[name*="billing_address" i], textarea[name*="billing_address" i]');
|
|
499
|
+
for (let index = 0; index < (await fields.count()); index += 1) {
|
|
500
|
+
if (await fields
|
|
501
|
+
.nth(index)
|
|
502
|
+
.isVisible()
|
|
503
|
+
.catch(() => false))
|
|
504
|
+
return true;
|
|
505
|
+
}
|
|
506
|
+
return false;
|
|
507
|
+
}
|
|
508
|
+
async function waitForVisibleBillingField(page) {
|
|
509
|
+
if (await hasVisibleBillingField(page))
|
|
510
|
+
return true;
|
|
511
|
+
return page
|
|
512
|
+
.waitForFunction(() => Array.from(document.querySelectorAll('input:not([type="radio"]):not([type="checkbox"])[name*="billing_address" i], select[name*="billing_address" i], textarea[name*="billing_address" i]')).some((element) => {
|
|
513
|
+
const style = window.getComputedStyle(element);
|
|
514
|
+
return (style.display !== 'none' &&
|
|
515
|
+
style.visibility !== 'hidden' &&
|
|
516
|
+
element.getClientRects().length > 0);
|
|
517
|
+
}), undefined, { timeout: 2_000 })
|
|
518
|
+
.then(() => true)
|
|
519
|
+
.catch(() => false);
|
|
520
|
+
}
|
|
521
|
+
async function revealSeparateBilling(page) {
|
|
522
|
+
if (await hasVisibleBillingField(page))
|
|
523
|
+
return true;
|
|
524
|
+
const roleRadio = page
|
|
525
|
+
.getByRole('radio', { name: /different billing|use a different billing/i })
|
|
526
|
+
.first();
|
|
527
|
+
if ((await roleRadio.count().catch(() => 0)) > 0 &&
|
|
528
|
+
(await roleRadio.isVisible().catch(() => false))) {
|
|
529
|
+
await roleRadio.check();
|
|
530
|
+
return waitForVisibleBillingField(page);
|
|
531
|
+
}
|
|
532
|
+
if (await clickFirstVisible([
|
|
533
|
+
page.locator('input[name="billing_address_selector"][value="billing_address"]'),
|
|
534
|
+
page.locator('input[type="radio"][name*="billing" i][value*="different" i], input[type="radio"][name*="billing" i][value*="billing" i]'),
|
|
535
|
+
])) {
|
|
536
|
+
return waitForVisibleBillingField(page);
|
|
537
|
+
}
|
|
538
|
+
const sameAsShipping = page
|
|
539
|
+
.getByRole('checkbox', { name: /same as shipping|use shipping address/i })
|
|
540
|
+
.first();
|
|
541
|
+
if ((await sameAsShipping.count().catch(() => 0)) > 0 &&
|
|
542
|
+
(await sameAsShipping.isVisible().catch(() => false))) {
|
|
543
|
+
if (await sameAsShipping.isChecked().catch(() => false))
|
|
544
|
+
await sameAsShipping.uncheck();
|
|
545
|
+
return waitForVisibleBillingField(page);
|
|
546
|
+
}
|
|
547
|
+
return false;
|
|
548
|
+
}
|
|
549
|
+
function billingContact(address) {
|
|
550
|
+
return {
|
|
551
|
+
firstName: address.firstName,
|
|
552
|
+
lastName: address.lastName,
|
|
553
|
+
fullName: address.fullName,
|
|
554
|
+
addressLine1: address.addressLine1,
|
|
555
|
+
addressLine2: address.addressLine2,
|
|
556
|
+
city: address.city,
|
|
557
|
+
state: address.state,
|
|
558
|
+
postalCode: address.postalCode,
|
|
559
|
+
country: address.country,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
function labelBillingEvidence(filled) {
|
|
563
|
+
return filled.map((field) => ({
|
|
564
|
+
...field,
|
|
565
|
+
role: BILLING_ROLE_NAMES[field.role] ?? `billing:${field.role}`,
|
|
566
|
+
}));
|
|
567
|
+
}
|
|
568
|
+
function requiredAddressRoles(address, prefix = '') {
|
|
569
|
+
const role = (name) => `${prefix}${prefix ? name[0].toUpperCase() + name.slice(1) : name}`;
|
|
570
|
+
const regionRequired = ['US', 'CA', 'AU'].includes(address.country?.toUpperCase() ?? '');
|
|
571
|
+
return [
|
|
572
|
+
...(address.firstName ? [role('nameFirst')] : []),
|
|
573
|
+
...(address.lastName ? [role('nameLast')] : []),
|
|
574
|
+
...(!address.firstName && !address.lastName && address.fullName ? [role('name')] : []),
|
|
575
|
+
...(address.addressLine1 ? [role('addressLine1')] : []),
|
|
576
|
+
...(address.addressLine2 ? [role('addressLine2')] : []),
|
|
577
|
+
...(address.city ? [role('city')] : []),
|
|
578
|
+
...(address.state && regionRequired ? [role('state')] : []),
|
|
579
|
+
...(address.postalCode ? [role('postalCode')] : []),
|
|
580
|
+
...(address.country ? [role('country')] : []),
|
|
581
|
+
];
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* A checkout renders ONE name shape (a single full-name input, or first/last)
|
|
585
|
+
* while the contact record may carry the other, so the expected role and the
|
|
586
|
+
* filled role can disagree while the page is completely filled. Either shape
|
|
587
|
+
* satisfies the name requirement; same for the billing-prefixed variants.
|
|
588
|
+
*/
|
|
589
|
+
function withNameEquivalence(successful) {
|
|
590
|
+
const out = new Set(successful);
|
|
591
|
+
for (const [full, first, last] of [
|
|
592
|
+
['name', 'nameFirst', 'nameLast'],
|
|
593
|
+
['billingName', 'billingNameFirst', 'billingNameLast'],
|
|
594
|
+
]) {
|
|
595
|
+
if (out.has(first) && out.has(last))
|
|
596
|
+
out.add(full);
|
|
597
|
+
if (out.has(full)) {
|
|
598
|
+
out.add(first);
|
|
599
|
+
out.add(last);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return out;
|
|
603
|
+
}
|
|
604
|
+
export function missingContactRoles(filled, expected) {
|
|
605
|
+
const successful = withNameEquivalence(new Set(filled.filter((field) => field.ok).map((field) => field.role)));
|
|
606
|
+
return expected.filter((role) => !successful.has(role));
|
|
607
|
+
}
|
|
608
|
+
function hasAddressRole(fields) {
|
|
609
|
+
return Boolean(fields.addressLine1 ?? fields.city ?? fields.postalCode);
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* The contact surface to prefill. Shipping fields when the checkout has them —
|
|
613
|
+
* but a digital-goods (no-shipping) Shopify checkout renders exactly one
|
|
614
|
+
* address block and marks every field autocomplete="billing ..." (observed
|
|
615
|
+
* live 2026-08-16: all 18 candidates billing-classified, so the shipping map
|
|
616
|
+
* came back empty and the prefill reported every role missing). That billing
|
|
617
|
+
* block IS the primary contact surface, under its base roles.
|
|
618
|
+
*/
|
|
619
|
+
export function contactPrefillFieldMap(detected) {
|
|
620
|
+
const shipping = shippingFieldMap(detected);
|
|
621
|
+
if (hasAddressRole(shipping))
|
|
622
|
+
return { fields: shipping, surface: 'shipping' };
|
|
623
|
+
const fields = {};
|
|
624
|
+
for (const candidate of detected.candidates) {
|
|
625
|
+
const role = candidate.role;
|
|
626
|
+
if (!role || !CONTACT_ROLES.has(role) || !candidate.meta.visible)
|
|
627
|
+
continue;
|
|
628
|
+
if (isPaymentCandidate(candidate))
|
|
629
|
+
continue;
|
|
630
|
+
const entry = entryFromCandidate(candidate);
|
|
631
|
+
if (!entry)
|
|
632
|
+
continue;
|
|
633
|
+
const existing = fields[role];
|
|
634
|
+
if (!existing || entry.confidence > existing.confidence)
|
|
635
|
+
fields[role] = entry;
|
|
636
|
+
}
|
|
637
|
+
return { fields, surface: 'billing-only' };
|
|
638
|
+
}
|
|
639
|
+
export class ShopifyAdapter {
|
|
640
|
+
detected;
|
|
641
|
+
name = 'shopify';
|
|
642
|
+
constructor(detected) {
|
|
643
|
+
this.detected = detected;
|
|
644
|
+
}
|
|
645
|
+
matches(detected) {
|
|
646
|
+
return detected.candidates.some(isShopifyCandidate);
|
|
647
|
+
}
|
|
648
|
+
async prepareContact(page, contact) {
|
|
649
|
+
const { fields: contactFields, surface } = contactPrefillFieldMap(this.detected);
|
|
650
|
+
const shippingFilled = await fillContactFieldMap(page, contactFields, contact);
|
|
651
|
+
const shippingExpected = [...requiredAddressRoles(contact), ...(contact.phone ? ['phone'] : [])];
|
|
652
|
+
const shippingMissing = missingContactRoles(shippingFilled, shippingExpected);
|
|
653
|
+
if (surface === 'billing-only' || !contact.billingAddress) {
|
|
654
|
+
return {
|
|
655
|
+
ok: shippingMissing.length === 0,
|
|
656
|
+
filled: shippingFilled,
|
|
657
|
+
...(shippingMissing.length
|
|
658
|
+
? {
|
|
659
|
+
detail: `Shopify ${surface === 'billing-only' ? 'contact' : 'shipping'} prefill incomplete: missing ${shippingMissing.join(', ')}`,
|
|
660
|
+
}
|
|
661
|
+
: {}),
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
if (!(await revealSeparateBilling(page))) {
|
|
665
|
+
return {
|
|
666
|
+
ok: false,
|
|
667
|
+
filled: shippingFilled,
|
|
668
|
+
detail: 'Shopify billing address fields did not become visible during prefill',
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
const billingFields = billingFieldMap(await detectFields(page));
|
|
672
|
+
const billingFilled = labelBillingEvidence(await fillContactFieldMap(page, billingFields, billingContact(contact.billingAddress)));
|
|
673
|
+
const billingExpected = requiredAddressRoles(contact.billingAddress, 'billing');
|
|
674
|
+
const missing = [...shippingMissing, ...missingContactRoles(billingFilled, billingExpected)];
|
|
675
|
+
return {
|
|
676
|
+
ok: missing.length === 0,
|
|
677
|
+
filled: [...shippingFilled, ...billingFilled],
|
|
678
|
+
...(missing.length
|
|
679
|
+
? { detail: `Shopify contact prefill incomplete: missing ${missing.join(', ')}` }
|
|
680
|
+
: {}),
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
async fill(page, _fields, credential, _contact) {
|
|
684
|
+
const filled = await fillFieldMap(page, paymentFieldMap(this.detected), credential, {});
|
|
685
|
+
const numberOk = filled.some((field) => field.role === 'number' && field.ok);
|
|
686
|
+
return { ok: numberOk, filled };
|
|
687
|
+
}
|
|
688
|
+
}
|