@agentrhq/webcmd 0.2.2 → 0.2.4

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.
Files changed (65) hide show
  1. package/cli-manifest.json +1278 -115
  2. package/clis/bigbasket/add-to-cart.js +82 -0
  3. package/clis/bigbasket/bigbasket.test.js +255 -0
  4. package/clis/bigbasket/cart.js +81 -0
  5. package/clis/bigbasket/category.js +30 -0
  6. package/clis/bigbasket/checkout.js +71 -0
  7. package/clis/bigbasket/location.js +30 -0
  8. package/clis/bigbasket/product.js +79 -0
  9. package/clis/bigbasket/search.js +30 -0
  10. package/clis/bigbasket/utils.js +207 -0
  11. package/clis/blinkit/add-to-cart.js +123 -0
  12. package/clis/blinkit/auth.js +99 -0
  13. package/clis/blinkit/blinkit.test.js +168 -0
  14. package/clis/blinkit/cart.js +34 -0
  15. package/clis/blinkit/checkout.js +32 -0
  16. package/clis/blinkit/location.js +29 -0
  17. package/clis/blinkit/place-order.js +78 -0
  18. package/clis/blinkit/product.js +63 -0
  19. package/clis/blinkit/search.js +89 -0
  20. package/clis/blinkit/utils.js +223 -0
  21. package/clis/district/checkout.js +71 -1
  22. package/clis/practo/appointment.js +21 -0
  23. package/clis/practo/appointments.js +27 -0
  24. package/clis/practo/book-confirm.js +35 -0
  25. package/clis/practo/book-preview.js +24 -0
  26. package/clis/practo/booking-link.js +24 -0
  27. package/clis/practo/cancel.js +29 -0
  28. package/clis/practo/contact.js +21 -0
  29. package/clis/practo/login.js +48 -0
  30. package/clis/practo/practo.test.js +154 -0
  31. package/clis/practo/profile.js +31 -0
  32. package/clis/practo/search.js +30 -0
  33. package/clis/practo/slots.js +19 -0
  34. package/clis/practo/utils.js +374 -0
  35. package/clis/practo/whoami.js +28 -0
  36. package/clis/zepto/add-to-cart.js +53 -0
  37. package/clis/zepto/auth.js +59 -0
  38. package/clis/zepto/cart.js +23 -0
  39. package/clis/zepto/checkout.js +60 -0
  40. package/clis/zepto/location.js +20 -0
  41. package/clis/zepto/place-order.js +47 -0
  42. package/clis/zepto/product.js +52 -0
  43. package/clis/zepto/search.js +30 -0
  44. package/clis/zepto/utils.js +228 -0
  45. package/clis/zepto/zepto.test.js +238 -0
  46. package/dist/src/browser/daemon-lifecycle.d.ts +2 -0
  47. package/dist/src/browser/daemon-lifecycle.js +28 -6
  48. package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +5 -0
  49. package/dist/src/browser/runtime/local-cloak/session-manager.js +116 -2
  50. package/dist/src/browser/runtime/local-cloak/session-manager.test.js +36 -0
  51. package/dist/src/browser.test.js +8 -4
  52. package/dist/src/cli.js +96 -0
  53. package/dist/src/cli.test.js +2 -2
  54. package/dist/src/generate-release-notes-cli.test.js +87 -13
  55. package/dist/src/plugin-catalog.d.ts +46 -0
  56. package/dist/src/plugin-catalog.js +178 -0
  57. package/dist/src/plugin-catalog.test.d.ts +1 -0
  58. package/dist/src/plugin-catalog.test.js +95 -0
  59. package/dist/src/release-notes.d.ts +4 -2
  60. package/dist/src/release-notes.js +67 -20
  61. package/dist/src/release-notes.test.js +67 -9
  62. package/package.json +2 -1
  63. package/plugin-catalog.json +10 -0
  64. package/scripts/generate-release-notes.ts +33 -4
  65. package/skills/webcmd-adapter-author/SKILL.md +9 -0
