@detiq/crawler 1.1.13 → 1.1.15

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 (112) hide show
  1. package/Dockerfile +1 -1
  2. package/dist/accessibility-tree-crawler.js +156 -0
  3. package/dist/accessibility-tree-crawler.js.map +7 -0
  4. package/dist/advanced-ui-probes.js +310 -0
  5. package/dist/advanced-ui-probes.js.map +7 -0
  6. package/dist/content-chunker.js +2 -2
  7. package/dist/content-chunker.js.map +2 -2
  8. package/dist/crawl-session-manager.js +145 -0
  9. package/dist/crawl-session-manager.js.map +7 -0
  10. package/dist/crawler.js +1142 -263
  11. package/dist/crawler.js.map +2 -2
  12. package/dist/crud-interaction.js +264 -0
  13. package/dist/crud-interaction.js.map +7 -0
  14. package/dist/dropdown-enumerator.js +230 -0
  15. package/dist/dropdown-enumerator.js.map +7 -0
  16. package/dist/filter-pagination-crawler.js +299 -0
  17. package/dist/filter-pagination-crawler.js.map +7 -0
  18. package/dist/frontier.js +5 -3
  19. package/dist/frontier.js.map +2 -2
  20. package/dist/gap-pass.js +47 -14
  21. package/dist/gap-pass.js.map +2 -2
  22. package/dist/http-engine.js +28 -15
  23. package/dist/http-engine.js.map +2 -2
  24. package/dist/index.js +15 -1
  25. package/dist/index.js.map +2 -2
  26. package/dist/job-queue.js +12 -5
  27. package/dist/job-queue.js.map +2 -2
  28. package/dist/locator-extractor.js +23 -0
  29. package/dist/locator-extractor.js.map +2 -2
  30. package/dist/multi-context-handler.js +182 -0
  31. package/dist/multi-context-handler.js.map +7 -0
  32. package/dist/nested-window-crawler.js +172 -0
  33. package/dist/nested-window-crawler.js.map +7 -0
  34. package/dist/network-action-correlator.js +209 -0
  35. package/dist/network-action-correlator.js.map +7 -0
  36. package/dist/performance-metrics-collector.js +155 -0
  37. package/dist/performance-metrics-collector.js.map +7 -0
  38. package/dist/performance-metrics.js.map +2 -2
  39. package/dist/policies/crawl-safety.js +5 -1
  40. package/dist/policies/crawl-safety.js.map +2 -2
  41. package/dist/profiles/domain-profile.js +21 -1
  42. package/dist/profiles/domain-profile.js.map +2 -2
  43. package/dist/rate-limiter.js +2 -2
  44. package/dist/rate-limiter.js.map +2 -2
  45. package/dist/screen-reader.js +1 -0
  46. package/dist/screen-reader.js.map +2 -2
  47. package/dist/service-worker-interceptor.js +136 -0
  48. package/dist/service-worker-interceptor.js.map +7 -0
  49. package/dist/session-capture.js +4 -2
  50. package/dist/session-capture.js.map +2 -2
  51. package/dist/shadow-dom-crawler.js +132 -0
  52. package/dist/shadow-dom-crawler.js.map +7 -0
  53. package/dist/sitemap.js +16 -10
  54. package/dist/sitemap.js.map +2 -2
  55. package/dist/state-graph.js +193 -0
  56. package/dist/state-graph.js.map +7 -0
  57. package/dist/stealth/human-behavior.js +36 -3
  58. package/dist/stealth/human-behavior.js.map +2 -2
  59. package/dist/url-pattern-completer.js +48 -14
  60. package/dist/url-pattern-completer.js.map +2 -2
  61. package/dist/visual-diff-verdict.js +3 -1
  62. package/dist/visual-diff-verdict.js.map +2 -2
  63. package/package.json +2 -1
  64. package/src/accessibility-tree-crawler.ts +246 -0
  65. package/src/advanced-ui-probes.ts +470 -0
  66. package/src/crawl-session-manager.ts +231 -0
  67. package/src/crawler.ts +1071 -142
  68. package/src/crud-interaction.ts +386 -0
  69. package/src/dropdown-enumerator.ts +312 -0
  70. package/src/filter-pagination-crawler.ts +500 -0
  71. package/src/gap-pass.ts +58 -16
  72. package/src/http-engine.ts +29 -15
  73. package/src/index.ts +14 -1
  74. package/src/job-queue.ts +3 -1
  75. package/src/locator-extractor.ts +45 -0
  76. package/src/multi-context-handler.ts +296 -0
  77. package/src/nested-window-crawler.ts +310 -0
  78. package/src/network-action-correlator.ts +320 -0
  79. package/src/performance-metrics-collector.ts +243 -0
  80. package/src/performance-metrics.ts +1 -1
  81. package/src/screen-reader.ts +4 -1
  82. package/src/service-worker-interceptor.ts +185 -0
  83. package/src/session-capture.ts +3 -0
  84. package/src/shadow-dom-crawler.ts +198 -0
  85. package/src/sitemap.ts +17 -10
  86. package/src/state-graph.ts +280 -0
  87. package/src/stealth/human-behavior.ts +38 -4
  88. package/src/url-pattern-completer.ts +61 -15
  89. package/tsconfig.json +1 -1
  90. package/types/accessibility-tree-crawler.d.ts +44 -0
  91. package/types/advanced-ui-probes.d.ts +47 -0
  92. package/types/crawl-session-manager.d.ts +44 -0
  93. package/types/crawler.d.ts +3 -1
  94. package/types/crud-interaction.d.ts +31 -0
  95. package/types/dropdown-enumerator.d.ts +20 -0
  96. package/types/filter-pagination-crawler.d.ts +52 -0
  97. package/types/gap-pass.d.ts +1 -0
  98. package/types/index.d.ts +14 -1
  99. package/types/job-queue.d.ts +18 -9
  100. package/types/locator-extractor.d.ts +7 -0
  101. package/types/multi-context-handler.d.ts +55 -0
  102. package/types/nested-window-crawler.d.ts +41 -0
  103. package/types/network-action-correlator.d.ts +47 -0
  104. package/types/performance-metrics-collector.d.ts +50 -0
  105. package/types/performance-metrics.d.ts +1 -2
  106. package/types/screen-reader.d.ts +1 -2
  107. package/types/service-worker-interceptor.d.ts +36 -0
  108. package/types/session-capture.d.ts +1 -1
  109. package/types/shadow-dom-crawler.d.ts +35 -0
  110. package/types/sitemap.d.ts +2 -2
  111. package/types/state-graph.d.ts +59 -0
  112. package/types/stealth/human-behavior.d.ts +1 -1
