@agentrhq/webcmd 0.4.1 → 0.4.2

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.
@@ -0,0 +1,479 @@
1
+ import {
2
+ ArgumentError,
3
+ AuthRequiredError,
4
+ CommandExecutionError,
5
+ } from '@agentrhq/webcmd/errors';
6
+ import { cli, Strategy } from '@agentrhq/webcmd/registry';
7
+ import {
8
+ buildProductUrl,
9
+ normalizeCheckoutReview,
10
+ totalsAreConsistent,
11
+ validateCheckoutArgs,
12
+ } from './parsers.js';
13
+ import { assertUsablePage, gotoAmazon, SITE } from './shared.js';
14
+
15
+ const VERIFY_COMMAND = 'webcmd amazon-in checkout-status';
16
+
17
+ function handoffRow({ status = 'action_required', asin, title, size, colour, quantity, payment, action }) {
18
+ return {
19
+ status,
20
+ asin,
21
+ title,
22
+ size,
23
+ colour,
24
+ quantity,
25
+ item_price: null,
26
+ total: null,
27
+ payment_method: payment,
28
+ delivery_date: '',
29
+ action,
30
+ verify_command: VERIFY_COMMAND,
31
+ };
32
+ }
33
+
34
+ async function selectVariant(page, dimension, requested) {
35
+ if (!requested) return '';
36
+ const result = await page.evaluateWithArgs(`
37
+ (() => {
38
+ const current = (document.querySelector(
39
+ '#inline-twister-expanded-dimension-text-' + dimension + '_name, ' +
40
+ '#variation_' + dimension + '_name .selection'
41
+ )?.textContent || '').trim();
42
+ if (current.toLowerCase() === requested.toLowerCase()) return { current, changed: false };
43
+ const options = [...document.querySelectorAll(
44
+ '#inline-twister-expander-content-' + dimension + '_name span[id^="' + dimension + '_name_"]:not([id$="-announce"])'
45
+ )].filter((node) => !node.classList.contains('aok-hidden'));
46
+ const matches = options.filter((node) => {
47
+ const label = dimension === 'color'
48
+ ? (node.querySelector('img')?.alt || '')
49
+ : (node.textContent || '').trim();
50
+ return label.toLowerCase() === requested.toLowerCase();
51
+ });
52
+ if (matches.length !== 1) return { current, changed: false, matches: matches.length };
53
+ (matches[0].querySelector('input') || matches[0]).click();
54
+ return { current, changed: true, matches: 1 };
55
+ })()
56
+ `, { dimension, requested });
57
+ if (!result?.changed && result?.current?.toLowerCase() !== requested.toLowerCase()) {
58
+ throw new ArgumentError(
59
+ `${dimension === 'color' ? 'colour' : dimension} "${requested}" is not uniquely available`,
60
+ );
61
+ }
62
+ if (result.changed) await page.sleep(2);
63
+ const selected = await page.evaluateWithArgs(`
64
+ (() => (document.querySelector(
65
+ '#inline-twister-expanded-dimension-text-' + dimension + '_name, ' +
66
+ '#variation_' + dimension + '_name .selection'
67
+ )?.textContent || '').trim())()
68
+ `, { dimension });
69
+ if (selected.toLowerCase() !== requested.toLowerCase()) {
70
+ throw new CommandExecutionError(`Amazon did not select ${dimension} "${requested}"`);
71
+ }
72
+ return selected;
73
+ }
74
+
75
+ async function readProductSelection(page) {
76
+ return page.evaluate(`
77
+ (() => ({
78
+ asin: (location.pathname.match(/\\/dp\\/([A-Z0-9]{10})/i)?.[1] || document.querySelector('#ASIN')?.value || '').toUpperCase(),
79
+ title: (document.querySelector('#productTitle')?.textContent || '').replace(/\\s+/g, ' ').trim(),
80
+ size: (document.querySelector('#inline-twister-expanded-dimension-text-size_name, #variation_size_name .selection')?.textContent || '').trim(),
81
+ colour: (document.querySelector('#inline-twister-expanded-dimension-text-color_name, #variation_color_name .selection')?.textContent || '').trim(),
82
+ }))()
83
+ `);
84
+ }
85
+
86
+ async function selectPayment(page, options) {
87
+ return page.evaluateWithArgs(`
88
+ (() => {
89
+ const radios = [...document.querySelectorAll('input[type="radio"]')];
90
+ radios.forEach((radio) => radio.removeAttribute('data-webcmd-payment-target'));
91
+ let matches = [];
92
+ if (payment === 'upi') {
93
+ matches = radios.filter((radio) => /paymentMethod=UnifiedPaymentsInterface/i.test(radio.value));
94
+ } else if (payment === 'cod') {
95
+ matches = radios.filter((radio) => /paymentMethod=COD/i.test(radio.value));
96
+ } else if (payment === 'new-card') {
97
+ matches = radios.filter((radio) => radio.value === 'SelectableAddCreditCard');
98
+ } else {
99
+ matches = radios.filter((radio) => {
100
+ const label = radio.closest('label')?.innerText || radio.parentElement?.innerText || '';
101
+ return new RegExp('ending in\\\\s+' + cardLast4 + '\\\\b', 'i').test(label);
102
+ });
103
+ }
104
+ if (matches.length !== 1) return { matches: matches.length };
105
+ matches[0].setAttribute('data-webcmd-payment-target', 'true');
106
+ return { matches: 1 };
107
+ })()
108
+ `, options);
109
+ }
110
+
111
+ async function waitForPaymentUi(page) {
112
+ for (let attempt = 0; attempt < 40; attempt += 1) {
113
+ const ready = await page.evaluate(`
114
+ (() => {
115
+ const visible = (node) => {
116
+ const rect = node.getBoundingClientRect();
117
+ const style = getComputedStyle(node);
118
+ return rect.width > 0 && rect.height > 0 &&
119
+ style.display !== 'none' && style.visibility !== 'hidden';
120
+ };
121
+ const loaders = [...document.querySelectorAll('[aria-label^="acp-loading"]')];
122
+ return document.querySelectorAll('#checkout-paymentOptionPanel input[type="radio"]').length > 0 &&
123
+ Boolean(document.querySelector('[data-testid="bottom-continue-button"]')) &&
124
+ !loaders.some(visible);
125
+ })()
126
+ `);
127
+ if (ready) return;
128
+ await page.sleep(0.5);
129
+ }
130
+ throw new CommandExecutionError('Amazon payment interface did not finish initializing');
131
+ }
132
+
133
+ async function continueAfterPaymentSelection(page, options, selected, quantity) {
134
+ if (options.payment === 'new-card') {
135
+ return handoffRow({
136
+ ...selected,
137
+ quantity,
138
+ payment: options.payment,
139
+ action: 'Enter the new card details in the opened browser, then run checkout-status.',
140
+ });
141
+ }
142
+ if (options.payment === 'saved-card') {
143
+ for (let attempt = 0; attempt < 20; attempt += 1) {
144
+ const readiness = await page.evaluate(`
145
+ (() => {
146
+ const button = document.querySelector('[data-testid="bottom-continue-button"]');
147
+ return {
148
+ needsSecret: Boolean(document.querySelector(
149
+ 'input[name*="verification"], input[autocomplete="cc-csc"], input[placeholder*="CVV" i]'
150
+ )),
151
+ continueEnabled: Boolean(button && !button.disabled),
152
+ };
153
+ })()
154
+ `);
155
+ if (readiness?.needsSecret) {
156
+ return handoffRow({
157
+ ...selected,
158
+ quantity,
159
+ payment: options.payment,
160
+ action: 'Enter the saved card CVV in the opened browser, then run checkout-status.',
161
+ });
162
+ }
163
+ if (readiness?.continueEnabled) {
164
+ await page.click('[data-testid="bottom-continue-button"]');
165
+ return null;
166
+ }
167
+ await page.sleep(0.25);
168
+ }
169
+ throw new CommandExecutionError('Amazon did not expose the saved-card CVV or enable Continue');
170
+ }
171
+ try {
172
+ await page.wait({
173
+ selector: '[data-testid="bottom-continue-button"]:not([disabled])',
174
+ timeout: 10,
175
+ });
176
+ } catch {
177
+ throw new CommandExecutionError('Amazon did not enable the selected payment method');
178
+ }
179
+ await page.click('[data-testid="bottom-continue-button"]');
180
+ return null;
181
+ }
182
+
183
+ async function waitForUrlChange(page, previousUrl) {
184
+ for (let attempt = 0; attempt < 40; attempt += 1) {
185
+ try {
186
+ if (await page.evaluate('location.href') !== previousUrl) return;
187
+ } catch {
188
+ return;
189
+ }
190
+ await page.sleep(0.25);
191
+ }
192
+ }
193
+
194
+ async function advanceToReview(page) {
195
+ for (let step = 0; step < 4; step += 1) {
196
+ try {
197
+ await page.wait({
198
+ selector: '#placeOrder, #noThanksCtaButtonId a, a[href*="/spc"]',
199
+ timeout: 20,
200
+ });
201
+ } catch {
202
+ throw new CommandExecutionError('Amazon checkout did not expose a review transition');
203
+ }
204
+ const state = await page.evaluate(`
205
+ (() => {
206
+ if (document.querySelector('#placeOrder')) return { kind: 'review' };
207
+ const reviewLinks = [...document.querySelectorAll('a[href*="/spc"]')]
208
+ .filter((link) => (link.textContent || '').trim() === 'Review Order');
209
+ if (reviewLinks.length === 1) return { kind: 'review-link', href: reviewLinks[0].href };
210
+ const decline = document.querySelector('#noThanksCtaButtonId a');
211
+ if (decline && (decline.textContent || '').trim() === 'No Thanks') {
212
+ return { kind: 'decline', url: location.href };
213
+ }
214
+ return { kind: 'unknown' };
215
+ })()
216
+ `);
217
+ if (state.kind === 'review') return;
218
+ if (state.kind === 'review-link') {
219
+ await page.goto(state.href, { waitUntil: 'load' });
220
+ continue;
221
+ }
222
+ if (state.kind === 'decline') {
223
+ await page.click('#noThanksCtaButtonId a');
224
+ await waitForUrlChange(page, state.url);
225
+ continue;
226
+ }
227
+ throw new CommandExecutionError('Amazon checkout exposed an unsupported Prime offer state');
228
+ }
229
+ throw new CommandExecutionError('Amazon checkout did not reach final review');
230
+ }
231
+
232
+ async function waitForReviewUi(page) {
233
+ for (let attempt = 0; attempt < 40; attempt += 1) {
234
+ const ready = await page.evaluate(`
235
+ (() => {
236
+ const total = [...document.querySelectorAll('#subtotals-marketplace-table li')]
237
+ .find((node) => /^Order Total:/i.test((node.textContent || '').trim()));
238
+ return Boolean(
239
+ document.querySelector('[data-csa-c-item-type="asin"][data-csa-c-item-id*="amzn1.asin."]') &&
240
+ document.querySelector('[data-a-component="stepper"]') &&
241
+ /₹|Rs\\.?/i.test(total?.textContent || '')
242
+ );
243
+ })()
244
+ `);
245
+ if (ready) return;
246
+ await page.sleep(0.5);
247
+ }
248
+ throw new CommandExecutionError('Amazon checkout review details did not finish loading');
249
+ }
250
+
251
+ async function readReview(page, selected, options) {
252
+ const snapshot = await page.evaluate(`
253
+ (() => {
254
+ const text = (node) => (node?.textContent || '').replace(/\\s+/g, ' ').trim();
255
+ const rows = [...document.querySelectorAll('#subtotals-marketplace-table li')];
256
+ const row = (pattern) => text(rows.find((node) => pattern.test(text(node))));
257
+ const amount = (type) => text(
258
+ [...document.querySelectorAll('input[name="subtotalLineType"]')]
259
+ .find((input) => input.value === type)?.parentElement
260
+ );
261
+ const asinMarkers = [...document.querySelectorAll(
262
+ '[data-csa-c-item-type="asin"][data-csa-c-item-id*="amzn1.asin."]'
263
+ )];
264
+ const asinMarker = asinMarkers[0];
265
+ const asin = asinMarker?.getAttribute('data-csa-c-item-id')?.match(/amzn1\\.asin\\.([A-Z0-9]{10})/)?.[1] || '';
266
+ const item = asinMarker?.closest('.product-description-column') || document.querySelector('.product-description-column');
267
+ const snapshot = {
268
+ itemCount: asinMarkers.length,
269
+ asin,
270
+ title: text(item?.querySelector('.lineitem-title-text')),
271
+ itemPriceText: text(item?.querySelector('.a-price .a-offscreen')),
272
+ deliveryFeeText: amount('SHIPPING_TAX_INCLUSIVE'),
273
+ deliveryDiscountText: row(/^Free Delivery/i),
274
+ marketplaceFeeText: amount('MARKETPLACE_FEE_TAX_INCLUSIVE'),
275
+ totalText: row(/^Order Total:/i),
276
+ quantity: Number(document.querySelector('[data-a-component="stepper"]')?.getAttribute('data-steppervalue') || 0),
277
+ deliveryDate: text(document.querySelector('.address-promise-text')),
278
+ bodyText: document.body?.innerText || '',
279
+ };
280
+ return snapshot;
281
+ })()
282
+ `);
283
+ assertSingleLineItem(snapshot, selected, options);
284
+ const totals = readCheckoutTotals(snapshot);
285
+ return {
286
+ status: 'review_ready',
287
+ asin: selected.asin,
288
+ title: snapshot.title || selected.title,
289
+ size: selected.size,
290
+ colour: selected.colour,
291
+ quantity: options.quantity,
292
+ item_price: totals.itemPrice,
293
+ total: totals.total,
294
+ payment_method: options.payment,
295
+ delivery_date: snapshot.deliveryDate,
296
+ action: '',
297
+ verify_command: VERIFY_COMMAND,
298
+ };
299
+ }
300
+
301
+ function assertSingleLineItem(snapshot, selected, options) {
302
+ if (snapshot.itemCount !== 1) {
303
+ throw new CommandExecutionError(
304
+ `Expected exactly one checkout line item; found ${snapshot.itemCount}`,
305
+ );
306
+ }
307
+ if (snapshot.asin !== selected.asin || snapshot.quantity !== options.quantity) {
308
+ throw new CommandExecutionError(
309
+ `Amazon checkout item or quantity mismatch: expected ${selected.asin} × ${options.quantity}, got ${snapshot.asin || '(missing ASIN)'} × ${snapshot.quantity}`,
310
+ );
311
+ }
312
+ if (options.size && !snapshot.title.toLowerCase().includes(options.size.toLowerCase())) {
313
+ throw new CommandExecutionError(`Amazon checkout review does not show size "${options.size}"`);
314
+ }
315
+ if (options.colour && !snapshot.title.toLowerCase().includes(options.colour.toLowerCase())) {
316
+ throw new CommandExecutionError(`Amazon checkout review does not show colour "${options.colour}"`);
317
+ }
318
+ const paymentPatterns = {
319
+ upi: /Pay by scanning the QR code|Pay with UPI/i,
320
+ cod: /Cash on Delivery|Pay on Delivery/i,
321
+ 'saved-card': new RegExp(`ending in\\s+${options.cardLast4}\\b`, 'i'),
322
+ 'new-card': /credit or debit card/i,
323
+ };
324
+ if (!paymentPatterns[options.payment].test(snapshot.bodyText)) {
325
+ throw new CommandExecutionError('Amazon checkout payment method does not match the requested method');
326
+ }
327
+ }
328
+
329
+ function readCheckoutTotals(snapshot) {
330
+ let totals;
331
+ try {
332
+ totals = normalizeCheckoutReview(snapshot);
333
+ } catch (error) {
334
+ throw new CommandExecutionError(`Amazon checkout totals could not be read: ${error.message}`);
335
+ }
336
+ if (!totalsAreConsistent(totals)) {
337
+ throw new CommandExecutionError('Amazon checkout total is inconsistent with item price and fees');
338
+ }
339
+ return totals;
340
+ }
341
+
342
+ async function submitOrder(page, enabled) {
343
+ if (!enabled) return false;
344
+ const placement = await page.evaluate(`
345
+ (() => {
346
+ window.scrollTo(0, 0);
347
+ const candidates = [...document.querySelectorAll('#placeOrder')]
348
+ .filter((button) => {
349
+ const rect = button.getBoundingClientRect();
350
+ const style = getComputedStyle(button);
351
+ return !button.disabled && rect.width > 0 && rect.height > 0 &&
352
+ rect.top >= 0 && rect.bottom <= innerHeight &&
353
+ style.display !== 'none' && style.visibility !== 'hidden';
354
+ });
355
+ if (candidates.length !== 1) return { clicked: false, matches: candidates.length };
356
+ candidates[0].click();
357
+ return { clicked: true, matches: 1 };
358
+ })()
359
+ `);
360
+ if (!placement?.clicked) {
361
+ throw new CommandExecutionError(
362
+ `Expected one visible final order control; found ${placement?.matches ?? 0}`,
363
+ );
364
+ }
365
+ return true;
366
+ }
367
+
368
+ cli({
369
+ site: SITE,
370
+ name: 'checkout',
371
+ access: 'write',
372
+ description: 'Prepare a guarded Amazon.in checkout with browser-only payment handoff',
373
+ domain: 'amazon.in',
374
+ strategy: Strategy.UI,
375
+ browser: true,
376
+ navigateBefore: false,
377
+ siteSession: 'persistent',
378
+ freshPage: true,
379
+ defaultWindowMode: 'foreground',
380
+ args: [
381
+ { name: 'input', required: true, positional: true, help: 'Amazon.in product URL or ASIN' },
382
+ { name: 'quantity', type: 'int', default: 1, help: 'Quantity (1-10)' },
383
+ { name: 'size', help: 'Exact visible size label' },
384
+ { name: 'colour', help: 'Exact visible colour label' },
385
+ {
386
+ name: 'payment',
387
+ required: true,
388
+ choices: ['upi', 'saved-card', 'new-card', 'cod'],
389
+ help: 'Payment method; secrets remain browser-only',
390
+ },
391
+ { name: 'card-last4', help: 'Saved-card selector: exactly four digits' },
392
+ { name: 'place-order', type: 'boolean', default: false, help: 'Submit the final Amazon action once' },
393
+ ],
394
+ columns: [
395
+ 'status', 'asin', 'title', 'size', 'colour', 'quantity', 'item_price',
396
+ 'total', 'payment_method', 'delivery_date', 'action', 'verify_command',
397
+ ],
398
+ func: async (page, args) => {
399
+ let url;
400
+ let options;
401
+ try {
402
+ url = buildProductUrl(args.input);
403
+ options = validateCheckoutArgs({
404
+ quantity: args.quantity,
405
+ size: args.size,
406
+ colour: args.colour,
407
+ payment: args.payment,
408
+ cardLast4: args['card-last4'],
409
+ placeOrder: args['place-order'],
410
+ });
411
+ } catch (error) {
412
+ throw new ArgumentError(error.message);
413
+ }
414
+
415
+ await gotoAmazon(page, url, 'checkout product');
416
+ await page.wait({ selector: '#productTitle', timeout: 15 });
417
+ await selectVariant(page, 'color', options.colour);
418
+ await selectVariant(page, 'size', options.size);
419
+ const selected = await readProductSelection(page);
420
+ if (!selected.asin || !selected.title) {
421
+ throw new CommandExecutionError('Amazon product selection could not be verified');
422
+ }
423
+
424
+ const quantitySet = await page.evaluateWithArgs(`
425
+ (() => {
426
+ const select = document.querySelector('#quantity');
427
+ if (!select) return quantity === 1;
428
+ if (![...select.options].some((option) => Number(option.value) === quantity)) return false;
429
+ select.value = String(quantity);
430
+ select.dispatchEvent(new Event('change', { bubbles: true }));
431
+ return Number(select.value) === quantity;
432
+ })()
433
+ `, { quantity: options.quantity });
434
+ if (!quantitySet) throw new ArgumentError(`quantity ${options.quantity} is not available`);
435
+
436
+ await page.click('#buy-now-button');
437
+ await page.sleep(3);
438
+ await assertUsablePage(page, 'checkout payment page');
439
+ try {
440
+ await page.wait({ selector: '#checkout-paymentOptionPanel input[type="radio"]', timeout: 20 });
441
+ } catch {
442
+ throw new CommandExecutionError('Amazon payment methods did not appear');
443
+ }
444
+ await waitForPaymentUi(page);
445
+
446
+ const paymentResult = await selectPayment(page, options);
447
+ if (paymentResult?.matches !== 1) {
448
+ throw new ArgumentError(
449
+ options.payment === 'saved-card'
450
+ ? `card-last4 ${options.cardLast4} did not match exactly one saved card`
451
+ : `${options.payment} is not uniquely available for this checkout`,
452
+ );
453
+ }
454
+ await page.click('input[data-webcmd-payment-target="true"]');
455
+ const handoff = await continueAfterPaymentSelection(page, options, selected, options.quantity);
456
+ if (handoff) return [handoff];
457
+ await advanceToReview(page);
458
+ await waitForReviewUi(page);
459
+ await assertUsablePage(page, 'checkout review');
460
+ const review = await readReview(page, selected, options);
461
+ if (!await submitOrder(page, options.placeOrder)) return [review];
462
+ await page.sleep(3);
463
+ return [{
464
+ ...review,
465
+ status: options.payment === 'cod' ? 'submitted' : 'action_required',
466
+ action: options.payment === 'upi'
467
+ ? 'Scan Amazon’s QR code in the opened browser and approve the UPI payment, then run checkout-status.'
468
+ : options.payment === 'cod'
469
+ ? ''
470
+ : 'Complete the bank verification in the opened browser, then run checkout-status.',
471
+ }];
472
+ },
473
+ });
474
+
475
+ export const __test__ = {
476
+ assertSingleLineItem,
477
+ continueAfterPaymentSelection,
478
+ submitOrder,
479
+ };