@agentrhq/webcmd 0.4.2 → 0.4.3

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 (59) hide show
  1. package/cli-manifest.json +755 -174
  2. package/clis/_shared/site-auth.js +0 -1
  3. package/clis/_shared/site-auth.test.js +0 -1
  4. package/clis/amazon-in/checkout.js +0 -1
  5. package/clis/blinkit/checkout.js +0 -1
  6. package/clis/blinkit/place-order.js +0 -1
  7. package/clis/chatgpt/model.js +2 -2
  8. package/clis/chatgpt/model.test.js +7 -1
  9. package/clis/chatgpt/utils.js +8 -0
  10. package/clis/chatgpt/utils.test.js +92 -1
  11. package/clis/district/checkout.js +0 -1
  12. package/clis/district/seats.js +0 -1
  13. package/clis/district/set-location.js +0 -1
  14. package/clis/district/showtimes.js +0 -1
  15. package/clis/google/images.js +456 -0
  16. package/clis/google/images.test.js +375 -0
  17. package/clis/instagram/user.js +5 -13
  18. package/clis/instagram/user.test.js +66 -0
  19. package/clis/mercury/check-login.js +0 -1
  20. package/clis/mercury/reimbursement-draft.js +0 -1
  21. package/clis/practo/book-confirm.js +0 -1
  22. package/clis/practo/cancel.js +0 -1
  23. package/clis/trip/attraction.js +74 -0
  24. package/clis/trip/car.js +74 -0
  25. package/clis/trip/deals.js +61 -0
  26. package/clis/trip/flight-round.js +88 -0
  27. package/clis/trip/flight.js +83 -0
  28. package/clis/trip/hotel-search.js +80 -0
  29. package/clis/trip/hotel.js +54 -0
  30. package/clis/trip/package.js +93 -0
  31. package/clis/trip/search.js +43 -0
  32. package/clis/trip/tour.js +84 -0
  33. package/clis/trip/train.js +76 -0
  34. package/clis/trip/transfer.js +82 -0
  35. package/clis/trip/trip.test.js +1420 -0
  36. package/clis/trip/utils.js +911 -0
  37. package/dist/src/build-manifest.js +0 -1
  38. package/dist/src/build-manifest.test.js +4 -0
  39. package/dist/src/cli.js +7 -7
  40. package/dist/src/cli.test.js +26 -10
  41. package/dist/src/command-presentation.js +1 -1
  42. package/dist/src/command-presentation.test.js +3 -0
  43. package/dist/src/command-surface.js +1 -1
  44. package/dist/src/command-surface.test.js +4 -0
  45. package/dist/src/commands/auth.js +0 -2
  46. package/dist/src/commands/auth.test.js +0 -2
  47. package/dist/src/discovery.js +0 -1
  48. package/dist/src/execution.js +3 -3
  49. package/dist/src/execution.test.js +68 -15
  50. package/dist/src/hosted/browser-args.js +2 -2
  51. package/dist/src/hosted/browser-args.test.js +10 -0
  52. package/dist/src/hosted/client.js +2 -2
  53. package/dist/src/manifest-types.d.ts +0 -2
  54. package/dist/src/registry.d.ts +0 -2
  55. package/dist/src/registry.js +0 -1
  56. package/hosted-contract.json +767 -3
  57. package/package.json +2 -2
  58. package/skills/webcmd-browser/SKILL.md +2 -1
  59. package/skills/webcmd-usage/SKILL.md +1 -1