@@ -0,0 +1,470 @@
1
+ /**
2
+ * Advanced UI probes — detect and exercise drag-drop, date pickers, sliders,
3
+ * and typeahead/autocomplete widgets on a crawled page.
4
+ *
5
+ * Results feed ZeTa's test generator so it can emit interaction-level test
6
+ * cases for these widget families instead of falling back to generic clicks.
7
+ *
8
+ * Each probe is independently try/caught so a broken widget does not abort
9
+ * the others. All browser-side code runs inside page.evaluate / page.$$eval
10
+ * to avoid serialisation issues with DOM handles.
11
+ */
12
+
13
+ import type { Page } from 'playwright';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Public interfaces
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export interface DragDropCapture {
20
+ library: 'html5' | 'dnd-kit' | 'react-beautiful-dnd' | 'sortablejs' | 'pointer-event' | 'none';
21
+ draggableCount: number;
22
+ dropZoneCount: number;
23
+ testAttempted: boolean;
24
+ testCompleted: boolean;
25
+ }
26
+
27
+ export interface DatePickerCapture {
28
+ fieldLabel: string;
29
+ pickerType: 'calendar-popup' | 'native-date' | 'native-datetime' | 'none';
30
+ minDate?: string;
31
+ maxDate?: string;
32
+ disabledDatesFound: boolean;
33
+ sampleSelectedValue?: string;
34
+ }
35
+
36
+ export interface SliderCapture {
37
+ label: string;
38
+ min: number;
39
+ max: number;
40
+ step: number;
41
+ currentValue: number;
42
+ }
43
+
44
+ export interface TypeaheadCapture {
45
+ label: string;
46
+ minCharsToTrigger: number;
47
+ sampleSuggestions: string[];
48
+ noResultsText: string | null;
49
+ }
50
+
51
+ export interface AdvancedProbeResult {
52
+ dragDrop: DragDropCapture;
53
+ datePickers: DatePickerCapture[];
54
+ sliders: SliderCapture[];
55
+ typeaheads: TypeaheadCapture[];
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Internal: drag-and-drop detection
60
+ // ---------------------------------------------------------------------------
61
+
62
+ async function detectDragAndDrop(page: Page): Promise<DragDropCapture> {
63
+ // Detect library and count elements in the browser context
64
+ const detected = await page.evaluate((): {
65
+ library: DragDropCapture['library'];
66
+ draggableCount: number;
67
+ dropZoneCount: number;
68
+ } => {
69
+ // dnd-kit
70
+ if (document.querySelector('[data-dnd-kit-draggable]')) {
71
+ return {
72
+ library: 'dnd-kit',
73
+ draggableCount: document.querySelectorAll('[data-dnd-kit-draggable]').length,
74
+ dropZoneCount: document.querySelectorAll('[data-dnd-kit-droppable]').length,
75
+ };
76
+ }
77
+ // react-beautiful-dnd
78
+ if (document.querySelector('[data-rbd-draggable-id]')) {
79
+ return {
80
+ library: 'react-beautiful-dnd',
81
+ draggableCount: document.querySelectorAll('[data-rbd-draggable-id]').length,
82
+ dropZoneCount: document.querySelectorAll('[data-rbd-droppable-id]').length,
83
+ };
84
+ }
85
+ // SortableJS
86
+ if (document.querySelector('.sortable-ghost') || document.querySelector('[data-sortable]')) {
87
+ return {
88
+ library: 'sortablejs',
89
+ draggableCount: document.querySelectorAll('[data-sortable], .sortable-item').length,
90
+ dropZoneCount: document.querySelectorAll('[data-sortable-list], .sortable-list').length,
91
+ };
92
+ }
93
+ // Native HTML5 draggable
94
+ const html5Draggables = document.querySelectorAll('[draggable="true"]');
95
+ if (html5Draggables.length > 0) {
96
+ return {
97
+ library: 'html5',
98
+ draggableCount: html5Draggables.length,
99
+ dropZoneCount: document.querySelectorAll('[data-drop-target], [ondrop], .drop-zone').length,
100
+ };
101
+ }
102
+ return { library: 'none', draggableCount: 0, dropZoneCount: 0 };
103
+ });
104
+
105
+ if (detected.library === 'none') {
106
+ return { ...detected, testAttempted: false, testCompleted: false };
107
+ }
108
+
109
+ let testAttempted = false;
110
+ let testCompleted = false;
111
+
112
+ try {
113
+ if (detected.library === 'html5' && detected.draggableCount >= 2) {
114
+ // Attempt a real dragAndDrop between the first two draggable elements
115
+ const sources = await page.$$('[draggable="true"]');
116
+ if (sources.length >= 2) {
117
+ testAttempted = true;
118
+ await page.dragAndDrop('[draggable="true"]:nth-child(1)', '[draggable="true"]:nth-child(2)');
119
+ testCompleted = true;
120
+ }
121
+ } else if (detected.draggableCount >= 2) {
122
+ // Pointer-event simulation for library-managed drag
123
+ const draggableSel =
124
+ detected.library === 'dnd-kit' ? '[data-dnd-kit-draggable]' :
125
+ detected.library === 'react-beautiful-dnd' ? '[data-rbd-draggable-id]' :
126
+ '[data-sortable], .sortable-item';
127
+
128
+ const elements = await page.$$(draggableSel);
129
+ if (elements.length >= 2) {
130
+ testAttempted = true;
131
+ const src = elements[0];
132
+ const dst = elements[1];
133
+ const srcBox = await src.boundingBox();
134
+ const dstBox = await dst.boundingBox();
135
+
136
+ if (srcBox && dstBox) {
137
+ const srcX = srcBox.x + srcBox.width / 2;
138
+ const srcY = srcBox.y + srcBox.height / 2;
139
+ const dstX = dstBox.x + dstBox.width / 2;
140
+ const dstY = dstBox.y + dstBox.height / 2;
141
+
142
+ await page.mouse.move(srcX, srcY);
143
+ await page.mouse.down();
144
+ // Move in small steps to allow drag handlers to fire
145
+ const steps = 10;
146
+ for (let i = 1; i <= steps; i++) {
147
+ await page.mouse.move(
148
+ srcX + ((dstX - srcX) * i) / steps,
149
+ srcY + ((dstY - srcY) * i) / steps,
150
+ );
151
+ }
152
+ await page.mouse.up();
153
+ testCompleted = true;
154
+ }
155
+ }
156
+ }
157
+ } catch {
158
+ // testCompleted remains false — that is the signal to the caller
159
+ }
160
+
161
+ return { ...detected, testAttempted, testCompleted };
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Internal: date pickers
166
+ // ---------------------------------------------------------------------------
167
+
168
+ async function captureDatePickers(page: Page): Promise<DatePickerCapture[]> {
169
+ const results: DatePickerCapture[] = [];
170
+
171
+ // Native date / datetime-local inputs
172
+ const nativeInputs = await page.$$('input[type="date"], input[type="datetime-local"]');
173
+ for (const input of nativeInputs.slice(0, 5)) {
174
+ try {
175
+ const attrs = await input.evaluate((el: any) => ({
176
+ type: el.type as string,
177
+ min: el.min || undefined,
178
+ max: el.max || undefined,
179
+ id: el.id || '',
180
+ name: el.name || '',
181
+ }));
182
+
183
+ // Resolve label text
184
+ let fieldLabel = attrs.id || attrs.name || 'unknown';
185
+ if (attrs.id) {
186
+ const lblText = await page.evaluate((id: string) => {
187
+ const lbl = document.querySelector(`label[for="${id}"]`);
188
+ return lbl ? (lbl.textContent ?? '').trim() : null;
189
+ }, attrs.id);
190
+ if (lblText) fieldLabel = lblText;
191
+ }
192
+
193
+ // Try filling a known date and reading it back
194
+ let sampleSelectedValue: string | undefined;
195
+ try {
196
+ await input.fill('2026-09-15');
197
+ sampleSelectedValue = await input.inputValue();
198
+ if (!sampleSelectedValue) sampleSelectedValue = undefined;
199
+ } catch {
200
+ // ignore fill errors
201
+ }
202
+
203
+ results.push({
204
+ fieldLabel,
205
+ pickerType: attrs.type === 'datetime-local' ? 'native-datetime' : 'native-date',
206
+ minDate: attrs.min,
207
+ maxDate: attrs.max,
208
+ disabledDatesFound: false, // native inputs have no visual disabled cells
209
+ sampleSelectedValue,
210
+ });
211
+ } catch {
212
+ // skip this element
213
+ }
214
+ }
215
+
216
+ // Calendar-popup pickers (custom widgets triggered by click)
217
+ // Only look for inputs not already classified as native date inputs
218
+ const customInputs = await page.$$('input:not([type="date"]):not([type="datetime-local"]):not([type="hidden"])');
219
+ for (const input of customInputs.slice(0, 5)) {
220
+ if (results.length >= 5) break;
221
+
222
+ try {
223
+ await input.click();
224
+ // Wait up to 2 s for a calendar grid to appear
225
+ const calendarAppeared = await page.waitForSelector('[role="grid"]', { timeout: 2000 })
226
+ .then(() => true)
227
+ .catch(() => false);
228
+
229
+ if (!calendarAppeared) {
230
+ await page.keyboard.press('Escape');
231
+ continue;
232
+ }
233
+
234
+ // Capture month/year header
235
+ const monthHeader = await page.evaluate(() => {
236
+ const header = document.querySelector('[role="grid"] caption, [role="columnheader"], .calendar-header, .rdp-caption');
237
+ return header ? (header.textContent ?? '').trim().slice(0, 60) : '';
238
+ });
239
+
240
+ // Count disabled cells
241
+ const disabledCount = await page.evaluate(() =>
242
+ document.querySelectorAll('[role="gridcell"][aria-disabled="true"], [role="gridcell"].disabled').length
243
+ );
244
+
245
+ // Resolve label for this input
246
+ const inputId = await input.evaluate((el: any) => el.id || el.name || '');
247
+ let fieldLabel = inputId || 'datepicker';
248
+ if (inputId) {
249
+ const lblText = await page.evaluate((id: string) => {
250
+ const lbl = document.querySelector(`label[for="${id}"]`);
251
+ return lbl ? (lbl.textContent ?? '').trim() : null;
252
+ }, inputId);
253
+ if (lblText) fieldLabel = lblText;
254
+ }
255
+
256
+ // Click the first enabled cell and read value
257
+ let sampleSelectedValue: string | undefined;
258
+ try {
259
+ const firstEnabled = await page.$('[role="gridcell"]:not([aria-disabled="true"]):not(.disabled)');
260
+ if (firstEnabled) {
261
+ await firstEnabled.click();
262
+ sampleSelectedValue = await input.inputValue().catch(() => undefined);
263
+ }
264
+ } catch {
265
+ // ignore
266
+ }
267
+
268
+ await page.keyboard.press('Escape');
269
+
270
+ results.push({
271
+ fieldLabel,
272
+ pickerType: 'calendar-popup',
273
+ disabledDatesFound: disabledCount > 0,
274
+ sampleSelectedValue: sampleSelectedValue || undefined,
275
+ });
276
+ } catch {
277
+ try { await page.keyboard.press('Escape'); } catch { /* ignore */ }
278
+ }
279
+ }
280
+
281
+ return results;
282
+ }
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // Internal: sliders
286
+ // ---------------------------------------------------------------------------
287
+
288
+ async function captureSliders(page: Page): Promise<SliderCapture[]> {
289
+ const results: SliderCapture[] = [];
290
+
291
+ // Collect both native range inputs and ARIA sliders
292
+ const handles = await page.$$('input[type="range"], [role="slider"]');
293
+
294
+ for (const handle of handles.slice(0, 5)) {
295
+ try {
296
+ const attrs = await handle.evaluate((el: any) => {
297
+ const min = parseFloat(el.min ?? el.getAttribute('aria-valuemin') ?? '0');
298
+ const max = parseFloat(el.max ?? el.getAttribute('aria-valuemax') ?? '100');
299
+ const step = parseFloat(el.step ?? el.getAttribute('aria-valuestep') ?? '1') || 1;
300
+ const value = parseFloat(el.value ?? el.getAttribute('aria-valuenow') ?? String(min));
301
+ const id = el.id || el.name || '';
302
+ return { min, max, step, value, id };
303
+ });
304
+
305
+ // Resolve label
306
+ let label = attrs.id || 'slider';
307
+ if (attrs.id) {
308
+ const lblText = await page.evaluate((id: string) => {
309
+ const lbl = document.querySelector(`label[for="${id}"]`);
310
+ return lbl ? (lbl.textContent ?? '').trim() : null;
311
+ }, attrs.id);
312
+ if (lblText) label = lblText;
313
+ }
314
+
315
+ // Exercise fill to min then max then restore (native range only)
316
+ const isNative = await handle.evaluate((el: any) => el.tagName.toLowerCase() === 'input');
317
+ if (isNative) {
318
+ try {
319
+ await handle.fill(String(attrs.min));
320
+ await handle.fill(String(attrs.max));
321
+ await handle.fill(String(attrs.value));
322
+ } catch {
323
+ // ignore — read-only or detached
324
+ }
325
+ }
326
+
327
+ results.push({
328
+ label,
329
+ min: attrs.min,
330
+ max: attrs.max,
331
+ step: attrs.step,
332
+ currentValue: attrs.value,
333
+ });
334
+ } catch {
335
+ // skip this element
336
+ }
337
+ }
338
+
339
+ return results;
340
+ }
341
+
342
+ // ---------------------------------------------------------------------------
343
+ // Internal: typeahead / autocomplete
344
+ // ---------------------------------------------------------------------------
345
+
346
+ async function captureTypeaheads(page: Page): Promise<TypeaheadCapture[]> {
347
+ const results: TypeaheadCapture[] = [];
348
+
349
+ // Candidate selectors for typeahead inputs
350
+ const candidates = await page.$$(
351
+ '[role="combobox"][aria-autocomplete], [aria-autocomplete="list"], input.autocomplete, input[aria-autocomplete]'
352
+ );
353
+
354
+ for (const input of candidates.slice(0, 3)) {
355
+ try {
356
+ const inputId = await input.evaluate((el: any) => el.id || el.name || el.placeholder || '');
357
+
358
+ // Resolve label
359
+ let label = inputId || 'typeahead';
360
+ if (inputId) {
361
+ const lblText = await page.evaluate((id: string) => {
362
+ const lbl = document.querySelector(`label[for="${id}"]`);
363
+ return lbl ? (lbl.textContent ?? '').trim() : null;
364
+ }, inputId);
365
+ if (lblText) label = lblText;
366
+ }
367
+
368
+ await input.click();
369
+ await input.fill('');
370
+
371
+ // Type 'a' and check whether a listbox appears
372
+ await input.type('a', { delay: 100 });
373
+ await page.waitForTimeout(500);
374
+
375
+ let listboxVisible = await page.evaluate(() => {
376
+ const lb = document.querySelector('[role="listbox"]');
377
+ if (!lb) return false;
378
+ const style = window.getComputedStyle(lb);
379
+ return style.display !== 'none' && style.visibility !== 'hidden';
380
+ });
381
+
382
+ let minCharsToTrigger = listboxVisible ? 1 : 2;
383
+
384
+ if (!listboxVisible) {
385
+ // Try a second character
386
+ await input.type('b', { delay: 100 });
387
+ await page.waitForTimeout(500);
388
+ listboxVisible = await page.evaluate(() => {
389
+ const lb = document.querySelector('[role="listbox"]');
390
+ if (!lb) return false;
391
+ const style = window.getComputedStyle(lb);
392
+ return style.display !== 'none' && style.visibility !== 'hidden';
393
+ });
394
+ if (listboxVisible) minCharsToTrigger = 2;
395
+ }
396
+
397
+ // Collect suggestion text from visible options
398
+ const sampleSuggestions: string[] = [];
399
+ if (listboxVisible) {
400
+ const suggestions = await page.evaluate(() =>
401
+ Array.from(document.querySelectorAll('[role="option"]'))
402
+ .slice(0, 8)
403
+ .map(o => (o.textContent ?? '').trim())
404
+ .filter(Boolean)
405
+ );
406
+ sampleSuggestions.push(...suggestions);
407
+ }
408
+
409
+ // Type a nonsense string to capture the "no results" message
410
+ await input.fill('ZZXQQ_NORESULT_9999');
411
+ await page.waitForTimeout(500);
412
+
413
+ const noResultsText = await page.evaluate(() => {
414
+ // Common no-results patterns
415
+ const selectors = [
416
+ '[role="listbox"] [role="option"]',
417
+ '[role="status"]',
418
+ '.no-results',
419
+ '.empty-message',
420
+ '[data-testid*="no-result"]',
421
+ ];
422
+ for (const sel of selectors) {
423
+ const el = document.querySelector(sel);
424
+ if (el) {
425
+ const txt = (el.textContent ?? '').trim();
426
+ if (txt) return txt.slice(0, 120);
427
+ }
428
+ }
429
+ return null;
430
+ });
431
+
432
+ // Clear the input to leave the page in a clean state
433
+ await input.fill('');
434
+
435
+ results.push({
436
+ label,
437
+ minCharsToTrigger,
438
+ sampleSuggestions,
439
+ noResultsText,
440
+ });
441
+ } catch {
442
+ try { await input.fill(''); } catch { /* ignore */ }
443
+ }
444
+ }
445
+
446
+ return results;
447
+ }
448
+
449
+ // ---------------------------------------------------------------------------
450
+ // Public orchestrator
451
+ // ---------------------------------------------------------------------------
452
+
453
+ export async function probeAdvancedUI(page: Page): Promise<AdvancedProbeResult> {
454
+ const defaultDragDrop: DragDropCapture = {
455
+ library: 'none',
456
+ draggableCount: 0,
457
+ dropZoneCount: 0,
458
+ testAttempted: false,
459
+ testCompleted: false,
460
+ };
461
+
462
+ const [dragDrop, datePickers, sliders, typeaheads] = await Promise.all([
463
+ detectDragAndDrop(page).catch((): DragDropCapture => defaultDragDrop),
464
+ captureDatePickers(page).catch((): DatePickerCapture[] => []),
465
+ captureSliders(page).catch((): SliderCapture[] => []),
466
+ captureTypeaheads(page).catch((): TypeaheadCapture[] => []),
467
+ ]);
468
+
469
+ return { dragDrop, datePickers, sliders, typeaheads };
470
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Session management for authenticated crawl sessions.
3
+ * Detects auth expiry via HTTP status codes and login-page redirects,
4
+ * then attempts recovery via a token refresh endpoint or re-login flow.
5
+ * Designed to keep long-running crawls alive without manual intervention.
6
+ */
7
+
8
+ import type { Page } from 'playwright';
9
+
10
+ export interface SessionConfig {
11
+ loginUrl?: string;
12
+ email?: string;
13
+ password?: string;
14
+ healthCheckUrl?: string;
15
+ refreshEndpoint?: string;
16
+ tokenLocalStorageKey?: string;
17
+ maxRefreshAttempts?: number;
18
+ }
19
+
20
+ export type SessionEventType =
21
+ | 'HEALTHY'
22
+ | 'EXPIRY_DETECTED'
23
+ | 'REFRESH_ATTEMPT'
24
+ | 'REFRESH_SUCCESS'
25
+ | 'REFRESH_FAILED'
26
+ | 'AUTH_FAILURE';
27
+
28
+ export interface SessionEvent {
29
+ type: SessionEventType;
30
+ isoTimestamp: string;
31
+ url?: string;
32
+ strategy?: string;
33
+ attemptCount?: number;
34
+ }
35
+
36
+ export interface SessionManager {
37
+ onResponse(url: string, status: number): void;
38
+ onNavigation(url: string): void;
39
+ isHealthy(): boolean;
40
+ needsRefresh(): boolean;
41
+ markHealthy(isoTimestamp: string): void;
42
+ refresh(page: Page, isoTimestamp: string): Promise<boolean>;
43
+ getEvents(): SessionEvent[];
44
+ getStats(): { healthy: boolean; refreshAttempts: number; authFailure: boolean };
45
+ }
46
+
47
+ const LOGIN_PATH_PATTERNS = [
48
+ '/login',
49
+ '/sign-in',
50
+ '/signin',
51
+ '/auth/login',
52
+ '/auth/sign-in',
53
+ ];
54
+
55
+ export function createSessionManager(config: SessionConfig): SessionManager {
56
+ let healthy = true;
57
+ let refreshAttempts = 0;
58
+ let authFailure = false;
59
+ const events: SessionEvent[] = [];
60
+ const maxAttempts = config.maxRefreshAttempts ?? 3;
61
+
62
+ function onResponse(url: string, status: number): void {
63
+ if ((status === 401 || status === 403) && healthy) {
64
+ healthy = false;
65
+ events.push({ type: 'EXPIRY_DETECTED', isoTimestamp: new Date().toISOString(), url });
66
+ }
67
+ }
68
+
69
+ function onNavigation(url: string): void {
70
+ const isLoginPage = LOGIN_PATH_PATTERNS.some((pattern) => url.includes(pattern));
71
+ if (isLoginPage && healthy) {
72
+ healthy = false;
73
+ events.push({ type: 'EXPIRY_DETECTED', isoTimestamp: new Date().toISOString(), url });
74
+ }
75
+ }
76
+
77
+ function isHealthy(): boolean {
78
+ return healthy;
79
+ }
80
+
81
+ function needsRefresh(): boolean {
82
+ return !healthy && !authFailure && refreshAttempts < maxAttempts;
83
+ }
84
+
85
+ function markHealthy(isoTimestamp: string): void {
86
+ healthy = true;
87
+ events.push({ type: 'HEALTHY', isoTimestamp });
88
+ }
89
+
90
+ async function tryRefreshEndpoint(page: Page, isoTimestamp: string): Promise<boolean> {
91
+ if (!config.refreshEndpoint) return false;
92
+
93
+ events.push({
94
+ type: 'REFRESH_ATTEMPT',
95
+ isoTimestamp,
96
+ strategy: 'refreshEndpoint',
97
+ attemptCount: refreshAttempts + 1,
98
+ });
99
+
100
+ try {
101
+ const result = await page.evaluate(async (endpoint) => {
102
+ try {
103
+ const res = await fetch(endpoint, { method: 'POST', credentials: 'include' });
104
+ if (res.ok) {
105
+ const data = await res.json().catch(() => ({}));
106
+ return { ok: true, token: (data as any).access_token || (data as any).token || null };
107
+ }
108
+ return { ok: false, token: null };
109
+ } catch {
110
+ return { ok: false, token: null };
111
+ }
112
+ }, config.refreshEndpoint);
113
+
114
+ if (result.ok) {
115
+ if (result.token && config.tokenLocalStorageKey) {
116
+ await page.evaluate(
117
+ ([key, val]) => localStorage.setItem(key, val),
118
+ [config.tokenLocalStorageKey, result.token] as [string, string],
119
+ );
120
+ }
121
+ refreshAttempts++;
122
+ events.push({ type: 'REFRESH_SUCCESS', isoTimestamp, strategy: 'refreshEndpoint' });
123
+ return true;
124
+ }
125
+ } catch {
126
+ // page.evaluate or localStorage.setItem failed — fall through
127
+ }
128
+
129
+ return false;
130
+ }
131
+
132
+ async function tryReLogin(page: Page, isoTimestamp: string): Promise<boolean> {
133
+ if (!config.loginUrl || !config.email || !config.password) return false;
134
+
135
+ events.push({
136
+ type: 'REFRESH_ATTEMPT',
137
+ isoTimestamp,
138
+ strategy: 'reLogin',
139
+ attemptCount: refreshAttempts + 1,
140
+ });
141
+
142
+ try {
143
+ await page.goto(config.loginUrl, { waitUntil: 'domcontentloaded' });
144
+
145
+ await page
146
+ .locator('input[type="email"], input[name="email"], input[name="username"]')
147
+ .first()
148
+ .fill(config.email);
149
+
150
+ await page
151
+ .locator('input[type="password"]')
152
+ .first()
153
+ .fill(config.password);
154
+
155
+ await page
156
+ .locator(
157
+ 'button[type="submit"], button:has-text("Sign In"), button:has-text("Log In"), button:has-text("Login")',
158
+ )
159
+ .first()
160
+ .click();
161
+
162
+ await page.waitForFunction(
163
+ (loginUrl) => !location.href.includes(loginUrl),
164
+ config.loginUrl,
165
+ { timeout: 10000 },
166
+ );
167
+
168
+ events.push({ type: 'REFRESH_SUCCESS', isoTimestamp, strategy: 'reLogin' });
169
+ return true;
170
+ } catch {
171
+ events.push({ type: 'REFRESH_FAILED', isoTimestamp, strategy: 'reLogin' });
172
+ return false;
173
+ }
174
+ }
175
+
176
+ async function refresh(page: Page, isoTimestamp: string): Promise<boolean> {
177
+ // Strategy 1: token refresh endpoint
178
+ if (config.refreshEndpoint) {
179
+ const ok = await tryRefreshEndpoint(page, isoTimestamp);
180
+ if (ok) return true;
181
+ }
182
+
183
+ // Strategy 2: re-login
184
+ if (config.loginUrl && config.email && config.password) {
185
+ const ok = await tryReLogin(page, isoTimestamp);
186
+ if (ok) return true;
187
+ }
188
+
189
+ // All strategies exhausted
190
+ refreshAttempts++;
191
+ events.push({ type: 'REFRESH_FAILED', isoTimestamp, attemptCount: refreshAttempts });
192
+
193
+ if (refreshAttempts >= maxAttempts) {
194
+ authFailure = true;
195
+ events.push({ type: 'AUTH_FAILURE', isoTimestamp, attemptCount: refreshAttempts });
196
+ }
197
+
198
+ return false;
199
+ }
200
+
201
+ function getEvents(): SessionEvent[] {
202
+ return [...events];
203
+ }
204
+
205
+ function getStats(): { healthy: boolean; refreshAttempts: number; authFailure: boolean } {
206
+ return { healthy, refreshAttempts, authFailure };
207
+ }
208
+
209
+ return {
210
+ onResponse,
211
+ onNavigation,
212
+ isHealthy,
213
+ needsRefresh,
214
+ markHealthy,
215
+ refresh,
216
+ getEvents,
217
+ getStats,
218
+ };
219
+ }
220
+
221
+ /**
222
+ * Attaches a Playwright response listener to feed HTTP status codes into the
223
+ * session manager. Returns a cleanup function to remove the listener.
224
+ */
225
+ export function attachSessionWatcher(page: Page, manager: SessionManager): () => void {
226
+ const onResponse = (res: import('playwright').Response) => {
227
+ manager.onResponse(res.url(), res.status());
228
+ };
229
+ page.on('response', onResponse);
230
+ return () => page.off('response', onResponse);
231
+ }