@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.
- package/Dockerfile +1 -1
- package/dist/accessibility-tree-crawler.js +156 -0
- package/dist/accessibility-tree-crawler.js.map +7 -0
- package/dist/advanced-ui-probes.js +310 -0
- package/dist/advanced-ui-probes.js.map +7 -0
- package/dist/content-chunker.js +2 -2
- package/dist/content-chunker.js.map +2 -2
- package/dist/crawl-session-manager.js +145 -0
- package/dist/crawl-session-manager.js.map +7 -0
- package/dist/crawler.js +1142 -263
- package/dist/crawler.js.map +2 -2
- package/dist/crud-interaction.js +264 -0
- package/dist/crud-interaction.js.map +7 -0
- package/dist/dropdown-enumerator.js +230 -0
- package/dist/dropdown-enumerator.js.map +7 -0
- package/dist/filter-pagination-crawler.js +299 -0
- package/dist/filter-pagination-crawler.js.map +7 -0
- package/dist/frontier.js +5 -3
- package/dist/frontier.js.map +2 -2
- package/dist/gap-pass.js +47 -14
- package/dist/gap-pass.js.map +2 -2
- package/dist/http-engine.js +28 -15
- package/dist/http-engine.js.map +2 -2
- package/dist/index.js +15 -1
- package/dist/index.js.map +2 -2
- package/dist/job-queue.js +12 -5
- package/dist/job-queue.js.map +2 -2
- package/dist/locator-extractor.js +23 -0
- package/dist/locator-extractor.js.map +2 -2
- package/dist/multi-context-handler.js +182 -0
- package/dist/multi-context-handler.js.map +7 -0
- package/dist/nested-window-crawler.js +172 -0
- package/dist/nested-window-crawler.js.map +7 -0
- package/dist/network-action-correlator.js +209 -0
- package/dist/network-action-correlator.js.map +7 -0
- package/dist/performance-metrics-collector.js +155 -0
- package/dist/performance-metrics-collector.js.map +7 -0
- package/dist/performance-metrics.js.map +2 -2
- package/dist/policies/crawl-safety.js +5 -1
- package/dist/policies/crawl-safety.js.map +2 -2
- package/dist/profiles/domain-profile.js +21 -1
- package/dist/profiles/domain-profile.js.map +2 -2
- package/dist/rate-limiter.js +2 -2
- package/dist/rate-limiter.js.map +2 -2
- package/dist/screen-reader.js +1 -0
- package/dist/screen-reader.js.map +2 -2
- package/dist/service-worker-interceptor.js +136 -0
- package/dist/service-worker-interceptor.js.map +7 -0
- package/dist/session-capture.js +4 -2
- package/dist/session-capture.js.map +2 -2
- package/dist/shadow-dom-crawler.js +132 -0
- package/dist/shadow-dom-crawler.js.map +7 -0
- package/dist/sitemap.js +16 -10
- package/dist/sitemap.js.map +2 -2
- package/dist/state-graph.js +193 -0
- package/dist/state-graph.js.map +7 -0
- package/dist/stealth/human-behavior.js +36 -3
- package/dist/stealth/human-behavior.js.map +2 -2
- package/dist/url-pattern-completer.js +48 -14
- package/dist/url-pattern-completer.js.map +2 -2
- package/dist/visual-diff-verdict.js +3 -1
- package/dist/visual-diff-verdict.js.map +2 -2
- package/package.json +2 -1
- package/src/accessibility-tree-crawler.ts +246 -0
- package/src/advanced-ui-probes.ts +470 -0
- package/src/crawl-session-manager.ts +231 -0
- package/src/crawler.ts +1071 -142
- package/src/crud-interaction.ts +386 -0
- package/src/dropdown-enumerator.ts +312 -0
- package/src/filter-pagination-crawler.ts +500 -0
- package/src/gap-pass.ts +58 -16
- package/src/http-engine.ts +29 -15
- package/src/index.ts +14 -1
- package/src/job-queue.ts +3 -1
- package/src/locator-extractor.ts +45 -0
- package/src/multi-context-handler.ts +296 -0
- package/src/nested-window-crawler.ts +310 -0
- package/src/network-action-correlator.ts +320 -0
- package/src/performance-metrics-collector.ts +243 -0
- package/src/performance-metrics.ts +1 -1
- package/src/screen-reader.ts +4 -1
- package/src/service-worker-interceptor.ts +185 -0
- package/src/session-capture.ts +3 -0
- package/src/shadow-dom-crawler.ts +198 -0
- package/src/sitemap.ts +17 -10
- package/src/state-graph.ts +280 -0
- package/src/stealth/human-behavior.ts +38 -4
- package/src/url-pattern-completer.ts +61 -15
- package/tsconfig.json +1 -1
- package/types/accessibility-tree-crawler.d.ts +44 -0
- package/types/advanced-ui-probes.d.ts +47 -0
- package/types/crawl-session-manager.d.ts +44 -0
- package/types/crawler.d.ts +3 -1
- package/types/crud-interaction.d.ts +31 -0
- package/types/dropdown-enumerator.d.ts +20 -0
- package/types/filter-pagination-crawler.d.ts +52 -0
- package/types/gap-pass.d.ts +1 -0
- package/types/index.d.ts +14 -1
- package/types/job-queue.d.ts +18 -9
- package/types/locator-extractor.d.ts +7 -0
- package/types/multi-context-handler.d.ts +55 -0
- package/types/nested-window-crawler.d.ts +41 -0
- package/types/network-action-correlator.d.ts +47 -0
- package/types/performance-metrics-collector.d.ts +50 -0
- package/types/performance-metrics.d.ts +1 -2
- package/types/screen-reader.d.ts +1 -2
- package/types/service-worker-interceptor.d.ts +36 -0
- package/types/session-capture.d.ts +1 -1
- package/types/shadow-dom-crawler.d.ts +35 -0
- package/types/sitemap.d.ts +2 -2
- package/types/state-graph.d.ts +59 -0
- 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
|
+
}
|