@@ -0,0 +1,1420 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { JSDOM } from 'jsdom';
3
+ import { getRegistry } from '@agentrhq/webcmd/registry';
4
+ import './flight.js';
5
+ import './flight-round.js';
6
+ import './hotel-search.js';
7
+ import './hotel.js';
8
+ import './attraction.js';
9
+ import './train.js';
10
+ import './car.js';
11
+ import './transfer.js';
12
+ import './tour.js';
13
+ import './search.js';
14
+ import './package.js';
15
+ import './deals.js';
16
+ import {
17
+ WAIT_FOR_ATTRACTIONS_JS,
18
+ WAIT_FOR_CARS_JS,
19
+ WAIT_FOR_DEALS_JS,
20
+ WAIT_FOR_HOTEL_DETAIL_JS,
21
+ WAIT_FOR_TRAINS_JS,
22
+ WAIT_FOR_TRANSFERS_JS,
23
+ buildAttractionExtractJs,
24
+ buildAttractionSearchUrl,
25
+ buildCarExtractJs,
26
+ buildCarListUrl,
27
+ buildDealsExtractJs,
28
+ buildDealsUrl,
29
+ buildFlightExtractJs,
30
+ buildFlightRoundSearchUrl,
31
+ buildFlightSearchUrl,
32
+ buildHotelDetailExtractJs,
33
+ buildHotelDetailUrl,
34
+ buildHotelExtractJs,
35
+ buildHotelSearchUrl,
36
+ buildTourSearchJs,
37
+ buildTourSearchUrl,
38
+ buildTrainExtractJs,
39
+ buildTrainRouteUrl,
40
+ buildTransferExtractJs,
41
+ buildTransferListUrl,
42
+ fetchPoiSearch,
43
+ flattenPoiResults,
44
+ mapPackageRow,
45
+ mapSearchRow,
46
+ parseCityId,
47
+ parseHotelId,
48
+ parseIataCode,
49
+ parseIsoDate,
50
+ parseKeyword,
51
+ parseListLimit,
52
+ resolvePackageCity,
53
+ } from './utils.js';
54
+
55
+ function createPageMock(evaluateResults) {
56
+ const evaluate = vi.fn();
57
+ for (const result of evaluateResults) {
58
+ evaluate.mockResolvedValueOnce(result);
59
+ }
60
+ return {
61
+ goto: vi.fn().mockResolvedValue(undefined),
62
+ evaluate,
63
+ wait: vi.fn().mockResolvedValue(undefined),
64
+ };
65
+ }
66
+
67
+ describe('trip parseIataCode', () => {
68
+ it('uppercases valid 3-letter codes', () => {
69
+ expect(parseIataCode('from', 'lon')).toBe('LON');
70
+ expect(parseIataCode('to', 'JFK')).toBe('JFK');
71
+ });
72
+ it('rejects empty / malformed codes', () => {
73
+ expect(() => parseIataCode('from', '')).toThrow('required');
74
+ expect(() => parseIataCode('from', 'LO')).toThrow('3-letter IATA');
75
+ expect(() => parseIataCode('from', 'LOND')).toThrow('3-letter IATA');
76
+ });
77
+ });
78
+
79
+ describe('trip parseIsoDate', () => {
80
+ it('accepts real calendar dates', () => {
81
+ expect(parseIsoDate('date', '2026-08-15')).toBe('2026-08-15');
82
+ });
83
+ it('rejects malformed / impossible dates', () => {
84
+ expect(() => parseIsoDate('date', '08/15')).toThrow('YYYY-MM-DD');
85
+ expect(() => parseIsoDate('date', '2026-02-30')).toThrow('not a real calendar date');
86
+ expect(() => parseIsoDate('date', '')).toThrow('required');
87
+ });
88
+ });
89
+
90
+ describe('trip parseListLimit', () => {
91
+ it('falls back for empty / undefined', () => {
92
+ expect(parseListLimit(undefined)).toBe(20);
93
+ expect(parseListLimit('')).toBe(20);
94
+ expect(parseListLimit(undefined, 5)).toBe(5);
95
+ });
96
+ it('rejects out-of-range / non-integer (no silent clamp)', () => {
97
+ expect(() => parseListLimit(0)).toThrow('--limit');
98
+ expect(() => parseListLimit(51)).toThrow('--limit');
99
+ expect(() => parseListLimit('abc')).toThrow('--limit');
100
+ });
101
+ });
102
+
103
+ describe('trip buildFlightSearchUrl', () => {
104
+ it('lowercases codes and pins one-way English/USD params', () => {
105
+ const url = buildFlightSearchUrl('LON', 'NYC', '2026-08-15');
106
+ const qs = new URL(url).searchParams;
107
+ expect(url).toContain('https://www.trip.com/flights/showfarefirst?');
108
+ expect(qs.get('dcity')).toBe('lon');
109
+ expect(qs.get('acity')).toBe('nyc');
110
+ expect(qs.get('ddate')).toBe('2026-08-15');
111
+ expect(qs.get('triptype')).toBe('ow');
112
+ expect(qs.get('locale')).toBe('en_US');
113
+ expect(qs.get('curr')).toBe('USD');
114
+ });
115
+ });
116
+
117
+ describe('trip flight command (registry-level)', () => {
118
+ const cmd = getRegistry().get('trip/flight');
119
+
120
+ const FLIGHT_RAW = {
121
+ airline: 'Norse Atlantic Airways',
122
+ departureTime: '1:05 PM',
123
+ departureAirport: 'LGW',
124
+ arrivalTime: '3:55 PM',
125
+ arrivalAirport: 'JFK',
126
+ duration: '7h 50m',
127
+ stops: 'Nonstop',
128
+ price: 662,
129
+ currency: 'USD',
130
+ };
131
+
132
+ it('declares Strategy.COOKIE + browser:true + navigateBefore:false + access:read', () => {
133
+ expect(cmd.access).toBe('read');
134
+ expect(cmd.browser).toBe(true);
135
+ expect(String(cmd.strategy)).toContain('cookie');
136
+ expect(cmd.navigateBefore).toBe(false);
137
+ expect(cmd.domain).toBe('trip.com');
138
+ });
139
+
140
+ it('rejects invalid IATA / date / from==to / limit before navigation', async () => {
141
+ const page = createPageMock([]);
142
+ await expect(cmd.func(page, { from: 'LO', to: 'NYC', date: '2026-08-15', limit: 5 }))
143
+ .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('IATA') });
144
+ await expect(cmd.func(page, { from: 'LON', to: 'LON', date: '2026-08-15', limit: 5 }))
145
+ .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('must differ') });
146
+ await expect(cmd.func(page, { from: 'LON', to: 'NYC', date: '08/15', limit: 5 }))
147
+ .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--date') });
148
+ await expect(cmd.func(page, { from: 'LON', to: 'NYC', date: '2026-08-15', limit: 0 }))
149
+ .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--limit') });
150
+ expect(page.goto).not.toHaveBeenCalled();
151
+ });
152
+
153
+ it('throws AuthRequired when a verification gate is detected', async () => {
154
+ const page = createPageMock(['captcha']);
155
+ await expect(cmd.func(page, { from: 'LON', to: 'NYC', date: '2026-08-15', limit: 5 }))
156
+ .rejects.toThrow('Trip.com is asking for a verification');
157
+ expect(page.evaluate).toHaveBeenCalledTimes(1);
158
+ });
159
+
160
+ it('throws CommandExecutionError on render timeout and on malformed extraction', async () => {
161
+ await expect(cmd.func(createPageMock(['timeout']), { from: 'LON', to: 'NYC', date: '2026-08-15', limit: 5 }))
162
+ .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('did not render flight cards') });
163
+ await expect(cmd.func(createPageMock(['content', { rows: [] }]), { from: 'LON', to: 'NYC', date: '2026-08-15', limit: 5 }))
164
+ .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('malformed rows') });
165
+ });
166
+
167
+ it('throws EmptyResultError when extraction returns no flights', async () => {
168
+ await expect(cmd.func(createPageMock(['content', []]), { from: 'LON', to: 'NYC', date: '2026-08-15', limit: 5 }))
169
+ .rejects.toMatchObject({ code: 'EMPTY_RESULT' });
170
+ });
171
+
172
+ it('maps DOM-extracted rows and respects --limit', async () => {
173
+ const page = createPageMock(['content', [FLIGHT_RAW, { ...FLIGHT_RAW, airline: 'Jetblue Airways', price: 837 }]]);
174
+ const rows = await cmd.func(page, { from: 'LON', to: 'NYC', date: '2026-08-15', limit: 1 });
175
+ expect(rows).toHaveLength(1);
176
+ expect(rows[0]).toMatchObject({
177
+ rank: 1,
178
+ airline: 'Norse Atlantic Airways',
179
+ departureTime: '1:05 PM',
180
+ departureAirport: 'LGW',
181
+ arrivalTime: '3:55 PM',
182
+ arrivalAirport: 'JFK',
183
+ price: 662,
184
+ currency: 'USD',
185
+ });
186
+ for (const row of rows) {
187
+ for (const col of cmd.columns) expect(row).toHaveProperty(col);
188
+ }
189
+ });
190
+ });
191
+
192
+ describe('trip buildFlightExtractJs (JSDOM)', () => {
193
+ function runExtract(html) {
194
+ const dom = new JSDOM(`<!doctype html><html><body>${html}</body></html>`, { url: 'https://www.trip.com/' });
195
+ const js = buildFlightExtractJs();
196
+ return Function('document', `return (${js})`)(dom.window.document);
197
+ }
198
+
199
+ const CARD = `
200
+ <div class="result-item">
201
+ <div data-testid="flights-name">Norse Atlantic Airways</div>
202
+ <div class="font-black_x">LGW</div>
203
+ <div class="font-black_x">JFK</div>
204
+ <span>1:05</span><span>PM</span>
205
+ <span>3:55</span><span>PM</span>
206
+ <span>7h 50m</span>
207
+ <div data-testid="stopInfoText">Nonstop</div>
208
+ <div data-testid="flight_price_1-0">$662</div>
209
+ </div>`;
210
+
211
+ it('extracts a flight card via data-testid + time/code anchors', () => {
212
+ expect(runExtract(CARD)).toEqual([{
213
+ airline: 'Norse Atlantic Airways',
214
+ departureTime: '1:05 PM',
215
+ departureAirport: 'LGW',
216
+ arrivalTime: '3:55 PM',
217
+ arrivalAirport: 'JFK',
218
+ duration: '7h 50m',
219
+ stops: 'Nonstop',
220
+ price: 662,
221
+ currency: 'USD',
222
+ }]);
223
+ });
224
+
225
+ it('keeps price null when the price node is missing/non-numeric', () => {
226
+ const noPrice = CARD.replace('<div data-testid="flight_price_1-0">$662</div>', '<div data-testid="flight_price_1-0">--</div>');
227
+ expect(runExtract(noPrice)[0].price).toBeNull();
228
+ });
229
+
230
+ it('drops cards missing airline or an airport (no sentinel rows)', () => {
231
+ const noAirline = CARD.replace('<div data-testid="flights-name">Norse Atlantic Airways</div>', '');
232
+ expect(runExtract(noAirline)).toEqual([]);
233
+ expect(runExtract('<div class="result-item"></div>')).toEqual([]);
234
+ });
235
+ });
236
+
237
+ describe('trip parseCityId', () => {
238
+ it('accepts numeric city ids', () => {
239
+ expect(parseCityId('city', '338')).toBe('338');
240
+ expect(parseCityId('city', 338)).toBe('338');
241
+ });
242
+ it('rejects empty / non-numeric ids', () => {
243
+ expect(() => parseCityId('city', '')).toThrow('required');
244
+ expect(() => parseCityId('city', 'London')).toThrow('numeric');
245
+ });
246
+ });
247
+
248
+ describe('trip buildHotelSearchUrl', () => {
249
+ it('pins city / dates / English / USD params', () => {
250
+ const url = buildHotelSearchUrl('338', '2026-08-15', '2026-08-16');
251
+ const qs = new URL(url).searchParams;
252
+ expect(url).toContain('https://www.trip.com/hotels/list?');
253
+ expect(qs.get('city')).toBe('338');
254
+ expect(qs.get('checkin')).toBe('2026-08-15');
255
+ expect(qs.get('checkout')).toBe('2026-08-16');
256
+ expect(qs.get('locale')).toBe('en_US');
257
+ expect(qs.get('curr')).toBe('USD');
258
+ });
259
+ });
260
+
261
+ describe('trip hotel-search command (registry-level)', () => {
262
+ const cmd = getRegistry().get('trip/hotel-search');
263
+
264
+ const HOTEL_RAW = {
265
+ name: 'Royal National Hotel',
266
+ score: 8.2,
267
+ reviewLabel: 'Very good',
268
+ reviews: 2918,
269
+ location: 'Bloomsbury, Near The British Museum',
270
+ room: 'Standard Plus Twin Room',
271
+ price: 205,
272
+ currency: 'USD',
273
+ };
274
+
275
+ it('declares Strategy.COOKIE + browser:true + access:read', () => {
276
+ expect(cmd.access).toBe('read');
277
+ expect(cmd.browser).toBe(true);
278
+ expect(String(cmd.strategy)).toContain('cookie');
279
+ expect(cmd.domain).toBe('trip.com');
280
+ });
281
+
282
+ it('rejects invalid city / dates / limit before navigation', async () => {
283
+ const page = createPageMock([]);
284
+ await expect(cmd.func(page, { city: 'London', checkin: '2026-08-15', checkout: '2026-08-16', limit: 5 }))
285
+ .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('numeric') });
286
+ await expect(cmd.func(page, { city: '338', checkin: '08/15', checkout: '2026-08-16', limit: 5 }))
287
+ .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--checkin') });
288
+ await expect(cmd.func(page, { city: '338', checkin: '2026-08-16', checkout: '2026-08-15', limit: 5 }))
289
+ .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('before --checkout') });
290
+ await expect(cmd.func(page, { city: '338', checkin: '2026-08-15', checkout: '2026-08-16', limit: 0 }))
291
+ .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--limit') });
292
+ expect(page.goto).not.toHaveBeenCalled();
293
+ });
294
+
295
+ it('throws AuthRequired on verification, CommandExec on timeout, EmptyResult on no hotels', async () => {
296
+ await expect(cmd.func(createPageMock(['captcha']), { city: '338', checkin: '2026-08-15', checkout: '2026-08-16', limit: 5 }))
297
+ .rejects.toThrow('Trip.com is asking for a verification');
298
+ await expect(cmd.func(createPageMock(['timeout']), { city: '338', checkin: '2026-08-15', checkout: '2026-08-16', limit: 5 }))
299
+ .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('did not render hotel cards') });
300
+ await expect(cmd.func(createPageMock(['content', []]), { city: '338', checkin: '2026-08-15', checkout: '2026-08-16', limit: 5 }))
301
+ .rejects.toMatchObject({ code: 'EMPTY_RESULT' });
302
+ });
303
+
304
+ it('maps DOM-extracted rows and respects --limit', async () => {
305
+ const page = createPageMock(['content', [HOTEL_RAW, { ...HOTEL_RAW, name: 'LSE Rosebery Hall', price: 116 }]]);
306
+ const rows = await cmd.func(page, { city: '338', checkin: '2026-08-15', checkout: '2026-08-16', limit: 1 });
307
+ expect(rows).toHaveLength(1);
308
+ expect(rows[0]).toMatchObject({ rank: 1, name: 'Royal National Hotel', score: 8.2, reviews: 2918, price: 205, currency: 'USD' });
309
+ for (const row of rows) {
310
+ for (const col of cmd.columns) expect(row).toHaveProperty(col);
311
+ }
312
+ });
313
+ });
314
+
315
+ describe('trip buildHotelExtractJs (JSDOM)', () => {
316
+ function runExtract(html) {
317
+ const dom = new JSDOM(`<!doctype html><html><body>${html}</body></html>`, { url: 'https://www.trip.com/' });
318
+ const js = buildHotelExtractJs();
319
+ return Function('document', `return (${js})`)(dom.window.document);
320
+ }
321
+
322
+ const CARD = `
323
+ <div class="hotel-card">
324
+ <div class="hotelName">Royal National Hotel</div>
325
+ <div class="score">8.2</div>
326
+ <div class="comment-desc">Very good</div>
327
+ <div class="comment-num">2,918 reviews</div>
328
+ <div class="position-desc">Bloomsbury</div>
329
+ <div class="position-desc">Near The British Museum</div>
330
+ <div class="room-name">Standard Plus Twin Room</div>
331
+ <div class="price-highlight">$205</div>
332
+ </div>`;
333
+
334
+ it('extracts a hotel card with numeric score / reviews / price', () => {
335
+ expect(runExtract(CARD)).toEqual([{
336
+ name: 'Royal National Hotel',
337
+ score: 8.2,
338
+ reviewLabel: 'Very good',
339
+ reviews: 2918,
340
+ location: 'Bloomsbury, Near The British Museum',
341
+ room: 'Standard Plus Twin Room',
342
+ price: 205,
343
+ currency: 'USD',
344
+ }]);
345
+ });
346
+
347
+ it('keeps price null when non-numeric and drops cards without a name', () => {
348
+ const noPrice = CARD.replace('<div class="price-highlight">$205</div>', '<div class="price-highlight">Sold out</div>');
349
+ expect(runExtract(noPrice)[0].price).toBeNull();
350
+ const noName = CARD.replace('<div class="hotelName">Royal National Hotel</div>', '');
351
+ expect(runExtract(noName)).toEqual([]);
352
+ });
353
+ });
354
+
355
+ const HOTEL_DETAIL_SSR = {
356
+ hotelBaseInfo: {
357
+ masterHotelId: 715233,
358
+ cityName: 'London',
359
+ nameInfo: { name: 'LSE Rosebery Hall', nameEn: '' },
360
+ starInfo: { level: 2 },
361
+ },
362
+ hotelPositionInfo: { address: '90 Rosebery Ave, Islington, London, EC1R 4TY, United Kingdom', lat: '51.527561', lng: '-0.107065' },
363
+ hotelComment: {
364
+ comment: {
365
+ score: '8.3',
366
+ scoreDescription: 'Very good',
367
+ totalComment: 159,
368
+ scoreDetail: [
369
+ { showName: 'Cleanliness', showScore: '8.7' },
370
+ { showName: 'Amenities', showScore: '7.7' },
371
+ { showName: 'Location', showScore: '8.5' },
372
+ { showName: 'Service', showScore: '8.3' },
373
+ ],
374
+ },
375
+ },
376
+ hotelFacilityPopV2: {
377
+ hotelPopularFacility: {
378
+ list: [
379
+ { facilityDesc: 'Luggage storage' },
380
+ { facilityDesc: 'Wi-Fi in public areas' },
381
+ ],
382
+ },
383
+ },
384
+ hotelPolicyInfo: {
385
+ checkInAndOut: {
386
+ content: [
387
+ { title: 'Check-in: ', description: 'After 15:00' },
388
+ { title: 'Check-out: ', description: 'Before 10:30' },
389
+ { description: 'Front desk hours: 24/7' },
390
+ ],
391
+ },
392
+ },
393
+ };
394
+
395
+ // Shape as projected by buildHotelDetailExtractJs (what page.evaluate returns).
396
+ const HOTEL_DETAIL_ROW = {
397
+ hotelId: '715233',
398
+ name: 'LSE Rosebery Hall',
399
+ enName: null,
400
+ star: 2,
401
+ score: 8.3,
402
+ scoreLabel: 'Very good',
403
+ reviewCount: 159,
404
+ ratingBreakdown: 'Cleanliness 8.7 / Amenities 7.7 / Location 8.5 / Service 8.3',
405
+ facilities: 'Luggage storage / Wi-Fi in public areas',
406
+ checkInOut: 'Check-in: After 15:00 / Check-out: Before 10:30 / Front desk hours: 24/7',
407
+ cityName: 'London',
408
+ address: '90 Rosebery Ave, Islington, London, EC1R 4TY, United Kingdom',
409
+ lat: 51.527561,
410
+ lon: -0.107065,
411
+ };
412
+
413
+ describe('trip parseHotelId', () => {
414
+ it('accepts a numeric id as string', () => {
415
+ expect(parseHotelId('id', '715233')).toBe('715233');
416
+ });
417
+ it('rejects blank / non-numeric ids', () => {
418
+ expect(() => parseHotelId('id', '')).toThrow('required');
419
+ expect(() => parseHotelId('id', 'abc')).toThrow('numeric Trip.com hotel id');
420
+ });
421
+ });
422
+
423
+ describe('trip buildHotelDetailUrl', () => {
424
+ it('builds the detail URL with the hotel id', () => {
425
+ const url = buildHotelDetailUrl('715233');
426
+ expect(url.startsWith('https://www.trip.com/hotels/detail/?')).toBe(true);
427
+ expect(url).toContain('hotelId=715233');
428
+ expect(url).toContain('curr=USD');
429
+ });
430
+ });
431
+
432
+ describe('trip hotel command (registry-level)', () => {
433
+ const cmd = getRegistry().get('trip/hotel');
434
+
435
+ it('declares Strategy.COOKIE + browser:true + access:read', () => {
436
+ expect(cmd.access).toBe('read');
437
+ expect(cmd.browser).toBe(true);
438
+ expect(String(cmd.strategy)).toContain('cookie');
439
+ expect(cmd.domain).toBe('trip.com');
440
+ });
441
+
442
+ it('rejects a non-numeric id before navigation', async () => {
443
+ const page = createPageMock([]);
444
+ await expect(cmd.func(page, { id: 'shoreditch' }))
445
+ .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('numeric Trip.com hotel id') });
446
+ expect(page.goto).not.toHaveBeenCalled();
447
+ });
448
+
449
+ it('throws AuthRequired on verification, CommandExec on timeout / malformed, EmptyResult on no profile', async () => {
450
+ await expect(cmd.func(createPageMock(['captcha']), { id: '715233' }))
451
+ .rejects.toThrow('Trip.com is asking for a verification');
452
+ await expect(cmd.func(createPageMock(['timeout']), { id: '715233' }))
453
+ .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('did not expose SSR hotel data') });
454
+ await expect(cmd.func(createPageMock(['content', null]), { id: '715233' }))
455
+ .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('malformed data') });
456
+ await expect(cmd.func(createPageMock(['content', { hotelId: null, name: null }]), { id: '715233' }))
457
+ .rejects.toMatchObject({ code: 'EMPTY_RESULT' });
458
+ });
459
+
460
+ it('maps the SSR profile into a single row carrying every declared column', async () => {
461
+ const page = createPageMock(['content', HOTEL_DETAIL_ROW]);
462
+ const rows = await cmd.func(page, { id: '715233' });
463
+ expect(rows).toHaveLength(1);
464
+ expect(rows[0]).toMatchObject({
465
+ hotelId: '715233',
466
+ name: 'LSE Rosebery Hall',
467
+ star: 2,
468
+ score: 8.3,
469
+ ratingBreakdown: 'Cleanliness 8.7 / Amenities 7.7 / Location 8.5 / Service 8.3',
470
+ facilities: 'Luggage storage / Wi-Fi in public areas',
471
+ url: expect.stringContaining('hotelId=715233'),
472
+ });
473
+ for (const row of rows) {
474
+ for (const col of cmd.columns) expect(row).toHaveProperty(col);
475
+ }
476
+ expect(page.goto).toHaveBeenCalledTimes(1);
477
+ });
478
+ });
479
+
480
+ describe('trip buildHotelDetailExtractJs (JSDOM)', () => {
481
+ function runExtract(nextData) {
482
+ const dom = new JSDOM('<!doctype html><html><body></body></html>', {
483
+ url: 'https://www.trip.com/hotels/detail/',
484
+ runScripts: 'outside-only',
485
+ });
486
+ dom.window.__NEXT_DATA__ = nextData;
487
+ return dom.window.Function(`return (${buildHotelDetailExtractJs()})`)();
488
+ }
489
+
490
+ it('projects the hotel profile, joining sub-scores / amenities / policy', () => {
491
+ const out = runExtract({ props: { pageProps: { hotelDetailResponse: HOTEL_DETAIL_SSR } } });
492
+ expect(out).toEqual(HOTEL_DETAIL_ROW);
493
+ });
494
+
495
+ it('returns null when the SSR detail block is absent', () => {
496
+ expect(runExtract({ props: { pageProps: {} } })).toBeNull();
497
+ });
498
+
499
+ it('detects the rendered SSR block as content via WAIT_FOR_HOTEL_DETAIL_JS', async () => {
500
+ const dom = new JSDOM('<!doctype html><html><body></body></html>', {
501
+ url: 'https://www.trip.com/hotels/detail/',
502
+ runScripts: 'outside-only',
503
+ });
504
+ dom.window.__NEXT_DATA__ = { props: { pageProps: { hotelDetailResponse: HOTEL_DETAIL_SSR } } };
505
+ await expect(dom.window.Function(`return (${WAIT_FOR_HOTEL_DETAIL_JS})`)())
506
+ .resolves.toBe('content');
507
+ });
508
+ });
509
+
510
+ describe('trip buildFlightRoundSearchUrl', () => {
511
+ it('lowercases codes and pins round-trip English/USD params', () => {
512
+ const url = buildFlightRoundSearchUrl('LON', 'NYC', '2026-08-15', '2026-08-22');
513
+ const qs = new URL(url).searchParams;
514
+ expect(url).toContain('https://www.trip.com/flights/showfarefirst?');
515
+ expect(qs.get('dcity')).toBe('lon');
516
+ expect(qs.get('acity')).toBe('nyc');
517
+ expect(qs.get('ddate')).toBe('2026-08-15');
518
+ expect(qs.get('rdate')).toBe('2026-08-22');
519
+ expect(qs.get('triptype')).toBe('rt');
520
+ expect(qs.get('curr')).toBe('USD');
521
+ });
522
+ });
523
+
524
+ describe('trip flight-round command (registry-level)', () => {
525
+ const cmd = getRegistry().get('trip/flight-round');
526
+
527
+ const FLIGHT_RAW = {
528
+ airline: 'British Airways',
529
+ departureTime: '6:05 PM',
530
+ departureAirport: 'LHR',
531
+ arrivalTime: '9:05 PM',
532
+ arrivalAirport: 'JFK',
533
+ duration: '8h',
534
+ stops: 'Nonstop',
535
+ price: 758,
536
+ currency: 'USD',
537
+ };
538
+
539
+ it('declares Strategy.COOKIE + browser:true + navigateBefore:false + access:read', () => {
540
+ expect(cmd.access).toBe('read');
541
+ expect(cmd.browser).toBe(true);
542
+ expect(String(cmd.strategy)).toContain('cookie');
543
+ expect(cmd.navigateBefore).toBe(false);
544
+ expect(cmd.domain).toBe('trip.com');
545
+ });
546
+
547
+ it('rejects invalid IATA / dates / from==to / depart>=return / limit before navigation', async () => {
548
+ const page = createPageMock([]);
549
+ await expect(cmd.func(page, { from: 'LO', to: 'NYC', depart: '2026-08-15', return: '2026-08-22', limit: 5 }))
550
+ .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('IATA') });
551
+ await expect(cmd.func(page, { from: 'LON', to: 'LON', depart: '2026-08-15', return: '2026-08-22', limit: 5 }))
552
+ .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('must differ') });
553
+ await expect(cmd.func(page, { from: 'LON', to: 'NYC', depart: '08/15', return: '2026-08-22', limit: 5 }))
554
+ .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--depart') });
555
+ await expect(cmd.func(page, { from: 'LON', to: 'NYC', depart: '2026-08-22', return: '2026-08-15', limit: 5 }))
556
+ .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--depart must be before --return') });
557
+ await expect(cmd.func(page, { from: 'LON', to: 'NYC', depart: '2026-08-15', return: '2026-08-22', limit: 0 }))
558
+ .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--limit') });
559
+ expect(page.goto).not.toHaveBeenCalled();
560
+ });
561
+
562
+ it('throws AuthRequired on verification, CommandExec on timeout, EmptyResult on no flights', async () => {
563
+ await expect(cmd.func(createPageMock(['captcha']), { from: 'LON', to: 'NYC', depart: '2026-08-15', return: '2026-08-22', limit: 5 }))
564
+ .rejects.toThrow('Trip.com is asking for a verification');
565
+ await expect(cmd.func(createPageMock(['timeout']), { from: 'LON', to: 'NYC', depart: '2026-08-15', return: '2026-08-22', limit: 5 }))
566
+ .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('did not render flight cards') });
567
+ await expect(cmd.func(createPageMock(['content', []]), { from: 'LON', to: 'NYC', depart: '2026-08-15', return: '2026-08-22', limit: 5 }))
568
+ .rejects.toMatchObject({ code: 'EMPTY_RESULT' });
569
+ });
570
+
571
+ it('maps DOM-extracted rows against the round-trip URL and respects --limit', async () => {
572
+ const page = createPageMock(['content', [FLIGHT_RAW, { ...FLIGHT_RAW, airline: 'American Airlines', price: 767 }]]);
573
+ const rows = await cmd.func(page, { from: 'LON', to: 'NYC', depart: '2026-08-15', return: '2026-08-22', limit: 1 });
574
+ expect(rows).toHaveLength(1);
575
+ expect(rows[0]).toMatchObject({ rank: 1, airline: 'British Airways', departureAirport: 'LHR', price: 758, currency: 'USD' });
576
+ for (const row of rows) {
577
+ for (const col of cmd.columns) expect(row).toHaveProperty(col);
578
+ }
579
+ expect(page.goto).toHaveBeenCalledTimes(1);
580
+ expect(page.goto.mock.calls[0][0]).toContain('triptype=rt');
581
+ });
582
+ });
583
+
584
+ const ATTR_CARD = `
585
+ <div>
586
+ <a href="https://www.trip.com/things-to-do/detail/24465457/">Tokyo Metro 24/48/72 Hour Pass</a>
587
+ <span>4.8 /5</span> <span>4.9k reviews</span> <span>109.5k booked</span>
588
+ <span>Up to $3 off</span> <span>$5.54</span> <span>$6.16</span>
589
+ </div>`;
590
+
591
+ const ATTR_RAW = {
592
+ name: 'Tokyo Metro 24/48/72 Hour Pass',
593
+ rating: 4.8,
594
+ reviews: 4900,
595
+ booked: 109500,
596
+ price: 5.54,
597
+ url: 'https://www.trip.com/things-to-do/detail/24465457/',
598
+ };
599
+
600
+ describe('trip parseKeyword', () => {
601
+ it('accepts a non-empty keyword', () => {
602
+ expect(parseKeyword('query', 'Tokyo')).toBe('Tokyo');
603
+ expect(parseKeyword('query', ' Paris ')).toBe('Paris');
604
+ });
605
+ it('rejects blank / over-long keywords', () => {
606
+ expect(() => parseKeyword('query', '')).toThrow('required');
607
+ expect(() => parseKeyword('query', 'x'.repeat(61))).toThrow('too long');
608
+ });
609
+ });
610
+
611
+ describe('trip buildAttractionSearchUrl', () => {
612
+ it('builds the things-to-do search URL with the keyword', () => {
613
+ const url = buildAttractionSearchUrl('Tokyo');
614
+ expect(url.startsWith('https://www.trip.com/things-to-do/list?')).toBe(true);
615
+ expect(url).toContain('keyword=Tokyo');
616
+ expect(url).toContain('curr=USD');
617
+ });
618
+ });
619
+
620
+ describe('trip attraction command (registry-level)', () => {
621
+ const cmd = getRegistry().get('trip/attraction');
622
+
623
+ it('declares Strategy.COOKIE + browser:true + access:read', () => {
624
+ expect(cmd.access).toBe('read');
625
+ expect(cmd.browser).toBe(true);
626
+ expect(String(cmd.strategy)).toContain('cookie');
627
+ expect(cmd.domain).toBe('trip.com');
628
+ });
629
+
630
+ it('rejects a blank query and invalid limit before navigation', async () => {
631
+ const page = createPageMock([]);
632
+ await expect(cmd.func(page, { query: '', limit: 5 }))
633
+ .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('required') });
634
+ await expect(cmd.func(page, { query: 'Tokyo', limit: 0 }))
635
+ .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--limit') });
636
+ expect(page.goto).not.toHaveBeenCalled();
637
+ });
638
+
639
+ it('throws AuthRequired on verification, EmptyResult on empty, CommandExec on timeout / drift', async () => {
640
+ await expect(cmd.func(createPageMock(['captcha']), { query: 'Tokyo', limit: 5 }))
641
+ .rejects.toThrow('Trip.com is asking for a verification');
642
+ await expect(cmd.func(createPageMock(['empty']), { query: 'Nowherexyz', limit: 5 }))
643
+ .rejects.toMatchObject({ code: 'EMPTY_RESULT' });
644
+ await expect(cmd.func(createPageMock(['timeout']), { query: 'Tokyo', limit: 5 }))
645
+ .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('did not render product cards') });
646
+ await expect(cmd.func(createPageMock(['content', []]), { query: 'Tokyo', limit: 5 }))
647
+ .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('did not find required detail-link anchors') });
648
+ });
649
+
650
+ it('maps rows with a per-row detail url and respects --limit', async () => {
651
+ const page = createPageMock(['content', [ATTR_RAW, { ...ATTR_RAW, name: 'Mount Fuji Day Trip', price: 36.29 }]]);
652
+ const rows = await cmd.func(page, { query: 'Tokyo', limit: 1 });
653
+ expect(rows).toHaveLength(1);
654
+ expect(rows[0]).toMatchObject({ rank: 1, name: 'Tokyo Metro 24/48/72 Hour Pass', rating: 4.8, reviews: 4900, price: 5.54, currency: 'USD', url: ATTR_RAW.url });
655
+ for (const row of rows) {
656
+ for (const col of cmd.columns) expect(row).toHaveProperty(col);
657
+ }
658
+ expect(page.goto).toHaveBeenCalledTimes(1);
659
+ expect(page.goto.mock.calls[0][0]).toContain('keyword=Tokyo');
660
+ });
661
+ });
662
+
663
+ describe('trip buildAttractionExtractJs (JSDOM)', () => {
664
+ function runExtract(html) {
665
+ const dom = new JSDOM(`<!doctype html><html><body>${html}</body></html>`, { url: 'https://www.trip.com/things-to-do/list' });
666
+ return Function('document', `return (${buildAttractionExtractJs()})`)(dom.window.document);
667
+ }
668
+
669
+ it('extracts a product by its stable detail link, excluding the promo price', () => {
670
+ expect(runExtract(ATTR_CARD)).toEqual([ATTR_RAW]);
671
+ });
672
+
673
+ it('dedupes repeated detail links and drops links without a numeric id', () => {
674
+ expect(runExtract(ATTR_CARD + ATTR_CARD)).toHaveLength(1);
675
+ expect(runExtract('<div><a href="/things-to-do/detail/none/">No id</a><span>$5</span></div>')).toEqual([]);
676
+ });
677
+ });
678
+
679
+ const TRAIN_TABLE = `
680
+ <table>
681
+ <tr><th class="item item-departure">Departure</th><th class="item item-arrival">Arrival</th></tr>
682
+ <tr>
683
+ <td class="item item-departure"><span class="item-time-text">05:27</span>3h 38m, 1 change<span class="item-name">London St. Pancras International</span></td>
684
+ <td class="item item-arrival"><span class="item-time-text">09:05</span><span class="item-name">Manchester Piccadilly</span></td>
685
+ </tr>
686
+ <tr>
687
+ <td class="item item-departure"><span class="item-time-text">06:08</span>2h 17m, Direct<span class="item-name">London Euston</span></td>
688
+ <td class="item item-arrival"><span class="item-time-text">08:25</span><span class="item-name">Manchester Piccadilly</span></td>
689
+ </tr>
690
+ </table>`;
691
+
692
+ const TRAIN_RAW = {
693
+ departureTime: '05:27',
694
+ fromStation: 'London St. Pancras International',
695
+ arrivalTime: '09:05',
696
+ toStation: 'Manchester Piccadilly',
697
+ duration: '3h 38m',
698
+ changes: 1,
699
+ };
700
+
701
+ describe('trip buildTrainRouteUrl', () => {
702
+ it('slugifies the cities under the country route path', () => {
703
+ expect(buildTrainRouteUrl('uk', 'London', 'Manchester'))
704
+ .toBe('https://www.trip.com/trains/uk/route/london-to-manchester/');
705
+ expect(buildTrainRouteUrl('France', 'Paris', 'Lyon'))
706
+ .toBe('https://www.trip.com/trains/france/route/paris-to-lyon/');
707
+ });
708
+ });
709
+
710
+ describe('trip train command (registry-level)', () => {
711
+ const cmd = getRegistry().get('trip/train');
712
+
713
+ it('declares Strategy.COOKIE + browser:true + access:read', () => {
714
+ expect(cmd.access).toBe('read');
715
+ expect(cmd.browser).toBe(true);
716
+ expect(String(cmd.strategy)).toContain('cookie');
717
+ expect(cmd.domain).toBe('trip.com');
718
+ });
719
+
720
+ it('rejects a blank city / country and invalid limit before navigation', async () => {
721
+ const page = createPageMock([]);
722
+ await expect(cmd.func(page, { from: '', to: 'Manchester', country: 'uk', limit: 5 }))
723
+ .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('required') });
724
+ await expect(cmd.func(page, { from: 'London', to: 'Manchester', country: '', limit: 5 }))
725
+ .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('required') });
726
+ await expect(cmd.func(page, { from: 'London', to: 'Manchester', country: 'uk', limit: 0 }))
727
+ .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--limit') });
728
+ expect(page.goto).not.toHaveBeenCalled();
729
+ });
730
+
731
+ it('throws AuthRequired on verification, CommandExec on timeout, EmptyResult on no timetable', async () => {
732
+ await expect(cmd.func(createPageMock(['captcha']), { from: 'London', to: 'Manchester', country: 'uk', limit: 5 }))
733
+ .rejects.toThrow('Trip.com is asking for a verification');
734
+ await expect(cmd.func(createPageMock(['timeout']), { from: 'London', to: 'Manchester', country: 'uk', limit: 5 }))
735
+ .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('did not render') });
736
+ await expect(cmd.func(createPageMock(['content', []]), { from: 'London', to: 'Manchester', country: 'uk', limit: 5 }))
737
+ .rejects.toMatchObject({ code: 'EMPTY_RESULT' });
738
+ });
739
+
740
+ it('maps timetable rows against the route URL and respects --limit', async () => {
741
+ const page = createPageMock(['content', [TRAIN_RAW, { ...TRAIN_RAW, departureTime: '06:08', changes: 0 }]]);
742
+ const rows = await cmd.func(page, { from: 'London', to: 'Manchester', country: 'uk', limit: 1 });
743
+ expect(rows).toHaveLength(1);
744
+ expect(rows[0]).toMatchObject({ rank: 1, departureTime: '05:27', fromStation: 'London St. Pancras International', arrivalTime: '09:05', duration: '3h 38m', changes: 1 });
745
+ for (const row of rows) {
746
+ for (const col of cmd.columns) expect(row).toHaveProperty(col);
747
+ }
748
+ expect(page.goto).toHaveBeenCalledTimes(1);
749
+ expect(page.goto.mock.calls[0][0]).toContain('/trains/uk/route/london-to-manchester/');
750
+ });
751
+ });
752
+
753
+ describe('trip buildTrainExtractJs (JSDOM)', () => {
754
+ function runExtract(html) {
755
+ const dom = new JSDOM(`<!doctype html><html><body>${html}</body></html>`, { url: 'https://www.trip.com/trains/uk/route/london-to-manchester/' });
756
+ return Function('document', `return (${buildTrainExtractJs()})`)(dom.window.document);
757
+ }
758
+
759
+ it('extracts timetable journeys, parsing duration and change count', () => {
760
+ const rows = runExtract(TRAIN_TABLE);
761
+ expect(rows).toHaveLength(2);
762
+ expect(rows[0]).toEqual(TRAIN_RAW);
763
+ expect(rows[1].changes).toBe(0);
764
+ expect(rows[1].fromStation).toBe('London Euston');
765
+ });
766
+
767
+ it('drops the header row and rows missing a time or station', () => {
768
+ expect(runExtract('<table><tr><th class="item item-departure">Departure</th><th class="item item-arrival">Arrival</th></tr></table>')).toEqual([]);
769
+ });
770
+ });
771
+
772
+ const CAR_CARDS = `
773
+ <div class="card-item">
774
+ <div class="card-item-title"><span>Mid-sized car</span><div class="title-info">Toyota Camry or Similar</div></div>
775
+ <div class="card-item-vehicle-info"><span>5</span> <span>3</span> <span>4</span></div>
776
+ <div class="card-item-price"><div class="card-item-price__main"><span class="car-daily-price">From $50 /day</span></div></div>
777
+ </div>
778
+ <div class="card-item">
779
+ <div class="card-item-title"><span>Compact car</span><div class="title-info">Toyota Corolla or Similar</div></div>
780
+ <div class="card-item-vehicle-info"><span>5</span> <span>3</span> <span>4</span></div>
781
+ <div class="card-item-price"><div class="card-item-price__main"><span class="car-daily-price">From $47 /day</span></div></div>
782
+ </div>`;
783
+
784
+ const CAR_RAW = {
785
+ category: 'Mid-sized car',
786
+ vehicle: 'Toyota Camry or Similar',
787
+ seats: 5,
788
+ price: 50,
789
+ currency: 'USD',
790
+ };
791
+
792
+ describe('trip buildCarListUrl', () => {
793
+ it('routes by the numeric city id with cosmetic slugs', () => {
794
+ expect(buildCarListUrl('313')).toBe('https://www.trip.com/carhire/to-city-1/city-313/');
795
+ });
796
+ });
797
+
798
+ describe('trip car command (registry-level)', () => {
799
+ const cmd = getRegistry().get('trip/car');
800
+
801
+ it('declares Strategy.COOKIE + browser:true + access:read', () => {
802
+ expect(cmd.access).toBe('read');
803
+ expect(cmd.browser).toBe(true);
804
+ expect(String(cmd.strategy)).toContain('cookie');
805
+ expect(cmd.domain).toBe('trip.com');
806
+ });
807
+
808
+ it('rejects a non-numeric city and invalid limit before navigation', async () => {
809
+ const page = createPageMock([]);
810
+ await expect(cmd.func(page, { city: 'san francisco', limit: 5 }))
811
+ .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('numeric') });
812
+ await expect(cmd.func(page, { city: '313', limit: 0 }))
813
+ .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--limit') });
814
+ expect(page.goto).not.toHaveBeenCalled();
815
+ });
816
+
817
+ it('throws AuthRequired on verification, EmptyResult on an empty listing, CommandExec on timeout or drift', async () => {
818
+ await expect(cmd.func(createPageMock(['captcha']), { city: '313', limit: 5 }))
819
+ .rejects.toThrow('Trip.com is asking for a verification');
820
+ await expect(cmd.func(createPageMock(['empty']), { city: '313', limit: 5 }))
821
+ .rejects.toMatchObject({ code: 'EMPTY_RESULT' });
822
+ await expect(cmd.func(createPageMock(['timeout']), { city: '313', limit: 5 }))
823
+ .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('did not render') });
824
+ await expect(cmd.func(createPageMock(['content', []]), { city: '313', limit: 5 }))
825
+ .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('did not find') });
826
+ });
827
+
828
+ it('maps vehicle rows against the listing URL and respects --limit', async () => {
829
+ const page = createPageMock(['content', [CAR_RAW, { ...CAR_RAW, category: 'Compact car', price: 47 }]]);
830
+ const rows = await cmd.func(page, { city: '313', limit: 1 });
831
+ expect(rows).toHaveLength(1);
832
+ expect(rows[0]).toMatchObject({ rank: 1, category: 'Mid-sized car', vehicle: 'Toyota Camry or Similar', seats: 5, price: 50, currency: 'USD' });
833
+ expect(rows[0].url).toBe('https://www.trip.com/carhire/to-city-1/city-313/');
834
+ for (const row of rows) {
835
+ for (const col of cmd.columns) expect(row).toHaveProperty(col);
836
+ }
837
+ expect(page.goto).toHaveBeenCalledTimes(1);
838
+ });
839
+ });
840
+
841
+ describe('trip buildCarExtractJs (JSDOM)', () => {
842
+ function runExtract(html) {
843
+ const dom = new JSDOM(`<!doctype html><html><body>${html}</body></html>`, { url: 'https://www.trip.com/carhire/to-city-1/city-313/' });
844
+ return Function('document', `return (${buildCarExtractJs()})`)(dom.window.document);
845
+ }
846
+
847
+ it('splits the category from the example model and reads seats + price', () => {
848
+ const rows = runExtract(CAR_CARDS);
849
+ expect(rows).toHaveLength(2);
850
+ expect(rows[0]).toEqual(CAR_RAW);
851
+ expect(rows[1]).toMatchObject({ category: 'Compact car', vehicle: 'Toyota Corolla or Similar', price: 47 });
852
+ });
853
+
854
+ it('drops cards without a rendered price', () => {
855
+ expect(runExtract('<div class="card-item"><div class="card-item-title"><div class="title-info">No Price Car</div></div></div>')).toEqual([]);
856
+ });
857
+ });
858
+
859
+ describe('trip WAIT_FOR_CARS_JS (JSDOM)', () => {
860
+ it('detects a rendered price anchor as content', async () => {
861
+ const dom = new JSDOM('<!doctype html><html><body><div class="card-item"><span class="car-daily-price">From $50 /day</span></div></body></html>', {
862
+ url: 'https://www.trip.com/carhire/to-city-1/city-313/',
863
+ runScripts: 'outside-only',
864
+ });
865
+ await expect(dom.window.Function(`return (${WAIT_FOR_CARS_JS})`)())
866
+ .resolves.toBe('content');
867
+ });
868
+ });
869
+
870
+ const TRANSFER_CARDS = `
871
+ <div class="vehicle-booking-list">
872
+ <div class="vehicle-card">
873
+ <div class="vehicle-card__title"><span class="vehicle-card__title-text">Standard Car</span></div>
874
+ <span class="vehicle-card__capacity-text">Max 4</span>
875
+ <span class="vehicle-card__luggage-text">1</span>
876
+ <div class="vehicle-card__price-block"><span class="vehicle-card__discount-tag">$ 4.21 off</span><div class="vehicle-card__price-row">From $15.73 Incl. taxes &amp; fees</div></div>
877
+ </div>
878
+ <div class="vehicle-card">
879
+ <div class="vehicle-card__title"><span class="vehicle-card__title-text">Minibus</span></div>
880
+ <span class="vehicle-card__capacity-text">Max 9</span>
881
+ <span class="vehicle-card__luggage-text">3</span>
882
+ <div class="vehicle-card__price-block"><div class="vehicle-card__price-row">From $30.81 Incl. taxes &amp; fees</div></div>
883
+ </div>
884
+ </div>`;
885
+
886
+ const TRANSFER_RAW = {
887
+ type: 'Standard Car',
888
+ passengers: 4,
889
+ luggage: 1,
890
+ price: 15.73,
891
+ currency: 'USD',
892
+ };
893
+
894
+ describe('trip buildTransferListUrl', () => {
895
+ it('slugifies the city and airport code into the SEO path', () => {
896
+ expect(buildTransferListUrl('Bangkok', 'DMK'))
897
+ .toBe('https://www.trip.com/airport-transfers/bangkok/airport-dmk/');
898
+ expect(buildTransferListUrl('Ho Chi Minh City', 'SGN'))
899
+ .toBe('https://www.trip.com/airport-transfers/ho-chi-minh-city/airport-sgn/');
900
+ });
901
+ });
902
+
903
+ describe('trip transfer command (registry-level)', () => {
904
+ const cmd = getRegistry().get('trip/transfer');
905
+
906
+ it('declares Strategy.COOKIE + browser:true + access:read', () => {
907
+ expect(cmd.access).toBe('read');
908
+ expect(cmd.browser).toBe(true);
909
+ expect(String(cmd.strategy)).toContain('cookie');
910
+ expect(cmd.domain).toBe('trip.com');
911
+ });
912
+
913
+ it('rejects a blank city / non-IATA airport and invalid limit before navigation', async () => {
914
+ const page = createPageMock([]);
915
+ await expect(cmd.func(page, { city: '', airport: 'DMK', limit: 5 }))
916
+ .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('required') });
917
+ await expect(cmd.func(page, { city: 'Bangkok', airport: 'BANGKOK', limit: 5 }))
918
+ .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('IATA') });
919
+ await expect(cmd.func(page, { city: 'Bangkok', airport: 'DMK', limit: 99 }))
920
+ .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--limit') });
921
+ expect(page.goto).not.toHaveBeenCalled();
922
+ });
923
+
924
+ it('throws AuthRequired on verification, EmptyResult on no listing, CommandExec on timeout', async () => {
925
+ await expect(cmd.func(createPageMock(['captcha']), { city: 'Bangkok', airport: 'DMK', limit: 5 }))
926
+ .rejects.toThrow('Trip.com is asking for a verification');
927
+ await expect(cmd.func(createPageMock(['empty']), { city: 'Bangkok', airport: 'DMK', limit: 5 }))
928
+ .rejects.toMatchObject({ code: 'EMPTY_RESULT' });
929
+ await expect(cmd.func(createPageMock(['timeout']), { city: 'Bangkok', airport: 'DMK', limit: 5 }))
930
+ .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('did not render') });
931
+ });
932
+
933
+ it('flags a landing-page bounce when the city does not match the airport', async () => {
934
+ await expect(cmd.func(createPageMock(['content', '/airport-transfers/']), { city: 'Paris', airport: 'DMK', limit: 5 }))
935
+ .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('bounced') });
936
+ });
937
+
938
+ it('maps vehicle rows against the listing URL and respects --limit', async () => {
939
+ const page = createPageMock(['content', '/airport-transfers/bangkok/airport-dmk/', [TRANSFER_RAW, { ...TRANSFER_RAW, type: 'Minibus', passengers: 9, price: 30.81 }]]);
940
+ const rows = await cmd.func(page, { city: 'Bangkok', airport: 'DMK', limit: 1 });
941
+ expect(rows).toHaveLength(1);
942
+ expect(rows[0]).toMatchObject({ rank: 1, type: 'Standard Car', passengers: 4, luggage: 1, price: 15.73, currency: 'USD' });
943
+ expect(rows[0].url).toBe('https://www.trip.com/airport-transfers/bangkok/airport-dmk/');
944
+ for (const row of rows) {
945
+ for (const col of cmd.columns) expect(row).toHaveProperty(col);
946
+ }
947
+ expect(page.goto).toHaveBeenCalledTimes(1);
948
+ });
949
+ });
950
+
951
+ describe('trip buildTransferExtractJs (JSDOM)', () => {
952
+ function runExtract(html) {
953
+ const dom = new JSDOM(`<!doctype html><html><body>${html}</body></html>`, { url: 'https://www.trip.com/airport-transfers/bangkok/airport-dmk/' });
954
+ return Function('document', `return (${buildTransferExtractJs()})`)(dom.window.document);
955
+ }
956
+
957
+ it('reads type / passengers / luggage / price and excludes the discount tag', () => {
958
+ const rows = runExtract(TRANSFER_CARDS);
959
+ expect(rows).toHaveLength(2);
960
+ expect(rows[0]).toEqual(TRANSFER_RAW);
961
+ expect(rows[1]).toMatchObject({ type: 'Minibus', passengers: 9, luggage: 3, price: 30.81 });
962
+ });
963
+
964
+ it('drops cards without a type or price', () => {
965
+ expect(runExtract('<div class="vehicle-card"><div class="vehicle-card__price-row">From $15 Incl.</div></div>')).toEqual([]);
966
+ });
967
+ });
968
+
969
+ describe('trip WAIT_FOR_TRANSFERS_JS (JSDOM)', () => {
970
+ it('detects a rendered price row as content', async () => {
971
+ const dom = new JSDOM('<!doctype html><html><body><div class="vehicle-card"><div class="vehicle-card__price-row">From $15.73</div></div></body></html>', {
972
+ url: 'https://www.trip.com/airport-transfers/bangkok/airport-dmk/',
973
+ runScripts: 'outside-only',
974
+ });
975
+ await expect(dom.window.Function(`return (${WAIT_FOR_TRANSFERS_JS})`)())
976
+ .resolves.toBe('content');
977
+ });
978
+ });
979
+
980
+ const TOUR_RAW = {
981
+ name: '2D1N · Private Tours · Japan Osaka + Kyoto + Nara Kansai Three-City Travel Route',
982
+ type: 'Private Tours',
983
+ rating: 4.9,
984
+ reviews: 48,
985
+ price: 88,
986
+ url: 'https://us.trip.com/package-tours/detail/70457661?city=219&locale=en-US&curr=USD',
987
+ };
988
+
989
+ describe('trip buildTourSearchUrl', () => {
990
+ it('builds the package-tours list URL with kwd + tabType', () => {
991
+ expect(buildTourSearchUrl('Kyoto', 'privateTours'))
992
+ .toBe('https://www.trip.com/package-tours/list?kwd=Kyoto&tabType=privateTours&locale=en-US&curr=USD');
993
+ });
994
+ });
995
+
996
+ describe('trip buildTourSearchJs', () => {
997
+ it('embeds the keyword, the products-content capture guard, and the empty-result guard', () => {
998
+ const js = buildTourSearchJs('Bali');
999
+ expect(js).toContain('"Bali"');
1000
+ expect(js).toContain('"products":[');
1001
+ expect(js).toContain('routes?');
1002
+ });
1003
+ });
1004
+
1005
+ describe('trip tour command (registry-level)', () => {
1006
+ const cmd = getRegistry().get('trip/tour');
1007
+
1008
+ it('declares Strategy.COOKIE + browser:true + access:read', () => {
1009
+ expect(cmd.access).toBe('read');
1010
+ expect(cmd.browser).toBe(true);
1011
+ expect(String(cmd.strategy)).toContain('cookie');
1012
+ expect(cmd.domain).toBe('trip.com');
1013
+ });
1014
+
1015
+ it('rejects a blank query, invalid --type, and invalid limit before navigation', async () => {
1016
+ const page = createPageMock([]);
1017
+ await expect(cmd.func(page, { query: '', type: 'private', limit: 5 }))
1018
+ .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('required') });
1019
+ await expect(cmd.func(page, { query: 'Kyoto', type: 'luxury', limit: 5 }))
1020
+ .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--type') });
1021
+ await expect(cmd.func(page, { query: 'Kyoto', type: 'private', limit: 0 }))
1022
+ .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--limit') });
1023
+ expect(page.goto).not.toHaveBeenCalled();
1024
+ });
1025
+
1026
+ it('throws AuthRequired on verification, EmptyResult on a genuine no-match, CommandExec on timeout / malformed / drift', async () => {
1027
+ await expect(cmd.func(createPageMock([{ status: 'captcha' }]), { query: 'Kyoto', type: 'private', limit: 5 }))
1028
+ .rejects.toThrow('Trip.com is asking for a verification');
1029
+ await expect(cmd.func(createPageMock([{ status: 'empty' }]), { query: 'Nowherexyz', type: 'private', limit: 5 }))
1030
+ .rejects.toMatchObject({ code: 'EMPTY_RESULT' });
1031
+ await expect(cmd.func(createPageMock([{ status: 'timeout' }]), { query: 'Kyoto', type: 'private', limit: 5 }))
1032
+ .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('did not return results') });
1033
+ await expect(cmd.func(createPageMock([null]), { query: 'Kyoto', type: 'private', limit: 5 }))
1034
+ .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('malformed') });
1035
+ await expect(cmd.func(createPageMock([{ status: 'content', rows: [] }]), { query: 'Kyoto', type: 'private', limit: 5 }))
1036
+ .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('none carried a name') });
1037
+ });
1038
+
1039
+ it('maps captured products, drops nameless rows, and respects --type / --limit', async () => {
1040
+ const page = createPageMock([{ status: 'content', rows: [TOUR_RAW, { name: null }, { ...TOUR_RAW, name: 'Osaka Group Tour', price: 119 }] }]);
1041
+ const rows = await cmd.func(page, { query: 'Kyoto', type: 'group', limit: 5 });
1042
+ expect(rows).toHaveLength(2);
1043
+ expect(rows[0]).toMatchObject({ rank: 1, name: TOUR_RAW.name, type: 'Private Tours', rating: 4.9, reviews: 48, price: 88, currency: 'USD', url: TOUR_RAW.url });
1044
+ for (const row of rows) {
1045
+ for (const col of cmd.columns) expect(row).toHaveProperty(col);
1046
+ }
1047
+ expect(page.goto).toHaveBeenCalledTimes(1);
1048
+ expect(page.goto.mock.calls[0][0]).toContain('tabType=groupTours');
1049
+ });
1050
+ });
1051
+
1052
+ const POI_CITY = {
1053
+ name: 'Bali',
1054
+ cityId: 723,
1055
+ provinceName: 'Bali Province',
1056
+ countryName: 'Indonesia',
1057
+ airportCode: '',
1058
+ childResults: [
1059
+ { name: 'Ngurah Rai International Airport', cityId: 723, airportCode: 'DPS', provinceName: 'Bali Province', countryName: 'Indonesia' },
1060
+ ],
1061
+ };
1062
+
1063
+ function poiResponse(payload) {
1064
+ return new Response(JSON.stringify(payload), { status: 200 });
1065
+ }
1066
+
1067
+ describe('trip mapSearchRow', () => {
1068
+ it('classifies a city vs an airport and preserves ids (no silent drop)', () => {
1069
+ expect(mapSearchRow(POI_CITY, 0)).toEqual({
1070
+ rank: 1, name: 'Bali', type: 'city', cityId: 723, airportCode: null,
1071
+ province: 'Bali Province', country: 'Indonesia',
1072
+ });
1073
+ expect(mapSearchRow(POI_CITY.childResults[0], 1)).toMatchObject({
1074
+ rank: 2, name: 'Ngurah Rai International Airport', type: 'airport', cityId: 723, airportCode: 'DPS',
1075
+ });
1076
+ });
1077
+ it('keeps cityId null when the entry has none (e.g. a province)', () => {
1078
+ expect(mapSearchRow({ name: 'Bali Province', provinceName: 'Bali Province', countryName: 'Indonesia' }, 0).cityId).toBeNull();
1079
+ });
1080
+ });
1081
+
1082
+ describe('trip flattenPoiResults', () => {
1083
+ it('flattens each city plus its child airports in order', () => {
1084
+ const rows = flattenPoiResults([POI_CITY]);
1085
+ expect(rows).toHaveLength(2);
1086
+ expect(rows[0].name).toBe('Bali');
1087
+ expect(rows[1].airportCode).toBe('DPS');
1088
+ });
1089
+ it('skips non-object entries', () => {
1090
+ expect(flattenPoiResults([null, POI_CITY, 'x'])).toHaveLength(2);
1091
+ });
1092
+ });
1093
+
1094
+ describe('trip search command (registry-level)', () => {
1095
+ const cmd = getRegistry().get('trip/search');
1096
+ beforeEach(() => vi.unstubAllGlobals());
1097
+
1098
+ it('declares Strategy.PUBLIC + browser:false + access:read', () => {
1099
+ expect(cmd.access).toBe('read');
1100
+ expect(cmd.browser).toBe(false);
1101
+ expect(String(cmd.strategy)).toContain('public');
1102
+ });
1103
+
1104
+ it('rejects a blank query and invalid limit', async () => {
1105
+ await expect(cmd.func({ query: '', limit: 5 }))
1106
+ .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('required') });
1107
+ await expect(cmd.func({ query: 'Bali', limit: 0 }))
1108
+ .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--limit') });
1109
+ });
1110
+
1111
+ it('maps flattened suggestions and respects --limit', async () => {
1112
+ vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(poiResponse({ results: [POI_CITY] }))));
1113
+ const rows = await cmd.func({ query: 'Bali', limit: 5 });
1114
+ expect(rows).toHaveLength(2);
1115
+ expect(rows[0]).toMatchObject({ rank: 1, name: 'Bali', type: 'city', cityId: 723 });
1116
+ expect(rows[1]).toMatchObject({ rank: 2, type: 'airport', airportCode: 'DPS' });
1117
+ for (const row of rows) {
1118
+ for (const col of cmd.columns) expect(row).toHaveProperty(col);
1119
+ }
1120
+ });
1121
+
1122
+ it('throws EmptyResult when the endpoint returns no results', async () => {
1123
+ vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(poiResponse({ results: [] }))));
1124
+ await expect(cmd.func({ query: 'Nowherexyz', limit: 5 })).rejects.toMatchObject({ code: 'EMPTY_RESULT' });
1125
+ });
1126
+
1127
+ it('surfaces HTTP + network failures as typed COMMAND_EXEC', async () => {
1128
+ vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response('{}', { status: 503 }))));
1129
+ await expect(cmd.func({ query: 'Bali', limit: 5 }))
1130
+ .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('status 503') });
1131
+ vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('socket hang up'))));
1132
+ await expect(cmd.func({ query: 'Bali', limit: 5 }))
1133
+ .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('socket hang up') });
1134
+ });
1135
+
1136
+ it('surfaces a 200 with an unparseable body as typed COMMAND_EXEC', async () => {
1137
+ vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response('<html>not json</html>', { status: 200 }))));
1138
+ await expect(cmd.func({ query: 'Bali', limit: 5 })).rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('invalid JSON') });
1139
+ });
1140
+
1141
+ it('surfaces a 200 POI body missing the results array as typed COMMAND_EXEC', async () => {
1142
+ vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(poiResponse({ data: [] }))));
1143
+ await expect(cmd.func({ query: 'Bali', limit: 5 }))
1144
+ .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('missing results array') });
1145
+ });
1146
+ });
1147
+
1148
+ const PKG_GROUP = {
1149
+ flightlist: [{
1150
+ binfo: { flightno: 'TW247', airlineName: "T'Way Air" },
1151
+ dportinfo: { aport: 'ICN' },
1152
+ aportinfo: { aport: 'NRT' },
1153
+ dateinfo: { dtime: '2026-08-05 18:15:00', atime: '2026-08-05 20:45:00' },
1154
+ }],
1155
+ policylist: [{ price: { price: 53.4 } }],
1156
+ };
1157
+
1158
+ const PKG_ROW = {
1159
+ rank: 1,
1160
+ airline: "T'Way Air",
1161
+ flightNo: 'TW247',
1162
+ from: 'ICN',
1163
+ to: 'NRT',
1164
+ departure: '2026-08-05 18:15:00',
1165
+ arrival: '2026-08-05 20:45:00',
1166
+ stops: 0,
1167
+ price: 53.4,
1168
+ currency: 'USD',
1169
+ };
1170
+
1171
+ const PKG_POIS = {
1172
+ Seoul: [{ name: 'Seoul', cityId: 274, cityCode: 'SEL', airportCode: '' }],
1173
+ Tokyo: [{ name: 'Tokyo', cityId: 228, cityCode: 'TYO', airportCode: '' }],
1174
+ };
1175
+
1176
+ function stubPackageFetch({ pois = PKG_POIS, pkg = { grouplist: [PKG_GROUP] }, pkgStatus = 200 } = {}) {
1177
+ vi.stubGlobal('fetch', vi.fn((url, opts) => {
1178
+ if (String(url).includes('poiSearch')) {
1179
+ const key = JSON.parse(opts.body).key;
1180
+ return Promise.resolve(new Response(JSON.stringify({ results: pois[key] || [] }), { status: 200 }));
1181
+ }
1182
+ return Promise.resolve(new Response(JSON.stringify(pkg), { status: pkgStatus }));
1183
+ }));
1184
+ }
1185
+
1186
+ describe('trip mapPackageRow', () => {
1187
+ it('projects a nonstop group into the route summary row', () => {
1188
+ expect(mapPackageRow(PKG_GROUP, 0)).toEqual(PKG_ROW);
1189
+ });
1190
+ it('reads the arrival off the last leg, counts stops, and keeps a missing price null', () => {
1191
+ const connection = {
1192
+ flightlist: [
1193
+ { binfo: { flightno: 'KE1', airlineName: 'Korean Air' }, dportinfo: { aport: 'ICN' }, aportinfo: { aport: 'PVG' }, dateinfo: { dtime: '2026-08-05 08:00:00', atime: '2026-08-05 10:00:00' } },
1194
+ { binfo: { flightno: 'KE2', airlineName: 'Korean Air' }, dportinfo: { aport: 'PVG' }, aportinfo: { aport: 'NRT' }, dateinfo: { dtime: '2026-08-05 12:00:00', atime: '2026-08-05 15:00:00' } },
1195
+ ],
1196
+ policylist: [],
1197
+ };
1198
+ expect(mapPackageRow(connection, 1)).toMatchObject({
1199
+ rank: 2, airline: 'Korean Air', flightNo: 'KE1', from: 'ICN', to: 'NRT',
1200
+ departure: '2026-08-05 08:00:00', arrival: '2026-08-05 15:00:00', stops: 1, price: null,
1201
+ });
1202
+ });
1203
+ });
1204
+
1205
+ describe('trip resolvePackageCity', () => {
1206
+ beforeEach(() => vi.unstubAllGlobals());
1207
+
1208
+ it('resolves the first city carrying both a metro code and id', async () => {
1209
+ vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response(JSON.stringify({ results: [{ name: 'Tokyo', cityId: 228, cityCode: 'tyo', airportCode: '' }] }), { status: 200 }))));
1210
+ await expect(resolvePackageCity('Tokyo')).resolves.toEqual({ name: 'Tokyo', cityCode: 'TYO', cityId: 228 });
1211
+ });
1212
+
1213
+ it('skips airport-only children and returns null when no city has a code', async () => {
1214
+ vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response(JSON.stringify({ results: [
1215
+ { name: 'Narita International Airport', cityId: 228, cityCode: 'TYO', airportCode: 'NRT' },
1216
+ { name: 'Some Province', cityId: 0, cityCode: '' },
1217
+ ] }), { status: 200 }))));
1218
+ await expect(resolvePackageCity('nowhere')).resolves.toBeNull();
1219
+ });
1220
+ });
1221
+
1222
+ describe('trip package command (registry-level)', () => {
1223
+ const cmd = getRegistry().get('trip/package');
1224
+ beforeEach(() => vi.unstubAllGlobals());
1225
+
1226
+ it('declares Strategy.PUBLIC + browser:false + access:read', () => {
1227
+ expect(cmd.access).toBe('read');
1228
+ expect(cmd.browser).toBe(false);
1229
+ expect(String(cmd.strategy)).toContain('public');
1230
+ expect(cmd.domain).toBe('trip.com');
1231
+ });
1232
+
1233
+ it('rejects a blank keyword, malformed dates, depart>=return, and invalid adults / limit', async () => {
1234
+ await expect(cmd.func({ from: '', to: 'Tokyo', depart: '2026-08-05', return: '2026-08-08', adults: 2, limit: 5 }))
1235
+ .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('required') });
1236
+ await expect(cmd.func({ from: 'Seoul', to: 'Tokyo', depart: '08/05', return: '2026-08-08', adults: 2, limit: 5 }))
1237
+ .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--depart') });
1238
+ await expect(cmd.func({ from: 'Seoul', to: 'Tokyo', depart: '2026-08-08', return: '2026-08-05', adults: 2, limit: 5 }))
1239
+ .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--depart must be before --return') });
1240
+ await expect(cmd.func({ from: 'Seoul', to: 'Tokyo', depart: '2026-08-05', return: '2026-08-08', adults: 0, limit: 5 }))
1241
+ .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--adults') });
1242
+ await expect(cmd.func({ from: 'Seoul', to: 'Tokyo', depart: '2026-08-05', return: '2026-08-08', adults: 2, limit: 0 }))
1243
+ .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--limit') });
1244
+ });
1245
+
1246
+ it('rejects an unresolvable keyword and a from==to that resolves to one city', async () => {
1247
+ stubPackageFetch({ pois: { Tokyo: PKG_POIS.Tokyo } });
1248
+ await expect(cmd.func({ from: 'Nowherexyz', to: 'Tokyo', depart: '2026-08-05', return: '2026-08-08', adults: 2, limit: 5 }))
1249
+ .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('Could not resolve origin') });
1250
+ await expect(cmd.func({ from: 'Tokyo', to: 'Tokyo', depart: '2026-08-05', return: '2026-08-08', adults: 2, limit: 5 }))
1251
+ .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('must differ') });
1252
+ });
1253
+
1254
+ it('resolves both endpoints, maps package rows, and respects --limit', async () => {
1255
+ stubPackageFetch({ pkg: { grouplist: [PKG_GROUP, { ...PKG_GROUP, policylist: [{ price: { price: 61 } }] }] } });
1256
+ const rows = await cmd.func({ from: 'Seoul', to: 'Tokyo', depart: '2026-08-05', return: '2026-08-08', adults: 2, limit: 1 });
1257
+ expect(rows).toHaveLength(1);
1258
+ expect(rows[0]).toEqual(PKG_ROW);
1259
+ for (const row of rows) {
1260
+ for (const col of cmd.columns) expect(row).toHaveProperty(col);
1261
+ }
1262
+ });
1263
+
1264
+ it('throws EmptyResult when the endpoint returns no package groups', async () => {
1265
+ stubPackageFetch({ pkg: { grouplist: [] } });
1266
+ await expect(cmd.func({ from: 'Seoul', to: 'Tokyo', depart: '2026-08-05', return: '2026-08-08', adults: 2, limit: 5 }))
1267
+ .rejects.toMatchObject({ code: 'EMPTY_RESULT' });
1268
+ });
1269
+
1270
+ it('throws CommandExec (drift) when groups return but none carry an itinerary', async () => {
1271
+ stubPackageFetch({ pkg: { grouplist: [{ policylist: [{ price: { price: 53.4 } }] }] } });
1272
+ await expect(cmd.func({ from: 'Seoul', to: 'Tokyo', depart: '2026-08-05', return: '2026-08-08', adults: 2, limit: 5 }))
1273
+ .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('parseable flight identity') });
1274
+ });
1275
+
1276
+ it('throws CommandExec (drift) when package legs lack flight identity, route, or times', async () => {
1277
+ stubPackageFetch({ pkg: { grouplist: [{ flightlist: [{}], policylist: [{ price: { price: 53.4 } }] }] } });
1278
+ await expect(cmd.func({ from: 'Seoul', to: 'Tokyo', depart: '2026-08-05', return: '2026-08-08', adults: 2, limit: 5 }))
1279
+ .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('parseable flight identity') });
1280
+ });
1281
+
1282
+ it('drops malformed package groups and reranks parseable rows', async () => {
1283
+ stubPackageFetch({ pkg: { grouplist: [{ flightlist: [{}] }, { ...PKG_GROUP, policylist: [] }] } });
1284
+ const rows = await cmd.func({ from: 'Seoul', to: 'Tokyo', depart: '2026-08-05', return: '2026-08-08', adults: 2, limit: 5 });
1285
+ expect(rows).toEqual([{ ...PKG_ROW, rank: 1, price: null }]);
1286
+ });
1287
+
1288
+ it('surfaces a package endpoint failure as typed COMMAND_EXEC', async () => {
1289
+ stubPackageFetch({ pkgStatus: 503 });
1290
+ await expect(cmd.func({ from: 'Seoul', to: 'Tokyo', depart: '2026-08-05', return: '2026-08-08', adults: 2, limit: 5 }))
1291
+ .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('status 503') });
1292
+ });
1293
+
1294
+ it('surfaces a 200 package body that will not parse as typed COMMAND_EXEC', async () => {
1295
+ vi.stubGlobal('fetch', vi.fn((url, opts) => {
1296
+ if (String(url).includes('poiSearch')) {
1297
+ const key = JSON.parse(opts.body).key;
1298
+ return Promise.resolve(new Response(JSON.stringify({ results: PKG_POIS[key] || [] }), { status: 200 }));
1299
+ }
1300
+ return Promise.resolve(new Response('<html>not json</html>', { status: 200 }));
1301
+ }));
1302
+ await expect(cmd.func({ from: 'Seoul', to: 'Tokyo', depart: '2026-08-05', return: '2026-08-08', adults: 2, limit: 5 }))
1303
+ .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('invalid JSON') });
1304
+ });
1305
+
1306
+ it('surfaces a 200 package body missing grouplist as typed COMMAND_EXEC', async () => {
1307
+ stubPackageFetch({ pkg: { data: [] } });
1308
+ await expect(cmd.func({ from: 'Seoul', to: 'Tokyo', depart: '2026-08-05', return: '2026-08-08', adults: 2, limit: 5 }))
1309
+ .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('missing grouplist array') });
1310
+ });
1311
+ });
1312
+
1313
+ const DEALS_HTML = `
1314
+ <div class="top-deals_root">
1315
+ <a class="top-deals_link-item" href="https://us.trip.com/sale/w/37676/gojapan.html">
1316
+ <img class="top-deals_item-img" />
1317
+ <div class="top-deals_item-text">
1318
+ <div class="top-deals_item-tit">Go Japan</div>
1319
+ <div class="top-deals_item-desc">Hotel up to 50% off</div>
1320
+ </div>
1321
+ </a>
1322
+ <a class="top-deals_link-item" href="https://us.trip.com/sale/w/19280/gochina.html">
1323
+ <div class="top-deals_item-text">
1324
+ <div class="top-deals_item-tit">Go China</div>
1325
+ <div class="top-deals_item-desc">Exclusive Summer Deals</div>
1326
+ </div>
1327
+ </a>
1328
+ </div>`;
1329
+
1330
+ const DEALS_RAW = {
1331
+ title: 'Go Japan',
1332
+ offer: 'Hotel up to 50% off',
1333
+ discount: '50%',
1334
+ url: 'https://us.trip.com/sale/w/37676/gojapan.html',
1335
+ };
1336
+
1337
+ describe('trip buildDealsUrl', () => {
1338
+ it('pins the Top Deals hub URL with English / USD', () => {
1339
+ const url = buildDealsUrl();
1340
+ expect(url.startsWith('https://www.trip.com/sale/deals/?')).toBe(true);
1341
+ expect(url).toContain('locale=en-US');
1342
+ expect(url).toContain('curr=USD');
1343
+ });
1344
+ });
1345
+
1346
+ describe('trip deals command (registry-level)', () => {
1347
+ const cmd = getRegistry().get('trip/deals');
1348
+
1349
+ it('declares Strategy.COOKIE + browser:true + access:read', () => {
1350
+ expect(cmd.access).toBe('read');
1351
+ expect(cmd.browser).toBe(true);
1352
+ expect(String(cmd.strategy)).toContain('cookie');
1353
+ expect(cmd.domain).toBe('trip.com');
1354
+ });
1355
+
1356
+ it('rejects an invalid limit before navigation', async () => {
1357
+ const page = createPageMock([]);
1358
+ await expect(cmd.func(page, { limit: 0 }))
1359
+ .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--limit') });
1360
+ expect(page.goto).not.toHaveBeenCalled();
1361
+ });
1362
+
1363
+ it('throws AuthRequired on verification, CommandExec on timeout / malformed / no parsed tiles', async () => {
1364
+ await expect(cmd.func(createPageMock(['captcha']), { limit: 5 }))
1365
+ .rejects.toThrow('Trip.com is asking for a verification');
1366
+ await expect(cmd.func(createPageMock(['timeout']), { limit: 5 }))
1367
+ .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('did not render deal tiles') });
1368
+ await expect(cmd.func(createPageMock(['content', null]), { limit: 5 }))
1369
+ .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('malformed rows') });
1370
+ // The hub is never legitimately empty, so rendered-but-nothing-parsed is drift, not empty.
1371
+ await expect(cmd.func(createPageMock(['content', []]), { limit: 5 }))
1372
+ .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('no promotion tiles parsed') });
1373
+ });
1374
+
1375
+ it('maps deal rows against the hub URL and respects --limit', async () => {
1376
+ const page = createPageMock(['content', [DEALS_RAW, { ...DEALS_RAW, title: 'Go China', offer: 'Exclusive Summer Deals', discount: null }]]);
1377
+ const rows = await cmd.func(page, { limit: 1 });
1378
+ expect(rows).toHaveLength(1);
1379
+ expect(rows[0]).toMatchObject({ rank: 1, title: 'Go Japan', offer: 'Hotel up to 50% off', discount: '50%', url: DEALS_RAW.url });
1380
+ for (const row of rows) {
1381
+ for (const col of cmd.columns) expect(row).toHaveProperty(col);
1382
+ }
1383
+ expect(page.goto).toHaveBeenCalledTimes(1);
1384
+ expect(page.goto.mock.calls[0][0]).toContain('/sale/deals/');
1385
+ });
1386
+ });
1387
+
1388
+ describe('trip buildDealsExtractJs (JSDOM)', () => {
1389
+ function runExtract(html) {
1390
+ const dom = new JSDOM(`<!doctype html><html><body>${html}</body></html>`, { url: 'https://www.trip.com/sale/deals/' });
1391
+ return Function('document', `return (${buildDealsExtractJs()})`)(dom.window.document);
1392
+ }
1393
+
1394
+ it('extracts tiles with title / offer / parsed discount and dedupes by campaign URL', () => {
1395
+ const rows = runExtract(DEALS_HTML + DEALS_HTML);
1396
+ expect(rows).toHaveLength(2);
1397
+ expect(rows[0]).toEqual(DEALS_RAW);
1398
+ expect(rows[1]).toMatchObject({ title: 'Go China', offer: 'Exclusive Summer Deals', discount: null });
1399
+ });
1400
+
1401
+ it('parses a $N off discount and prefixes a relative campaign href', () => {
1402
+ const rows = runExtract('<a class="top-deals_link-item" href="/sale/w/9/z.html"><div class="top-deals_item-tit">Flights</div><div class="top-deals_item-desc">Get $15 off</div></a>');
1403
+ expect(rows[0]).toMatchObject({ discount: '$15 off', url: 'https://www.trip.com/sale/w/9/z.html' });
1404
+ });
1405
+
1406
+ it('drops tiles without a title or offer', () => {
1407
+ expect(runExtract('<a class="top-deals_link-item" href="/sale/w/1/x.html"></a>')).toEqual([]);
1408
+ });
1409
+ });
1410
+
1411
+ describe('trip WAIT_FOR_DEALS_JS (JSDOM)', () => {
1412
+ it('detects a rendered deal tile as content', async () => {
1413
+ const dom = new JSDOM('<!doctype html><html><body><a class="top-deals_link-item" href="/sale/w/1/x.html"></a></body></html>', {
1414
+ url: 'https://www.trip.com/sale/deals/',
1415
+ runScripts: 'outside-only',
1416
+ });
1417
+ await expect(dom.window.Function(`return (${WAIT_FOR_DEALS_JS})`)())
1418
+ .resolves.toBe('content');
1419
+ });
1420
+ });