@@ -0,0 +1,207 @@
1
+ import { ArgumentError, CommandExecutionError } from '@agentrhq/webcmd/errors';
2
+
3
+ export const SITE = 'bigbasket';
4
+ export const DOMAIN = 'www.bigbasket.com';
5
+ export const HOME_URL = 'https://www.bigbasket.com/';
6
+ export const CART_URL = 'https://www.bigbasket.com/basket/';
7
+ export const CHECKOUT_URL = 'https://www.bigbasket.com/checkout/';
8
+
9
+ export function cleanText(value) {
10
+ return typeof value === 'string'
11
+ ? value.replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim()
12
+ : '';
13
+ }
14
+
15
+ export function parseMoney(value) {
16
+ const text = cleanText(String(value ?? ''));
17
+ if (!text) return null;
18
+ const match = text.replace(/,/g, '').match(/(\d+(?:\.\d+)?)/);
19
+ if (!match) return null;
20
+ const num = Number(match[1]);
21
+ return Number.isFinite(num) ? num : null;
22
+ }
23
+
24
+ function normalizeAvailability(value) {
25
+ const text = cleanText(value);
26
+ if (/instock$/i.test(text) || /\bin stock\b/i.test(text)) return 'In stock';
27
+ if (/outofstock$/i.test(text) || /out of stock|unavailable/i.test(text)) return 'Out of stock';
28
+ return text;
29
+ }
30
+
31
+ export function parseLimitArg(raw, fallback, max) {
32
+ if (raw === undefined || raw === null || raw === '') return fallback;
33
+ const num = Number(raw);
34
+ if (!Number.isInteger(num) || num < 1 || num > max) {
35
+ throw new ArgumentError(`--limit must be an integer between 1 and ${max} (got ${raw})`);
36
+ }
37
+ return num;
38
+ }
39
+
40
+ export function parseQuantityArg(raw, fallback, max) {
41
+ if (raw === undefined || raw === null || raw === '') return fallback;
42
+ const num = Number(raw);
43
+ if (!Number.isInteger(num) || num < 1 || num > max) {
44
+ throw new ArgumentError(`--quantity must be an integer between 1 and ${max} (got ${raw})`);
45
+ }
46
+ return num;
47
+ }
48
+
49
+ export function buildSearchUrl(query) {
50
+ const normalized = cleanText(query);
51
+ if (!normalized) throw new ArgumentError('bigbasket search query cannot be empty');
52
+ return `${HOME_URL}ps/?q=${encodeURIComponent(normalized)}`;
53
+ }
54
+
55
+ export function toBigBasketUrl(value) {
56
+ const text = cleanText(value);
57
+ if (!text) return '';
58
+ try {
59
+ const url = new URL(text.startsWith('http') ? text : text.startsWith('/') ? text : `/${text}`, HOME_URL);
60
+ if (url.hostname !== DOMAIN) {
61
+ throw new Error('not bigbasket');
62
+ }
63
+ url.hash = '';
64
+ return url.toString();
65
+ } catch {
66
+ return '';
67
+ }
68
+ }
69
+
70
+ export function resolveCategoryUrl(input) {
71
+ const text = cleanText(input);
72
+ if (!text) throw new ArgumentError('bigbasket category requires a category URL or slug');
73
+ if (!text.startsWith('http') && !text.includes('/')) {
74
+ throw new ArgumentError('bigbasket category expects a category path such as fruits-vegetables/vegetables');
75
+ }
76
+ const prefixed = text.startsWith('http') || text.startsWith('/pc/') ? text : `/pc/${text.replace(/^\/+/, '')}/`;
77
+ const url = toBigBasketUrl(prefixed);
78
+ if (!url || !new URL(url).pathname.startsWith('/pc/')) {
79
+ throw new ArgumentError('bigbasket category expects a BigBasket /pc/<category>/ URL or slug');
80
+ }
81
+ return url;
82
+ }
83
+
84
+ export function resolveProductInput(input) {
85
+ const text = cleanText(input);
86
+ if (!text) throw new ArgumentError('bigbasket product requires a product ID or URL');
87
+ if (/^\d{4,}$/.test(text)) {
88
+ return { productId: text, url: `${HOME_URL}pd/${text}/` };
89
+ }
90
+ const url = toBigBasketUrl(text);
91
+ if (!url) throw new ArgumentError('bigbasket product expects a BigBasket product ID or /pd/<id>/ URL');
92
+ const match = new URL(url).pathname.match(/^\/pd\/(\d{4,})(?:\/|$)/);
93
+ if (!match) throw new ArgumentError('bigbasket product expects a BigBasket product ID or /pd/<id>/ URL');
94
+ return { productId: match[1], url };
95
+ }
96
+
97
+ export function normalizeProductRow(raw, rank) {
98
+ const url = toBigBasketUrl(raw.url || raw.href || '');
99
+ const productId = cleanText(raw.product_id || raw.productId || raw.id || url.match(/\/pd\/(\d{4,})/)?.[1] || '');
100
+ return {
101
+ rank: rank + 1,
102
+ product_id: productId,
103
+ title: cleanText(raw.title || raw.name),
104
+ brand: cleanText(raw.brand),
105
+ pack_size: cleanText(raw.pack_size || raw.packSize || raw.size),
106
+ price: parseMoney(raw.price),
107
+ mrp: parseMoney(raw.mrp || raw.original_price || raw.originalPrice),
108
+ discount: cleanText(raw.discount),
109
+ availability: normalizeAvailability(raw.availability || raw.stock),
110
+ url,
111
+ };
112
+ }
113
+
114
+ function parseJsonObject(raw) {
115
+ try {
116
+ const value = typeof raw === 'string' ? JSON.parse(raw) : raw;
117
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
118
+ } catch {
119
+ return {};
120
+ }
121
+ }
122
+
123
+ export function normalizeLocationState(raw = {}) {
124
+ const info = parseJsonObject(raw.selectedAddressInfo || raw.selected_address_info || raw.address);
125
+ const pincode = cleanText(String(info.pin || info.pincode || raw.pin || raw.pincode || ''));
126
+ return {
127
+ selected: Boolean(raw.selected_address_id || raw.selectedAddressId || info.id || pincode),
128
+ label: cleanText(info.nick || info.nickname || raw.label || raw.nick),
129
+ area: cleanText(info.area || info.locality || raw.area),
130
+ city: cleanText(info.city_name || info.city || raw.city),
131
+ pincode,
132
+ source: raw.selectedAddressInfo || raw.selected_address_info ? 'selectedAddressInfo' : '',
133
+ };
134
+ }
135
+
136
+ export async function safeGoto(page, url, label) {
137
+ await page.goto(url).catch((error) => {
138
+ throw new CommandExecutionError(`${label} navigation failed: ${error?.message || error}`);
139
+ });
140
+ }
141
+
142
+ export function productCardsEvaluate(limit) {
143
+ return `
144
+ (() => {
145
+ const limit = ${Number(limit) || 20};
146
+ const clean = (value) => value == null ? '' : String(value).replace(/\\s+/g, ' ').trim();
147
+ const leafText = (root) => Array.from(root.querySelectorAll('*'))
148
+ .filter((node) => node.children.length === 0)
149
+ .map((node) => clean(node.textContent))
150
+ .filter(Boolean);
151
+ const anchors = Array.from(document.querySelectorAll('a[href*="/pd/"]'));
152
+ const byProductId = new Map();
153
+ const rows = [];
154
+
155
+ for (const anchor of anchors) {
156
+ const href = anchor.href || anchor.getAttribute('href') || '';
157
+ const productId = href.match(/\\/pd\\/(\\d{4,})/)?.[1] || '';
158
+ if (!productId) continue;
159
+
160
+ const root = anchor.closest('li, article, [data-testid], [class*="Product"], [class*="product"], [class*="SKU"], [class*="sku"]') || anchor;
161
+ const productAnchors = Array.from(root.querySelectorAll('a[href*="/pd/"]'))
162
+ .filter((node) => (node.href || node.getAttribute('href') || '').includes('/pd/' + productId + '/'));
163
+ const titleAnchor = productAnchors.find((node) => clean(node.textContent)) || anchor;
164
+ const titleParam = (() => {
165
+ try { return clean(new URL(href, location.origin).searchParams.get('t_s')); } catch { return ''; }
166
+ })();
167
+ const texts = leafText(root);
168
+ const joined = texts.join(' | ');
169
+ const brand = clean(titleAnchor.querySelector('span')?.textContent);
170
+ const visibleTitle =
171
+ clean(titleAnchor.getAttribute('title')) ||
172
+ clean(titleAnchor.querySelector('h1,h2,h3,[class*="name"],[class*="Name"]')?.textContent) ||
173
+ clean(titleAnchor.textContent).replace(brand, '').trim() ||
174
+ texts.find((text) => /[A-Za-z]/.test(text) && !/₹|rs\\.?|add|cart|off/i.test(text)) ||
175
+ '';
176
+ const title = !visibleTitle || visibleTitle === brand ? titleParam : visibleTitle;
177
+ const price = texts.find((text) => /₹|rs\\.?/i.test(text) && !/mrp/i.test(text)) || '';
178
+ const mrp = texts.find((text) => /mrp/i.test(text)) || '';
179
+ const discount = texts.find((text) => /%\\s*off|save/i.test(text)) || '';
180
+ const packSize = texts.find((text) => /\\b\\d+(?:\\.\\d+)?\\s*(?:kg|g|gm|ml|l|ltr|pcs?|pack)\\b/i.test(text)) || '';
181
+ const availability = /out of stock|unavailable/i.test(joined) ? 'Out of stock' : '';
182
+
183
+ const row = {
184
+ product_id: productId,
185
+ title,
186
+ brand,
187
+ pack_size: packSize,
188
+ price,
189
+ mrp,
190
+ discount,
191
+ availability,
192
+ url: href,
193
+ };
194
+ const existing = byProductId.get(productId);
195
+ if (!existing || (!existing.title && row.title)) byProductId.set(productId, row);
196
+ }
197
+
198
+ for (const row of byProductId.values()) {
199
+ rows.push(row);
200
+ if (rows.length >= limit) break;
201
+ }
202
+
203
+ const bodyText = clean(document.body?.innerText || '');
204
+ return { rows, bodyText, href: location.href };
205
+ })()
206
+ `;
207
+ }
@@ -0,0 +1,123 @@
1
+ import { CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors';
2
+ import { cli, Strategy } from '@agentrhq/webcmd/registry';
3
+ import {
4
+ DOMAIN,
5
+ ensureCartHasItems,
6
+ openCartPanel,
7
+ parseQuantity,
8
+ readCartState,
9
+ requireProductId,
10
+ resolveCoordinates,
11
+ summarizeCartResponse,
12
+ } from './utils.js';
13
+
14
+ function buildCartItemEvaluate(productId, lat, lon) {
15
+ return `
16
+ (async () => {
17
+ const headers = { lat: ${JSON.stringify(lat)}, lon: ${JSON.stringify(lon)}, app_client: 'consumer_web' };
18
+ const resp = await fetch('/v1/layout/product/' + ${JSON.stringify(productId)}, { method: 'POST', credentials: 'include', headers });
19
+ const json = await resp.json().catch(() => null);
20
+ if (!resp.ok || !json) return { ok: false, status: resp.status };
21
+ const snippets = json?.response?.snippets || [];
22
+ const strip = snippets.find((snippet) => snippet?.widget_type === 'product_atc_strip')?.data || {};
23
+ const action = strip.stepper_data_v2?.increment_actions?.default?.find((item) => item?.add_to_cart)
24
+ || strip.rfc_actions_v2?.default?.find((item) => item?.remove_from_cart);
25
+ const cartItem = action?.add_to_cart?.cart_item || action?.remove_from_cart?.cart_item || null;
26
+ return [Boolean(cartItem), resp.status, cartItem, strip.inventory, strip.is_sold_out === true];
27
+ })()
28
+ `;
29
+ }
30
+
31
+ function buildWriteCartEvaluate(cartItem, quantity) {
32
+ return `
33
+ (() => {
34
+ const cartItem = ${JSON.stringify(cartItem)};
35
+ const quantity = ${quantity};
36
+ const readJson = (key, fallback) => {
37
+ try { return JSON.parse(localStorage.getItem(key) || 'null') || fallback; } catch { return fallback; }
38
+ };
39
+ const cart = readJson('cart', { count: 0, total: 0, chargeableDeliveryCost: 0, items: {}, promoInfo: [], paymentMode: null, step: [], version: 1, promo_id: '', CartAddressScreenVisible: false, uniqueSkuInCart: 0, cart_type: '', cart_state: 'invalid' });
40
+ const id = String(cartItem.product_id);
41
+ const current = cart.items?.[id]?.quantity || 0;
42
+ cart.items = cart.items || {};
43
+ cart.items[id] = {
44
+ product: {
45
+ product_id: cartItem.product_id,
46
+ price: cartItem.price,
47
+ image_url: cartItem.image_url,
48
+ unit: cartItem.unit,
49
+ mrp: cartItem.mrp,
50
+ group_id: cartItem.group_id,
51
+ merchant_id: cartItem.merchant_id,
52
+ name: cartItem.product_name || cartItem.display_name,
53
+ brand: cartItem.brand
54
+ },
55
+ quantity: current + quantity
56
+ };
57
+ const values = Object.values(cart.items);
58
+ cart.count = values.reduce((sum, item) => sum + Number(item.quantity || 0), 0);
59
+ cart.total = values.reduce((sum, item) => sum + Number(item.quantity || 0) * Number(item.product?.price || 0), 0);
60
+ cart.uniqueSkuInCart = values.length;
61
+ cart.version = 1;
62
+ localStorage.setItem('cart', JSON.stringify(cart));
63
+ window.__reduxStore__?.dispatch?.({ type: 'SYNC_CART', cart });
64
+ return [true, id, cart.items[id].quantity, cart.count, cart.total];
65
+ })()
66
+ `;
67
+ }
68
+
69
+ cli({
70
+ site: 'blinkit',
71
+ name: 'add-to-cart',
72
+ access: 'write',
73
+ description: 'Add a Blinkit product to cart',
74
+ domain: DOMAIN,
75
+ strategy: Strategy.COOKIE,
76
+ browser: true,
77
+ navigateBefore: false,
78
+ args: [
79
+ { name: 'productId', required: true, positional: true, help: 'Blinkit product id' },
80
+ { name: 'quantity', type: 'int', default: 1, help: 'Quantity to add (default 1, max 12)' },
81
+ { name: 'lat', help: 'Delivery latitude (defaults to current Blinkit browser location)' },
82
+ { name: 'lon', help: 'Delivery longitude (defaults to current Blinkit browser location)' },
83
+ ],
84
+ columns: ['status', 'productId', 'quantity', 'itemCount', 'itemsTotal', 'payable', 'message'],
85
+ func: async (page, kwargs) => {
86
+ const productId = requireProductId(kwargs.productId);
87
+ const quantity = parseQuantity(kwargs.quantity);
88
+
89
+ await page.goto(`https://blinkit.com/prn/x/prid/${productId}`).catch((error) => {
90
+ throw new CommandExecutionError(`blinkit add-to-cart navigation failed: ${error?.message || error}`);
91
+ });
92
+ const { lat, lon } = await resolveCoordinates(page, kwargs);
93
+ const found = await page.evaluate(buildCartItemEvaluate(productId, lat, lon)).catch((error) => {
94
+ throw new CommandExecutionError(`blinkit add-to-cart product read failed: ${error?.message || error}`);
95
+ });
96
+ const [foundOk, , cartItem, , soldOut] = Array.isArray(found) ? found : [];
97
+ if (!foundOk || !cartItem) throw new EmptyResultError('blinkit add-to-cart', `No cart payload for product ${productId}`);
98
+ if (soldOut) throw new CommandExecutionError(`Product ${productId} is sold out`);
99
+
100
+ const updated = await page.evaluate(buildWriteCartEvaluate(cartItem, quantity)).catch((error) => {
101
+ throw new CommandExecutionError(`blinkit add-to-cart write failed: ${error?.message || error}`);
102
+ });
103
+ const [updatedOk, , updatedQuantity] = Array.isArray(updated) ? updated : [];
104
+ if (!updatedOk) throw new CommandExecutionError(`Could not add product ${productId} to cart`);
105
+
106
+ await openCartPanel(page);
107
+ const summary = summarizeCartResponse(await readCartState(page));
108
+ ensureCartHasItems(summary);
109
+ return [{
110
+ status: 'added',
111
+ productId,
112
+ quantity: updatedQuantity,
113
+ itemCount: summary.itemCount,
114
+ itemsTotal: summary.itemsTotal,
115
+ payable: summary.payable,
116
+ message: `Added ${quantity}`,
117
+ }];
118
+ },
119
+ });
120
+
121
+ export const __test__ = {
122
+ buildWriteCartEvaluate,
123
+ };
@@ -0,0 +1,99 @@
1
+ import { AuthRequiredError, TimeoutError } from '@agentrhq/webcmd/errors';
2
+ import { cli, Strategy } from '@agentrhq/webcmd/registry';
3
+ import { BASE, DOMAIN } from './utils.js';
4
+
5
+ const DEFAULT_TIMEOUT_SECONDS = 300;
6
+
7
+ async function probeBlinkitIdentity(page) {
8
+ const probe = await page.evaluate(`
9
+ (() => {
10
+ const readJson = (key) => {
11
+ try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch { return null; }
12
+ };
13
+ const state = window.__reduxStore__?.getState?.();
14
+ const auth = state?.data?.auth || readJson('auth') || {};
15
+ const user = state?.data?.user || readJson('user') || {};
16
+ const text = document.body.innerText || '';
17
+ if (!auth.accessToken && /^Login$/m.test(text)) return { kind: 'auth', detail: 'Blinkit login button is still visible' };
18
+ if (!auth.accessToken) return { kind: 'auth', detail: 'Blinkit auth access token missing' };
19
+ return {
20
+ ok: true,
21
+ phone: auth.phoneNumber || user.phone || user.profile?.phone || '',
22
+ user_id: user.id || user.user_id || user.profile?.id || ''
23
+ };
24
+ })()
25
+ `);
26
+ if (probe?.kind === 'auth') throw new AuthRequiredError(DOMAIN, probe.detail);
27
+ return { phone: probe?.phone || '', user_id: probe?.user_id || '' };
28
+ }
29
+
30
+ function buildOpenLoginEvaluate() {
31
+ return `
32
+ (() => {
33
+ const readJson = (key) => {
34
+ try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch { return null; }
35
+ };
36
+ const state = window.__reduxStore__?.getState?.();
37
+ const auth = state?.data?.auth || readJson('auth') || {};
38
+ if (auth.accessToken) return { opened: false, detail: 'already_logged_in' };
39
+
40
+ const dialogText = document.querySelector('[role="dialog"]')?.innerText || '';
41
+ if (/Enter mobile number|Log in or Sign up/i.test(dialogText)) {
42
+ return { opened: true, detail: 'login_dialog_visible' };
43
+ }
44
+
45
+ const target = Array.from(document.querySelectorAll('button, [role="button"], a, div'))
46
+ .find((node) => (node.innerText || node.textContent || '').trim() === 'Login');
47
+ target?.dispatchEvent?.(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
48
+ return { opened: Boolean(target), detail: target ? 'clicked_login' : 'login_button_missing' };
49
+ })()
50
+ `;
51
+ }
52
+
53
+ cli({
54
+ site: 'blinkit',
55
+ name: 'login',
56
+ access: 'write',
57
+ description: 'Open Blinkit login and wait until the browser session is authenticated',
58
+ domain: DOMAIN,
59
+ strategy: Strategy.COOKIE,
60
+ browser: true,
61
+ navigateBefore: false,
62
+ defaultWindowMode: 'foreground',
63
+ siteSession: 'persistent',
64
+ args: [
65
+ { name: 'timeout', type: 'int', default: DEFAULT_TIMEOUT_SECONDS, help: 'Maximum seconds to wait for the user to finish login' },
66
+ ],
67
+ columns: ['status', 'logged_in', 'phone', 'user_id'],
68
+ func: async (page, kwargs) => {
69
+ await page.goto(BASE);
70
+ await page.wait(1);
71
+ try {
72
+ const identity = await probeBlinkitIdentity(page);
73
+ return [{ status: 'already_logged_in', logged_in: true, ...identity }];
74
+ } catch (error) {
75
+ if (!(error instanceof AuthRequiredError)) throw error;
76
+ }
77
+
78
+ const opened = await page.evaluate(buildOpenLoginEvaluate()).catch(() => null);
79
+ const timeoutSeconds = Number(kwargs.timeout ?? DEFAULT_TIMEOUT_SECONDS);
80
+ const deadline = Date.now() + timeoutSeconds * 1000;
81
+ let lastMessage = opened?.opened ? '' : opened?.detail || '';
82
+ while (Date.now() < deadline) {
83
+ await page.wait(2);
84
+ try {
85
+ const identity = await probeBlinkitIdentity(page);
86
+ return [{ status: 'login_complete', logged_in: true, ...identity }];
87
+ } catch (error) {
88
+ if (!(error instanceof AuthRequiredError)) throw error;
89
+ lastMessage = error.message;
90
+ }
91
+ }
92
+ throw new TimeoutError('blinkit login', timeoutSeconds, lastMessage || 'Finish OTP login in the opened Blinkit tab and retry.');
93
+ },
94
+ });
95
+
96
+ export const __test__ = {
97
+ buildOpenLoginEvaluate,
98
+ probeBlinkitIdentity,
99
+ };
@@ -0,0 +1,168 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ArgumentError } from '@agentrhq/webcmd/errors';
3
+ import { getRegistry } from '@agentrhq/webcmd/registry';
4
+ import { __test__ as authTest } from './auth.js';
5
+ import { __test__ as searchTest } from './search.js';
6
+ import { __test__ as productTest } from './product.js';
7
+ import { __test__ as addToCartTest } from './add-to-cart.js';
8
+ import { __test__ as placeOrderTest } from './place-order.js';
9
+ import { normalizeLocationState, resolveCoordinates } from './utils.js';
10
+ import './cart.js';
11
+ import './checkout.js';
12
+ import './location.js';
13
+
14
+ describe('blinkit helpers', () => {
15
+ it('rejects invalid external args before browser work', () => {
16
+ expect(() => searchTest.parseLimit(0)).toThrow(ArgumentError);
17
+ expect(() => searchTest.parseLimit(49)).toThrow(ArgumentError);
18
+ expect(() => searchTest.parseCoordinate('91', 'lat', '28.413333')).toThrow(ArgumentError);
19
+ expect(() => searchTest.parseCoordinate('181', 'lon', '77.072833')).toThrow(ArgumentError);
20
+ });
21
+
22
+ it('normalizes product snippets from Blinkit layout search', () => {
23
+ const row = searchTest.normalizeSnippet({
24
+ widget_type: 'product_card_snippet_type_2',
25
+ data: {
26
+ identity: { id: '19512' },
27
+ name: { text: 'Amul Taaza Toned Milk' },
28
+ brand_name: { text: 'Amul' },
29
+ variant: { text: '500 ml' },
30
+ normal_price: { text: '₹30' },
31
+ inventory: 12,
32
+ merchant_id: '31719',
33
+ eta_tag: { title: { text: 'earliest' } },
34
+ image: { url: 'https://cdn.grofers.com/product.png' },
35
+ },
36
+ }, 1);
37
+
38
+ expect(row).toMatchObject({
39
+ rank: 1,
40
+ productId: '19512',
41
+ name: 'Amul Taaza Toned Milk',
42
+ brand: 'Amul',
43
+ variant: '500 ml',
44
+ price: 30,
45
+ currency: 'INR',
46
+ inventory: 12,
47
+ available: true,
48
+ url: 'https://blinkit.com/prn/x/prid/19512',
49
+ });
50
+ });
51
+
52
+ it('normalizes product detail snippets', () => {
53
+ const row = productTest.normalizeProduct([
54
+ { widget_type: 'text_right_icons_rating_snippet_type', data: { title: { text: 'Amul Taaza Toned Milk' } } },
55
+ {
56
+ widget_type: 'product_atc_strip',
57
+ data: {
58
+ identity: { id: '19512' },
59
+ variant: { text: '500 ml' },
60
+ normal_price: { text: '₹30' },
61
+ product_id: '19512',
62
+ stepper_data_v2: {
63
+ increment_actions: {
64
+ default: [{ add_to_cart: { cart_item: { product_id: 19512, product_name: 'Amul Taaza Toned Milk', price: 30, mrp: 30, unit: '500 ml' } } }],
65
+ },
66
+ },
67
+ },
68
+ },
69
+ ], '19512');
70
+ expect(row).toMatchObject({ productId: '19512', name: 'Amul Taaza Toned Milk', price: 30 });
71
+ });
72
+
73
+ it('keeps place-order no-op unless --confirm is passed', async () => {
74
+ const command = getRegistry().get('blinkit/place-order');
75
+ const fakePage = { goto: () => { throw new Error('should not navigate'); } };
76
+ await expect(command.func(fakePage, {})).resolves.toMatchObject([{ status: 'no-op', confirmed: false }]);
77
+ });
78
+
79
+ it('builds the cart write script from product payload', () => {
80
+ const script = addToCartTest.buildWriteCartEvaluate({ product_id: 19512, price: 30, mrp: 30, unit: '500 ml' }, 2);
81
+ expect(script).toContain('localStorage.setItem');
82
+ expect(script).toContain('SYNC_CART');
83
+ });
84
+
85
+ it('opens the Blinkit login dialog before waiting for OTP', () => {
86
+ const script = authTest.buildOpenLoginEvaluate();
87
+ expect(script).toContain('Login');
88
+ expect(script).toContain('click');
89
+ expect(script).toContain('Enter mobile number');
90
+ });
91
+
92
+ it('uses the current Blinkit browser location when coordinates are not explicit', async () => {
93
+ const page = {
94
+ evaluate: async () => ({ lat: 12.9110953, lon: 77.6292907 }),
95
+ };
96
+
97
+ await expect(resolveCoordinates(page, {})).resolves.toEqual({ lat: '12.9110953', lon: '77.6292907' });
98
+ await expect(resolveCoordinates(page, { lat: '1', lon: '2' })).resolves.toEqual({ lat: '1', lon: '2' });
99
+ });
100
+
101
+ it('normalizes selected location without leaking exact address or coordinates', () => {
102
+ expect(normalizeLocationState({
103
+ address: 'Block C-03 / 79, Private Building',
104
+ city: 'Bengaluru',
105
+ locality: 'HSR Layout',
106
+ pinCode: '560102',
107
+ coords: { lat: 12.9110953, lon: 77.6292907 },
108
+ })).toEqual({
109
+ selected: true,
110
+ label: '',
111
+ area: 'HSR Layout',
112
+ city: 'Bengaluru',
113
+ pincode: '560102',
114
+ hasCoordinates: true,
115
+ source: 'browser',
116
+ });
117
+ });
118
+
119
+ it('reports empty checkout instead of failing before items are added', async () => {
120
+ const command = getRegistry().get('blinkit/checkout');
121
+ let readCartState = false;
122
+ const fakePage = {
123
+ goto: async () => {},
124
+ wait: async () => {},
125
+ evaluate: async (script) => {
126
+ if (script.includes('loggedIn')) {
127
+ readCartState = true;
128
+ return {
129
+ ok: true,
130
+ loggedIn: true,
131
+ storedCart: { items: {}, count: 0, total: 0, cart_state: 'valid' },
132
+ };
133
+ }
134
+ if (script.includes('cartResponse')) return false;
135
+ return true;
136
+ },
137
+ };
138
+
139
+ await expect(command.func(fakePage, {})).resolves.toMatchObject([{ status: 'empty', itemCount: 0 }]);
140
+ expect(readCartState).toBe(true);
141
+ });
142
+ });
143
+
144
+ describe('blinkit registry shape', () => {
145
+ it('registers the buying-path commands', () => {
146
+ for (const name of ['login', 'location', 'search', 'product', 'add-to-cart', 'cart', 'checkout', 'place-order']) {
147
+ expect(getRegistry().get(`blinkit/${name}`)).toBeDefined();
148
+ }
149
+ });
150
+
151
+ it('marks only cart-changing commands as write', () => {
152
+ expect(getRegistry().get('blinkit/search').access).toBe('read');
153
+ expect(getRegistry().get('blinkit/product').access).toBe('read');
154
+ expect(getRegistry().get('blinkit/location').access).toBe('read');
155
+ expect(getRegistry().get('blinkit/cart').access).toBe('read');
156
+ expect(getRegistry().get('blinkit/checkout').access).toBe('read');
157
+ expect(getRegistry().get('blinkit/login').access).toBe('write');
158
+ expect(getRegistry().get('blinkit/add-to-cart').access).toBe('write');
159
+ expect(getRegistry().get('blinkit/place-order').access).toBe('write');
160
+ });
161
+
162
+ it('place-order script only targets final order/payment buttons', () => {
163
+ const script = placeOrderTest.buildPlaceOrderEvaluate();
164
+ expect(script).toContain('place order');
165
+ expect(script).toContain('cash on delivery');
166
+ expect(script).not.toContain('Proceed');
167
+ });
168
+ });
@@ -0,0 +1,34 @@
1
+ import { cli, Strategy } from '@agentrhq/webcmd/registry';
2
+ import { DOMAIN, openCartPanel, readCartState, summarizeCartResponse } from './utils.js';
3
+
4
+ cli({
5
+ site: 'blinkit',
6
+ name: 'cart',
7
+ access: 'read',
8
+ description: 'Show the current Blinkit cart',
9
+ domain: DOMAIN,
10
+ strategy: Strategy.COOKIE,
11
+ browser: true,
12
+ navigateBefore: false,
13
+ args: [],
14
+ columns: ['status', 'productId', 'name', 'variant', 'price', 'quantity', 'total', 'itemCount', 'payable', 'cartState'],
15
+ func: async (page) => {
16
+ await openCartPanel(page);
17
+ const summary = summarizeCartResponse(await readCartState(page));
18
+ if (!summary.items.length) {
19
+ return [{ status: 'empty', itemCount: 0, payable: summary.payable, cartState: summary.cartState }];
20
+ }
21
+ return summary.items.map((item) => ({
22
+ status: summary.status,
23
+ productId: item.productId,
24
+ name: item.name,
25
+ variant: item.variant,
26
+ price: item.price,
27
+ quantity: item.quantity,
28
+ total: item.total,
29
+ itemCount: summary.itemCount,
30
+ payable: summary.payable,
31
+ cartState: summary.cartState,
32
+ }));
33
+ },
34
+ });
@@ -0,0 +1,32 @@
1
+ import { cli, Strategy } from '@agentrhq/webcmd/registry';
2
+ import { DOMAIN, openCartPanel, readCartState, summarizeCartResponse } from './utils.js';
3
+
4
+ cli({
5
+ site: 'blinkit',
6
+ name: 'checkout',
7
+ access: 'read',
8
+ description: 'Review Blinkit checkout totals and blockers without placing an order',
9
+ domain: DOMAIN,
10
+ strategy: Strategy.COOKIE,
11
+ browser: true,
12
+ navigateBefore: false,
13
+ defaultWindowMode: 'foreground',
14
+ args: [],
15
+ columns: ['status', 'itemCount', 'itemsTotal', 'deliveryCharge', 'handlingCharge', 'payable', 'cartState', 'checkoutBlocked', 'validations'],
16
+ func: async (page) => {
17
+ await openCartPanel(page);
18
+ const state = await readCartState(page);
19
+ const summary = summarizeCartResponse(state);
20
+ return [{
21
+ status: state.loggedIn ? summary.status : 'login_required',
22
+ itemCount: summary.itemCount,
23
+ itemsTotal: summary.itemsTotal,
24
+ deliveryCharge: summary.deliveryCharge,
25
+ handlingCharge: summary.handlingCharge,
26
+ payable: summary.payable,
27
+ cartState: summary.cartState,
28
+ checkoutBlocked: summary.checkoutBlocked,
29
+ validations: summary.validations,
30
+ }];
31
+ },
32
+ });