@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,52 @@
1
+ import { EmptyResultError } from '@agentrhq/webcmd/errors';
2
+ import { cli, Strategy } from '@agentrhq/webcmd/registry';
3
+ import { DOMAIN, SITE, ZEPTO_NAV_OPTIONS, cleanText, normalizeProductRow, resolveProductInput, safeGoto } from './utils.js';
4
+
5
+ function productEvaluate(productId) {
6
+ return `
7
+ (() => {
8
+ const productId = ${JSON.stringify(productId)};
9
+ const clean = (value) => value == null ? '' : String(value).replace(/\\s+/g, ' ').trim();
10
+ const text = clean(document.body?.innerText || document.body?.textContent || '');
11
+ const prices = Array.from(document.querySelectorAll('span,div'))
12
+ .map((node) => clean(node.textContent))
13
+ .filter((value) => /^₹\\s*\\d/i.test(value));
14
+ const title = clean(document.querySelector('h1')?.textContent)
15
+ || text.split('\\n').map(clean).find((line) => /[A-Za-z]/.test(line) && !/Delivery|Search|Login|Cart|Home|₹|Add to Cart/i.test(line))
16
+ || '';
17
+ return {
18
+ product_id: productId,
19
+ title,
20
+ brand: clean(Array.from(document.querySelectorAll('div,span')).find((node) => clean(node.textContent) === 'Brand')?.nextElementSibling?.textContent),
21
+ pack_size: text.match(/\\b\\d+(?:\\.\\d+)?\\s*(?:kg|g|ml|l|pcs?|pack)\\b/i)?.[0] || '',
22
+ price: prices[0] || '',
23
+ mrp: prices[1] || '',
24
+ availability: /out of stock|unavailable/i.test(text) ? 'Out of stock' : '',
25
+ url: location.href,
26
+ };
27
+ })()
28
+ `;
29
+ }
30
+
31
+ cli({
32
+ site: SITE,
33
+ name: 'product',
34
+ access: 'read',
35
+ description: 'Read Zepto product details',
36
+ domain: DOMAIN,
37
+ strategy: Strategy.COOKIE,
38
+ browser: true,
39
+ navigateBefore: false,
40
+ args: [
41
+ { name: 'product', required: true, positional: true, help: 'Product URL from Zepto search results' },
42
+ ],
43
+ columns: ['product_id', 'title', 'brand', 'pack_size', 'price', 'mrp', 'availability', 'url'],
44
+ func: async (page, kwargs) => {
45
+ const product = resolveProductInput(kwargs.product);
46
+ await safeGoto(page, product.url, 'zepto product', ZEPTO_NAV_OPTIONS);
47
+ if (page.wait) await page.wait(2);
48
+ const row = normalizeProductRow(await page.evaluate(productEvaluate(product.productId)), 0);
49
+ if (!row.product_id || !cleanText(row.title)) throw new EmptyResultError('zepto product', `No product details found for ${product.productId}.`);
50
+ return [row];
51
+ },
52
+ });
@@ -0,0 +1,30 @@
1
+ import { AuthRequiredError, EmptyResultError } from '@agentrhq/webcmd/errors';
2
+ import { cli, Strategy } from '@agentrhq/webcmd/registry';
3
+ import { DOMAIN, SITE, ZEPTO_NAV_OPTIONS, buildSearchUrl, normalizeProductRow, parseLimitArg, productCardsEvaluate, safeGoto } from './utils.js';
4
+
5
+ cli({
6
+ site: SITE,
7
+ name: 'search',
8
+ access: 'read',
9
+ description: 'Search Zepto products',
10
+ domain: DOMAIN,
11
+ strategy: Strategy.COOKIE,
12
+ browser: true,
13
+ navigateBefore: false,
14
+ args: [
15
+ { name: 'query', required: true, positional: true, help: 'Search query' },
16
+ { name: 'limit', type: 'int', default: 20, help: 'Maximum products to return (max 50)' },
17
+ ],
18
+ columns: ['rank', 'product_id', 'title', 'brand', 'pack_size', 'price', 'mrp', 'availability', 'url'],
19
+ func: async (page, kwargs) => {
20
+ const url = buildSearchUrl(kwargs.query);
21
+ const limit = parseLimitArg(kwargs.limit, 20, 50);
22
+ await safeGoto(page, url, 'zepto search', ZEPTO_NAV_OPTIONS);
23
+ if (page.wait) await page.wait(2);
24
+ const result = await page.evaluate(productCardsEvaluate(limit));
25
+ if (result?.authRequired) throw new AuthRequiredError(DOMAIN, 'Log into Zepto in the Webcmd browser session to search products.');
26
+ const rows = (result?.rows || []).map(normalizeProductRow).filter((row) => row.product_id && row.title);
27
+ if (!rows.length) throw new EmptyResultError('zepto search', `No Zepto products matched "${kwargs.query}".`);
28
+ return rows;
29
+ },
30
+ });
@@ -0,0 +1,228 @@
1
+ import { ArgumentError, CommandExecutionError } from '@agentrhq/webcmd/errors';
2
+
3
+ export const SITE = 'zepto';
4
+ export const DOMAIN = 'www.zepto.com';
5
+ export const HOME_URL = 'https://www.zepto.com';
6
+ export const MAX_LIMIT = 50;
7
+ export const ZEPTO_NAV_OPTIONS = { waitUntil: 'none', settleMs: 1500 };
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
+ if (typeof value === 'number' && Number.isFinite(value)) return value > 999 ? value / 100 : value;
17
+ const match = cleanText(String(value ?? '')).replace(/,/g, '').match(/(\d+(?:\.\d+)?)/);
18
+ return match ? Number(match[1]) : null;
19
+ }
20
+
21
+ export function parseLimitArg(raw, fallback, max = MAX_LIMIT) {
22
+ if (raw === undefined || raw === null || raw === '') return fallback;
23
+ const num = Number(raw);
24
+ if (!Number.isInteger(num) || num < 1 || num > max) {
25
+ throw new ArgumentError(`--limit must be an integer between 1 and ${max} (got ${raw})`);
26
+ }
27
+ return num;
28
+ }
29
+
30
+ export function parseQuantityArg(raw, fallback, max = 12) {
31
+ if (raw === undefined || raw === null || raw === '') return fallback;
32
+ const num = Number(raw);
33
+ if (!Number.isInteger(num) || num < 1 || num > max) {
34
+ throw new ArgumentError(`--quantity must be an integer between 1 and ${max} (got ${raw})`);
35
+ }
36
+ return num;
37
+ }
38
+
39
+ export function buildSearchUrl(query) {
40
+ const normalized = cleanText(query);
41
+ if (!normalized) throw new ArgumentError('zepto search query cannot be empty');
42
+ return `${HOME_URL}/search?query=${encodeURIComponent(normalized)}`;
43
+ }
44
+
45
+ export function toZeptoUrl(value) {
46
+ const text = cleanText(value);
47
+ if (!text) return '';
48
+ try {
49
+ const url = new URL(text.startsWith('http') ? text : text.startsWith('/') ? text : `/${text}`, HOME_URL);
50
+ if (url.hostname !== DOMAIN && url.hostname !== 'zepto.com') throw new Error('not zepto');
51
+ url.hostname = DOMAIN;
52
+ url.hash = '';
53
+ return url.toString();
54
+ } catch {
55
+ return '';
56
+ }
57
+ }
58
+
59
+ export function resolveProductInput(input) {
60
+ const text = cleanText(input);
61
+ if (!text) throw new ArgumentError('zepto product requires a product URL');
62
+ const url = toZeptoUrl(text);
63
+ const match = url && new URL(url).pathname.match(/\/pvid\/([0-9a-f-]{36})(?:\/|$)/i);
64
+ if (!match) throw new ArgumentError('zepto product expects a Zepto /pn/.../pvid/<id> URL from search results');
65
+ return { productId: match[1], url };
66
+ }
67
+
68
+ export function normalizeProductRow(raw, rank) {
69
+ const url = toZeptoUrl(raw.url || raw.href || '');
70
+ const productId = cleanText(raw.product_id || raw.productId || raw.id || url.match(/\/pvid\/([0-9a-f-]{36})/i)?.[1] || '');
71
+ return {
72
+ rank: rank + 1,
73
+ product_id: productId,
74
+ title: cleanText(raw.title || raw.name),
75
+ brand: cleanText(raw.brand),
76
+ pack_size: cleanText(raw.pack_size || raw.packSize || raw.size),
77
+ price: parseMoney(raw.price),
78
+ mrp: parseMoney(raw.mrp || raw.original_price || raw.originalPrice),
79
+ availability: cleanText(raw.availability || raw.stock),
80
+ url,
81
+ };
82
+ }
83
+
84
+ function parsePersisted(raw) {
85
+ try {
86
+ const outer = typeof raw === 'string' ? JSON.parse(raw) : raw;
87
+ const value = typeof outer?.value === 'string' ? JSON.parse(outer.value) : outer;
88
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
89
+ } catch {
90
+ return {};
91
+ }
92
+ }
93
+
94
+ export function normalizeLocationState(raw = {}) {
95
+ const rawAddressText = cleanText(raw.addressText);
96
+ const addressText = /^select location$/i.test(rawAddressText) ? '' : rawAddressText;
97
+ const rawLabel = addressText.match(/^(Home|Work|Other)\b/i)?.[1] || '';
98
+ const label = rawLabel ? rawLabel[0].toUpperCase() + rawLabel.slice(1).toLowerCase() : '';
99
+ const publicAddressText = addressText
100
+ .replace(/^(Home|Work|Other)\s*[:-]?\s*/i, '')
101
+ .replace(/\b(?:house\s*number|floor|flat|apartment|door|building)\b\s*[-:#]?\s*[^,•\n]*/gi, '');
102
+ const parts = publicAddressText.split(/[,•\n]/).map(cleanText).filter(Boolean);
103
+ const position = raw.userPosition || raw.userGpsCoords || {};
104
+ const pincode = cleanText(raw.pincode || raw.pin || addressText.match(/\b\d{6}\b/)?.[0]);
105
+ const cityPart = parts.length > 1 ? (parts[parts.length - 1].toLowerCase() === 'india' && parts.length > 2 ? parts[parts.length - 2] : parts[parts.length - 1]) : cleanText(raw.city);
106
+ return {
107
+ selected: Boolean(addressText || position.latitude || position.longitude || raw.pincode),
108
+ label,
109
+ area: label ? '' : parts[0] || '',
110
+ city: cleanText(cityPart.replace(/\b\d{6}\b/g, '')),
111
+ pincode,
112
+ hasCoordinates: Boolean(position.latitude || position.longitude),
113
+ source: 'browser',
114
+ };
115
+ }
116
+
117
+ export async function safeGoto(page, url, label, options) {
118
+ await page.goto(url, options || ZEPTO_NAV_OPTIONS).catch((error) => {
119
+ const message = error?.message || String(error);
120
+ if (/net::ERR_ABORTED|Browser navigate command timed out/i.test(message)) return;
121
+ throw new CommandExecutionError(`${label} navigation failed: ${error?.message || error}`);
122
+ });
123
+ }
124
+
125
+ export function productCardsEvaluate(limit) {
126
+ return `
127
+ (() => {
128
+ const limit = ${Number(limit) || 20};
129
+ const clean = (value) => value == null ? '' : String(value).replace(/\\s+/g, ' ').trim();
130
+ const bodyText = clean(document.body?.innerText || document.body?.textContent || '');
131
+ if (/please login to continue searching|please login/i.test(bodyText) && location.pathname.includes('/search')) {
132
+ return { authRequired: true, rows: [], href: location.href };
133
+ }
134
+ const rows = [];
135
+ const seen = new Set();
136
+ const anchors = Array.from(document.querySelectorAll('a[href*="/pn/"][href*="/pvid/"]'));
137
+ for (const anchor of anchors) {
138
+ const href = anchor.href || anchor.getAttribute('href') || '';
139
+ const productId = href.match(/\\/pvid\\/([0-9a-f-]{36})/i)?.[1] || '';
140
+ if (!productId || seen.has(productId)) continue;
141
+ let root = anchor;
142
+ for (let i = 0; i < 6 && root?.parentElement; i += 1) {
143
+ const rootText = clean(root.innerText || root.textContent || '');
144
+ if (/₹|ADD|Add to Cart/i.test(rootText)) break;
145
+ root = root.parentElement;
146
+ }
147
+ const texts = Array.from(root.querySelectorAll('*'))
148
+ .filter((node) => node.children.length === 0)
149
+ .map((node) => clean(node.textContent))
150
+ .filter(Boolean);
151
+ const joined = texts.join(' ');
152
+ const prices = texts.filter((text) => /₹/.test(text));
153
+ const title = texts.find((text) => /[A-Za-z]/.test(text) && !/add|cart|off|bestseller|₹|\\d+(?:\\.\\d+)?\\s*(?:kg|g|ml|l|pcs?|pack)/i.test(text)) || clean(anchor.textContent);
154
+ if (!title) continue;
155
+ seen.add(productId);
156
+ rows.push({
157
+ product_id: productId,
158
+ title,
159
+ pack_size: texts.find((text) => /\\b\\d+(?:\\.\\d+)?\\s*(?:kg|g|ml|l|pcs?|pack)\\b/i.test(text)) || '',
160
+ price: prices[0] || '',
161
+ mrp: prices[1] || '',
162
+ availability: /out of stock|unavailable/i.test(joined) ? 'Out of stock' : '',
163
+ url: href,
164
+ });
165
+ if (rows.length >= limit) break;
166
+ }
167
+ return { rows, href: location.href };
168
+ })()
169
+ `;
170
+ }
171
+
172
+ export const CART_EVALUATE = `
173
+ (() => {
174
+ const clean = (value) => value == null ? '' : String(value).replace(/\\s+/g, ' ').trim();
175
+ const parse = (key) => {
176
+ try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch { return null; }
177
+ };
178
+ const cart = parse('cart')?.state || {};
179
+ const content = cart.cartContent || {};
180
+ const rows = Object.entries(content).map(([id, item]) => {
181
+ const product = item?.productVariant || item?.product || item || {};
182
+ const productId = clean(product.id || product.productVariantId || item?.productVariantId || id);
183
+ const price = Number(item?.superSaverSellingPrice ?? item?.discountedSellingPrice ?? item?.sellingPrice ?? product.price ?? product.discountedSellingPrice ?? product.sellingPrice);
184
+ const mrp = Number(item?.mrp ?? product.mrp ?? product.mrpPrice);
185
+ return {
186
+ product_id: productId,
187
+ title: clean(product.name || product.productName || item?.title || item?.name),
188
+ pack_size: clean(product.formattedPacksize || product.packsize || product.unitOfMeasure || item?.quantityText),
189
+ quantity: Number(item?.quantity || item?.qty || 0),
190
+ price: Number.isFinite(price) ? (price > 999 ? price / 100 : price) : null,
191
+ mrp: Number.isFinite(mrp) ? (mrp > 999 ? mrp / 100 : mrp) : null,
192
+ availability: clean(product.availability || ''),
193
+ };
194
+ }).filter((row) => row.product_id && row.title && row.quantity);
195
+ return { rows, href: location.href, cartId: cart.cartId || '' };
196
+ })()
197
+ `;
198
+
199
+ export function normalizeCartRows(result) {
200
+ return (result?.rows || []).map((row, index) => ({
201
+ rank: index + 1,
202
+ product_id: cleanText(row.product_id),
203
+ title: cleanText(row.title),
204
+ pack_size: cleanText(row.pack_size),
205
+ quantity: Number(row.quantity || 0),
206
+ price: parseMoney(row.price),
207
+ mrp: parseMoney(row.mrp),
208
+ availability: cleanText(row.availability),
209
+ })).filter((row) => row.product_id && row.title && row.quantity);
210
+ }
211
+
212
+ export function readLocationEvaluate() {
213
+ return `
214
+ (() => {
215
+ const clean = (value) => value == null ? '' : String(value).replace(/\\s+/g, ' ').trim();
216
+ const parse = (key) => {
217
+ try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch { return null; }
218
+ };
219
+ const persisted = parse('user-position');
220
+ const state = typeof persisted?.value === 'string' ? JSON.parse(persisted.value)?.state : persisted?.state;
221
+ return {
222
+ addressText: clean(document.querySelector('[data-testid="user-address"]')?.textContent || ''),
223
+ userPosition: state?.userPosition || null,
224
+ userGpsCoords: state?.userGpsCoords || null,
225
+ };
226
+ })()
227
+ `;
228
+ }
@@ -0,0 +1,238 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { JSDOM } from 'jsdom';
3
+ import { ArgumentError, AuthRequiredError } from '@agentrhq/webcmd/errors';
4
+ import { getRegistry } from '@agentrhq/webcmd/registry';
5
+ import './auth.js';
6
+ import './location.js';
7
+ import './search.js';
8
+ import './product.js';
9
+ import './add-to-cart.js';
10
+ import './cart.js';
11
+ import './checkout.js';
12
+ import './place-order.js';
13
+ import {
14
+ CART_EVALUATE,
15
+ buildSearchUrl,
16
+ normalizeLocationState,
17
+ normalizeProductRow,
18
+ parseLimitArg,
19
+ parseQuantityArg,
20
+ productCardsEvaluate,
21
+ resolveProductInput,
22
+ safeGoto,
23
+ } from './utils.js';
24
+
25
+ describe('zepto helpers', () => {
26
+ it('builds search URLs and validates numeric args', () => {
27
+ expect(buildSearchUrl('amul milk')).toBe('https://www.zepto.com/search?query=amul%20milk');
28
+ expect(() => buildSearchUrl(' ')).toThrow(ArgumentError);
29
+ expect(() => parseLimitArg(0, 20, 50)).toThrow(ArgumentError);
30
+ expect(() => parseLimitArg(51, 20, 50)).toThrow(ArgumentError);
31
+ expect(() => parseQuantityArg(0, 1, 12)).toThrow(ArgumentError);
32
+ });
33
+
34
+ it('parses product pvids and URLs', () => {
35
+ expect(resolveProductInput('https://www.zepto.com/pn/rin/pvid/5f54bb83-f3e0-4d8d-89b0-6339f3312089')).toMatchObject({
36
+ productId: '5f54bb83-f3e0-4d8d-89b0-6339f3312089',
37
+ url: 'https://www.zepto.com/pn/rin/pvid/5f54bb83-f3e0-4d8d-89b0-6339f3312089',
38
+ });
39
+ expect(() => resolveProductInput('5f54bb83-f3e0-4d8d-89b0-6339f3312089')).toThrow(ArgumentError);
40
+ });
41
+
42
+ it('normalizes product rows', () => {
43
+ expect(normalizeProductRow({
44
+ product_id: '5f54bb83-f3e0-4d8d-89b0-6339f3312089',
45
+ title: 'Rin Matic Top Load Detergent Liquid | Pouch',
46
+ pack_size: '1 pack (2 kg)',
47
+ price: '₹179',
48
+ mrp: '₹260',
49
+ url: '/pn/rin/pvid/5f54bb83-f3e0-4d8d-89b0-6339f3312089',
50
+ }, 0)).toMatchObject({
51
+ rank: 1,
52
+ product_id: '5f54bb83-f3e0-4d8d-89b0-6339f3312089',
53
+ price: 179,
54
+ mrp: 260,
55
+ url: 'https://www.zepto.com/pn/rin/pvid/5f54bb83-f3e0-4d8d-89b0-6339f3312089',
56
+ });
57
+ });
58
+
59
+ it('extracts rendered product cards', () => {
60
+ const dom = new JSDOM(`
61
+ <a href="/pn/rin/pvid/5f54bb83-f3e0-4d8d-89b0-6339f3312089">
62
+ <div>
63
+ <button>ADD</button>
64
+ <span>₹ 179</span><span>₹ 260</span>
65
+ <span>Rin Matic Top Load Detergent Liquid | Pouch</span>
66
+ <span>1 pack (2 kg)</span>
67
+ </div>
68
+ </a>
69
+ `, { runScripts: 'outside-only', url: 'https://www.zepto.com/search?query=rin' });
70
+
71
+ const result = dom.window.eval(productCardsEvaluate(5));
72
+
73
+ expect(result.rows[0]).toMatchObject({
74
+ product_id: '5f54bb83-f3e0-4d8d-89b0-6339f3312089',
75
+ title: 'Rin Matic Top Load Detergent Liquid | Pouch',
76
+ price: '₹ 179',
77
+ mrp: '₹ 260',
78
+ });
79
+ });
80
+
81
+ it('normalizes selected location without leaking exact coordinates', () => {
82
+ expect(normalizeLocationState({
83
+ addressText: 'Home: HSR Layout, Bengaluru',
84
+ userPosition: { latitude: 12.911, longitude: 77.629 },
85
+ })).toEqual({
86
+ selected: true,
87
+ label: 'Home',
88
+ area: '',
89
+ city: 'Bengaluru',
90
+ pincode: '',
91
+ hasCoordinates: true,
92
+ source: 'browser',
93
+ });
94
+ });
95
+
96
+ it('redacts exact house and floor details from location output', () => {
97
+ expect(normalizeLocationState({
98
+ addressText: 'home - House number - 5, Floor - 5th, HSR Layout, Bengaluru, India',
99
+ userPosition: { latitude: 12.911, longitude: 77.629 },
100
+ })).toEqual({
101
+ selected: true,
102
+ label: 'Home',
103
+ area: '',
104
+ city: 'Bengaluru',
105
+ pincode: '',
106
+ hasCoordinates: true,
107
+ source: 'browser',
108
+ });
109
+ });
110
+
111
+ it('does not treat the Select Location placeholder as selected', () => {
112
+ expect(normalizeLocationState({ addressText: 'Select Location' })).toEqual({
113
+ selected: false,
114
+ label: '',
115
+ area: '',
116
+ city: '',
117
+ pincode: '',
118
+ hasCoordinates: false,
119
+ source: 'browser',
120
+ });
121
+ });
122
+
123
+ it('tolerates Zepto SPA navigation aborts but keeps real navigation failures', async () => {
124
+ await expect(safeGoto({
125
+ goto: async () => { throw new Error('page.goto: net::ERR_ABORTED at https://www.zepto.com/'); },
126
+ }, 'https://www.zepto.com/', 'zepto cart')).resolves.toBeUndefined();
127
+
128
+ await expect(safeGoto({
129
+ goto: async () => { throw new Error('page.goto: net::ERR_NAME_NOT_RESOLVED'); },
130
+ }, 'https://www.zepto.com/', 'zepto cart')).rejects.toThrow('zepto cart navigation failed');
131
+ });
132
+
133
+ it('extracts cart items from Zepto cart storage without recommendations', () => {
134
+ const cart = {
135
+ state: {
136
+ cartContent: {
137
+ '5f54bb83-f3e0-4d8d-89b0-6339f3312089': {
138
+ quantity: 2,
139
+ productVariant: {
140
+ id: '5f54bb83-f3e0-4d8d-89b0-6339f3312089',
141
+ name: 'Rin Matic Top Load Detergent Liquid | Pouch',
142
+ formattedPacksize: '1 pack (2 kg)',
143
+ price: 17900,
144
+ mrp: 26000,
145
+ },
146
+ },
147
+ },
148
+ },
149
+ };
150
+ const dom = new JSDOM('', { runScripts: 'outside-only', url: 'https://www.zepto.com/' });
151
+ dom.window.localStorage.setItem('cart', JSON.stringify(cart));
152
+
153
+ const result = dom.window.eval(CART_EVALUATE);
154
+
155
+ expect(result.rows).toEqual([expect.objectContaining({
156
+ product_id: '5f54bb83-f3e0-4d8d-89b0-6339f3312089',
157
+ title: 'Rin Matic Top Load Detergent Liquid | Pouch',
158
+ quantity: 2,
159
+ price: 179,
160
+ })]);
161
+ });
162
+
163
+ it('extracts top-level Zepto cart item fields', () => {
164
+ const cart = {
165
+ state: {
166
+ cartContent: {
167
+ 'ba77f9b3-0525-4ce8-bc4b-a2480419b780': {
168
+ quantity: 1,
169
+ title: 'Godrej Jersey Toned Fresh Milk | Pouch',
170
+ productVariantId: 'ba77f9b3-0525-4ce8-bc4b-a2480419b780',
171
+ quantityText: '1 pack (490 ml or 500 ml)',
172
+ superSaverSellingPrice: 2600,
173
+ mrp: 2700,
174
+ },
175
+ },
176
+ },
177
+ };
178
+ const dom = new JSDOM('', { runScripts: 'outside-only', url: 'https://www.zepto.com/' });
179
+ dom.window.localStorage.setItem('cart', JSON.stringify(cart));
180
+
181
+ const result = dom.window.eval(CART_EVALUATE);
182
+
183
+ expect(result.rows).toEqual([expect.objectContaining({
184
+ product_id: 'ba77f9b3-0525-4ce8-bc4b-a2480419b780',
185
+ title: 'Godrej Jersey Toned Fresh Milk | Pouch',
186
+ pack_size: '1 pack (490 ml or 500 ml)',
187
+ quantity: 1,
188
+ price: 26,
189
+ mrp: 27,
190
+ })]);
191
+ });
192
+ });
193
+
194
+ describe('zepto registry shape', () => {
195
+ it('registers the buying-path commands', () => {
196
+ for (const name of ['login', 'location', 'search', 'product', 'add-to-cart', 'cart', 'checkout', 'place-order']) {
197
+ expect(getRegistry().get(`zepto/${name}`)).toBeDefined();
198
+ }
199
+ });
200
+
201
+ it('marks only cart-changing commands as write', () => {
202
+ expect(getRegistry().get('zepto/location').access).toBe('read');
203
+ expect(getRegistry().get('zepto/search').access).toBe('read');
204
+ expect(getRegistry().get('zepto/product').access).toBe('read');
205
+ expect(getRegistry().get('zepto/cart').access).toBe('read');
206
+ expect(getRegistry().get('zepto/login').access).toBe('write');
207
+ expect(getRegistry().get('zepto/add-to-cart').access).toBe('write');
208
+ expect(getRegistry().get('zepto/checkout').access).toBe('write');
209
+ expect(getRegistry().get('zepto/place-order').access).toBe('write');
210
+ });
211
+
212
+ it('lets commands own Zepto navigation instead of framework pre-navigation', () => {
213
+ for (const name of ['login', 'location', 'search', 'product', 'add-to-cart', 'cart', 'checkout', 'place-order']) {
214
+ expect(getRegistry().get(`zepto/${name}`).navigateBefore).toBe(false);
215
+ }
216
+ });
217
+ });
218
+
219
+ describe('zepto command behavior', () => {
220
+ it('search reports auth-required when Zepto blocks web search', async () => {
221
+ const command = getRegistry().get('zepto/search');
222
+ const calls = [];
223
+ const fakePage = {
224
+ goto: async (...args) => { calls.push(args); },
225
+ wait: async () => {},
226
+ evaluate: async () => ({ authRequired: true, rows: [] }),
227
+ };
228
+
229
+ await expect(command.func(fakePage, { query: 'milk' })).rejects.toThrow(AuthRequiredError);
230
+ expect(calls[0]).toEqual(['https://www.zepto.com/search?query=milk', { waitUntil: 'none', settleMs: 1500 }]);
231
+ });
232
+
233
+ it('keeps place-order no-op unless --confirm is passed', async () => {
234
+ const command = getRegistry().get('zepto/place-order');
235
+ const fakePage = { goto: () => { throw new Error('should not navigate'); } };
236
+ await expect(command.func(fakePage, {})).resolves.toMatchObject([{ status: 'no-op', confirmed: false }]);
237
+ });
238
+ });
@@ -23,8 +23,10 @@ export declare function waitForDaemonStatus(timeoutMs: number): Promise<DaemonSt
23
23
  export declare const daemonLifecycleHooks: {
24
24
  requestDaemonShutdown: typeof requestDaemonShutdown;
25
25
  spawnDaemonProcess: typeof spawnDaemonProcess;
26
+ signalDaemonProcessTree: typeof signalDaemonProcessTree;
26
27
  waitForDaemonStop: typeof waitForDaemonStop;
27
28
  };
29
+ export declare function signalDaemonProcessTree(pid: number, signal: NodeJS.Signals): boolean;
28
30
  export declare function restartDaemon(opts?: {
29
31
  stopTimeoutMs?: number;
30
32
  startTimeoutMs?: number;
@@ -53,8 +53,31 @@ export async function waitForDaemonStatus(timeoutMs) {
53
53
  export const daemonLifecycleHooks = {
54
54
  requestDaemonShutdown,
55
55
  spawnDaemonProcess,
56
+ signalDaemonProcessTree,
56
57
  waitForDaemonStop,
57
58
  };
59
+ export function signalDaemonProcessTree(pid, signal) {
60
+ let signaled = false;
61
+ if (process.platform !== 'win32') {
62
+ try {
63
+ process.kill(-pid, signal);
64
+ signaled = true;
65
+ }
66
+ catch {
67
+ // Fall back to the daemon PID below.
68
+ }
69
+ }
70
+ if (!signaled) {
71
+ try {
72
+ process.kill(pid, signal);
73
+ signaled = true;
74
+ }
75
+ catch {
76
+ // The process may have already exited; the caller polls the daemon port.
77
+ }
78
+ }
79
+ return signaled;
80
+ }
58
81
  export async function restartDaemon(opts = {}) {
59
82
  const previousStatus = await fetchDaemonStatus();
60
83
  let stopped = previousStatus === null;
@@ -91,13 +114,12 @@ export async function ensureBrowserBridgeReady(opts = {}) {
91
114
  if (!portReleased) {
92
115
  const stalePid = health.status?.pid;
93
116
  if (typeof stalePid === 'number' && Number.isInteger(stalePid) && stalePid > 0) {
94
- try {
95
- process.kill(stalePid, 'SIGKILL');
96
- }
97
- catch {
98
- // EPERM / ESRCH are both resolved by polling the fixed daemon port.
117
+ daemonLifecycleHooks.signalDaemonProcessTree(stalePid, 'SIGTERM');
118
+ portReleased = await daemonLifecycleHooks.waitForDaemonStop(1000);
119
+ if (!portReleased) {
120
+ daemonLifecycleHooks.signalDaemonProcessTree(stalePid, 'SIGKILL');
121
+ portReleased = await daemonLifecycleHooks.waitForDaemonStop(2000);
99
122
  }
100
- portReleased = await daemonLifecycleHooks.waitForDaemonStop(2000);
101
123
  }
102
124
  }
103
125
  if (!portReleased) {
@@ -3,6 +3,7 @@ import { launchPersistentContext as cloakLaunchPersistentContext } from 'cloakbr
3
3
  import type { BrowserSurface, SiteSessionMode } from '../../protocol.js';
4
4
  import { CloakNetworkCapture } from './network.js';
5
5
  export type LaunchPersistentContext = typeof cloakLaunchPersistentContext;
6
+ export type RecoverLockedProfile = (userDataDir: string) => Promise<boolean>;
6
7
  export interface SessionKeyInput {
7
8
  profileId?: string;
8
9
  session?: string;
@@ -33,13 +34,16 @@ export interface CloakTabInfo {
33
34
  export interface CloakSessionManagerOptions {
34
35
  baseDir?: string;
35
36
  launchPersistentContext?: LaunchPersistentContext;
37
+ recoverLockedProfile?: RecoverLockedProfile;
36
38
  }
37
39
  export declare function resolveLeaseKey(input: SessionKeyInput): string;
38
40
  export declare class CloakSessionManager {
39
41
  private readonly opts;
40
42
  readonly networkCapture: CloakNetworkCapture;
41
43
  private readonly launchPersistentContext;
44
+ private readonly recoverLockedProfile;
42
45
  private readonly profiles;
46
+ private readonly profileLaunches;
43
47
  constructor(opts?: CloakSessionManagerOptions);
44
48
  profileStatuses(): {
45
49
  contextId: string;
@@ -72,6 +76,7 @@ export declare class CloakSessionManager {
72
76
  release(input: SessionKeyInput): Promise<void>;
73
77
  shutdown(): Promise<void>;
74
78
  private getProfileRuntime;
79
+ private launchProfileRuntime;
75
80
  private openEntries;
76
81
  private findEntryByPageId;
77
82
  private refreshIdleTimer;