halo-agent 1.1.0

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/filler.js ADDED
@@ -0,0 +1,987 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * ATS-specific form fill strategies.
5
+ * All filling goes through the user's real Chrome via CDP — no fake browser.
6
+ */
7
+
8
+ const TYPING_SPEEDS = { slow: 80, normal: 40, fast: 15 };
9
+
10
+ // Randomize delay slightly to look human
11
+ function jitter(base) {
12
+ return Math.floor(base * (0.7 + Math.random() * 0.6));
13
+ }
14
+
15
+ /**
16
+ * Set a form field value in a React/Vue/Angular aware way.
17
+ * Fires the full event sequence that frameworks listen for.
18
+ */
19
+ async function setFieldValue(page, selector, value) {
20
+ try {
21
+ await page.locator(selector).first().waitFor({ timeout: 3000 });
22
+ await page.evaluate(({ sel, val }) => {
23
+ const el = document.querySelector(sel);
24
+ if (!el) return;
25
+ el.focus();
26
+ // React's synthetic event system requires using the native setter
27
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
28
+ || Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
29
+ if (nativeInputValueSetter) nativeInputValueSetter.call(el, val);
30
+ el.dispatchEvent(new Event('focus', { bubbles: true }));
31
+ el.dispatchEvent(new Event('input', { bubbles: true }));
32
+ el.dispatchEvent(new Event('change', { bubbles: true }));
33
+ el.dispatchEvent(new Event('blur', { bubbles: true }));
34
+ }, { sel: selector, val: value });
35
+ return true;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Set a field value by Playwright element handle (used after semantic matching).
43
+ */
44
+ async function setFieldValueByHandle(page, elementHandle, value) {
45
+ try {
46
+ await page.evaluate(({ val }) => {
47
+ const el = arguments[0]; // handled via elementHandle approach below
48
+ }, { val: value });
49
+ // Use evaluate with the handle directly
50
+ await elementHandle.evaluate((el, val) => {
51
+ el.focus();
52
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
53
+ || Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
54
+ if (nativeInputValueSetter) nativeInputValueSetter.call(el, val);
55
+ el.dispatchEvent(new Event('focus', { bubbles: true }));
56
+ el.dispatchEvent(new Event('input', { bubbles: true }));
57
+ el.dispatchEvent(new Event('change', { bubbles: true }));
58
+ el.dispatchEvent(new Event('blur', { bubbles: true }));
59
+ }, value);
60
+ return true;
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Type text character by character (for textareas like cover letters).
68
+ * Much more human-looking than setting value atomically.
69
+ */
70
+ async function typeFieldValue(page, selector, value, speed = 'normal') {
71
+ const delay = TYPING_SPEEDS[speed] || 40;
72
+ try {
73
+ const el = page.locator(selector).first();
74
+ await el.waitFor({ timeout: 3000 });
75
+ await el.click();
76
+ await el.fill(''); // clear first
77
+ await page.keyboard.type(value, { delay: jitter(delay) });
78
+ await el.dispatchEvent('change');
79
+ return true;
80
+ } catch {
81
+ // Fallback to setFieldValue
82
+ return setFieldValue(page, selector, value);
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Fill a native <select> dropdown.
88
+ */
89
+ async function setSelectValue(page, selector, value) {
90
+ try {
91
+ await page.locator(selector).first().selectOption({ label: value }).catch(() =>
92
+ page.locator(selector).first().selectOption({ value })
93
+ );
94
+ return true;
95
+ } catch {
96
+ // Try clicking the option via custom React dropdown
97
+ return clickDropdownOption(page, selector, value);
98
+ }
99
+ }
100
+
101
+ /**
102
+ * For custom React dropdowns: click trigger, then click matching option.
103
+ */
104
+ async function clickDropdownOption(page, triggerSelector, optionText) {
105
+ try {
106
+ await page.locator(triggerSelector).first().click();
107
+ await page.waitForTimeout(400);
108
+ // Look for option by text in any listbox or menu element
109
+ const optionEl = page.getByRole('option', { name: optionText }).or(
110
+ page.locator(`[role="menuitem"]:has-text("${optionText}")`).or(
111
+ page.locator(`li:has-text("${optionText}")`)
112
+ )
113
+ ).first();
114
+ await optionEl.waitFor({ timeout: 2000 });
115
+ await optionEl.click();
116
+ return true;
117
+ } catch {
118
+ return false;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Click a radio button by group name and option label text.
124
+ */
125
+ async function selectRadioOption(page, groupName, value) {
126
+ try {
127
+ // Try to find a radio input with matching name and value
128
+ const byValue = page.locator(`input[type="radio"][name="${groupName}"][value="${value}" i]`).first();
129
+ if (await byValue.isVisible({ timeout: 500 }).catch(() => false)) {
130
+ await byValue.click();
131
+ return true;
132
+ }
133
+
134
+ // Fall back: find all radios in the group, click the one whose label matches
135
+ const radios = await page.locator(`input[type="radio"][name="${groupName}"]`).all();
136
+ for (const radio of radios) {
137
+ const label = await radio.evaluate(el => {
138
+ // Check label element
139
+ if (el.id) {
140
+ const lbl = document.querySelector(`label[for="${el.id}"]`);
141
+ if (lbl) return lbl.textContent.trim();
142
+ }
143
+ // Walk up to find label text
144
+ let p = el.parentElement;
145
+ let depth = 0;
146
+ while (p && depth < 4) {
147
+ const lbl = p.querySelector('label, span, div');
148
+ if (lbl && lbl !== el) {
149
+ const t = lbl.textContent.trim();
150
+ if (t.length > 0 && t.length < 80) return t;
151
+ }
152
+ p = p.parentElement;
153
+ depth++;
154
+ }
155
+ return el.value || '';
156
+ });
157
+ if (label.toLowerCase().includes(value.toLowerCase()) || value.toLowerCase().includes(label.toLowerCase())) {
158
+ await radio.click();
159
+ return true;
160
+ }
161
+ }
162
+ return false;
163
+ } catch {
164
+ return false;
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Click a checkbox by its label or value.
170
+ */
171
+ async function clickCheckbox(page, label, value) {
172
+ try {
173
+ const checkboxes = await page.locator('input[type="checkbox"]').all();
174
+ for (const cb of checkboxes) {
175
+ const cbLabel = await cb.evaluate(el => {
176
+ if (el.id) {
177
+ const lbl = document.querySelector(`label[for="${el.id}"]`);
178
+ if (lbl) return lbl.textContent.trim();
179
+ }
180
+ let p = el.parentElement;
181
+ let depth = 0;
182
+ while (p && depth < 4) {
183
+ const t = p.textContent.trim();
184
+ if (t.length > 0 && t.length < 100) return t;
185
+ p = p.parentElement;
186
+ depth++;
187
+ }
188
+ return el.value || '';
189
+ });
190
+
191
+ const target = (label || value || '').toLowerCase();
192
+ if (target && cbLabel.toLowerCase().includes(target)) {
193
+ const isChecked = await cb.isChecked();
194
+ if (!isChecked) await cb.click();
195
+ return true;
196
+ }
197
+ }
198
+ return false;
199
+ } catch {
200
+ return false;
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Upload a resume PDF using Playwright's fileChooser interception.
206
+ * Works even when the file input is hidden behind a custom button.
207
+ */
208
+ async function uploadFile(page, triggerSelector, filePath) {
209
+ try {
210
+ const [fileChooser] = await Promise.all([
211
+ page.waitForFileChooser({ timeout: 5000 }),
212
+ page.locator(triggerSelector).first().click().catch(() => {}),
213
+ ]);
214
+ await fileChooser.setFiles(filePath);
215
+ return true;
216
+ } catch {
217
+ // Fallback: direct setInputFiles on any file input
218
+ try {
219
+ await page.locator('input[type="file"]').first().setInputFiles(filePath);
220
+ return true;
221
+ } catch {
222
+ return false;
223
+ }
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Universal field filler — given a Playwright locator and a string value,
229
+ * detects the field type and applies the right fill strategy.
230
+ *
231
+ * Handles all field types that appear in real job applications:
232
+ * - Native <select> dropdown
233
+ * - Custom dropdown (React Select, Ashby combobox — role="combobox" or role="listbox")
234
+ * - Radio group (native input[type=radio] or custom role="radio")
235
+ * - Checkbox (native or custom role="checkbox")
236
+ * - Yes/No button group (styled buttons acting as a radio group)
237
+ * - Textarea / long text
238
+ * - Short text input (text, email, tel, number, url)
239
+ * - Date input
240
+ *
241
+ * Returns true if fill succeeded.
242
+ */
243
+ async function fillLocator(page, locator, value, label) {
244
+ if (!value) return false;
245
+
246
+ try {
247
+ // Inspect the element to determine field type
248
+ const meta = await locator.evaluate(el => ({
249
+ tag: el.tagName.toLowerCase(),
250
+ type: (el.type || '').toLowerCase(),
251
+ role: (el.getAttribute('role') || '').toLowerCase(),
252
+ isContentEditable: el.isContentEditable,
253
+ hasOptions: el.tagName === 'SELECT',
254
+ optionCount: el.tagName === 'SELECT' ? el.options.length : 0,
255
+ })).catch(() => ({ tag: 'input', type: 'text', role: '', isContentEditable: false, hasOptions: false, optionCount: 0 }));
256
+
257
+ // ── Native <select> ──
258
+ if (meta.tag === 'select' || meta.hasOptions) {
259
+ const opts = await locator.evaluate(e => Array.from(e.options).map(o => ({ v: o.value, t: o.text.trim() }))).catch(() => []);
260
+ // 1. Exact label match
261
+ let ok = await locator.selectOption({ label: value }).then(() => true).catch(() => false);
262
+ // 2. Exact value match
263
+ if (!ok) ok = await locator.selectOption({ value }).then(() => true).catch(() => false);
264
+ // 3. Case-insensitive label match
265
+ if (!ok) {
266
+ const match = opts.find(o => o.t.toLowerCase() === value.toLowerCase());
267
+ if (match) ok = await locator.selectOption(match.v).then(() => true).catch(() => false);
268
+ }
269
+ // 4. Fuzzy: value is substring of option text or vice versa
270
+ if (!ok) {
271
+ const fuzzy = opts.find(o =>
272
+ o.t.toLowerCase().includes(value.toLowerCase()) ||
273
+ value.toLowerCase().includes(o.t.toLowerCase())
274
+ );
275
+ if (fuzzy) ok = await locator.selectOption(fuzzy.v).then(() => true).catch(() => false);
276
+ }
277
+ return ok;
278
+ }
279
+
280
+ // ── Custom dropdown / combobox (React Select, Ashby, Lever custom selects) ──
281
+ if (meta.role === 'combobox' || meta.role === 'listbox' ||
282
+ meta.tag === 'div' || meta.tag === 'button') {
283
+ return await fillCustomDropdown(page, locator, value);
284
+ }
285
+
286
+ // ── Radio group (native) ──
287
+ if (meta.type === 'radio') {
288
+ // locator points to one radio — find its group and select by value
289
+ const groupName = await locator.evaluate(e => e.name).catch(() => '');
290
+ return await selectRadioOption(page, groupName, value);
291
+ }
292
+
293
+ // ── Checkbox (native) ──
294
+ if (meta.type === 'checkbox') {
295
+ const shouldCheck = /^(yes|true|1|on|agree|accept)$/i.test(value.trim());
296
+ const checked = await locator.isChecked().catch(() => false);
297
+ if (shouldCheck !== checked) await locator.click();
298
+ return true;
299
+ }
300
+
301
+ // ── Date input ──
302
+ if (meta.type === 'date') {
303
+ // Try to parse value into YYYY-MM-DD format
304
+ const parsed = parseDate(value);
305
+ return await locator.fill(parsed || value).then(() => true).catch(() => false);
306
+ }
307
+
308
+ // ── Number input ──
309
+ if (meta.type === 'number') {
310
+ const num = value.replace(/[^0-9.]/g, '');
311
+ return await locator.fill(num).then(() => {
312
+ return locator.dispatchEvent('change').then(() => true);
313
+ }).catch(() => false);
314
+ }
315
+
316
+ // ── contenteditable div (some ATS use these for rich text fields) ──
317
+ if (meta.isContentEditable) {
318
+ await locator.click();
319
+ await locator.evaluate(el => el.innerHTML = '');
320
+ await locator.type(value, { delay: 20 });
321
+ return true;
322
+ }
323
+
324
+ // ── Textarea / long text ──
325
+ if (meta.tag === 'textarea') {
326
+ await locator.fill(value);
327
+ await locator.dispatchEvent('input');
328
+ await locator.dispatchEvent('change');
329
+ return true;
330
+ }
331
+
332
+ // ── Default: short text input (text, email, tel, url, search, etc.) ──
333
+ return await setFieldValueByHandle(page, locator, value);
334
+
335
+ } catch (e) {
336
+ console.warn(`[filler] fillLocator error for "${label}": ${e.message}`);
337
+ return false;
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Fill a custom dropdown that is NOT a native <select>.
343
+ * Strategy: click trigger → wait for option list → find closest matching option → click it.
344
+ * Handles React Select, Ashby custom select, Lever, Greenhouse custom dropdowns.
345
+ */
346
+ async function fillCustomDropdown(page, triggerLocator, value) {
347
+ try {
348
+ await triggerLocator.click();
349
+ await page.waitForTimeout(350);
350
+
351
+ // Look for an open listbox, menu, or option list
352
+ const optionSelectors = [
353
+ `[role="option"]:has-text("${value}")`,
354
+ `[role="menuitem"]:has-text("${value}")`,
355
+ `li:has-text("${value}")`,
356
+ `.Select-option:has-text("${value}")`,
357
+ `[class*="option"]:has-text("${value}")`,
358
+ ];
359
+
360
+ for (const sel of optionSelectors) {
361
+ try {
362
+ const opt = page.locator(sel).first();
363
+ if (await opt.isVisible({ timeout: 800 })) {
364
+ await opt.click();
365
+ return true;
366
+ }
367
+ } catch {}
368
+ }
369
+
370
+ // Fuzzy: get all visible options and find closest match
371
+ const allOptions = await page.locator('[role="option"], [role="menuitem"]').all();
372
+ let bestMatch = null;
373
+ let bestScore = 0;
374
+ for (const opt of allOptions) {
375
+ const text = (await opt.textContent().catch(() => '')).trim().toLowerCase();
376
+ const target = value.toLowerCase();
377
+ const score = text === target ? 1 :
378
+ text.includes(target) ? 0.8 :
379
+ target.includes(text) ? 0.7 : 0;
380
+ if (score > bestScore) { bestScore = score; bestMatch = opt; }
381
+ }
382
+ if (bestMatch && bestScore >= 0.7) {
383
+ await bestMatch.click();
384
+ return true;
385
+ }
386
+
387
+ // Nothing found — press Escape to close the dropdown
388
+ await page.keyboard.press('Escape');
389
+ return false;
390
+ } catch {
391
+ return false;
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Parse a human date string into YYYY-MM-DD for date inputs.
397
+ */
398
+ function parseDate(value) {
399
+ try {
400
+ const d = new Date(value);
401
+ if (isNaN(d.getTime())) return null;
402
+ return d.toISOString().split('T')[0];
403
+ } catch { return null; }
404
+ }
405
+
406
+ /**
407
+ * Find the best selector for a field given its label, id, name, or placeholder.
408
+ * Tries multiple strategies in order of reliability.
409
+ */
410
+ function buildSelector(field) {
411
+ if (field.id) return `#${CSS.escape(field.id)}`;
412
+ if (field.name) return `[name="${field.name}"]`;
413
+ // Label-based
414
+ const label = (field.label || '').toLowerCase();
415
+ if (label) {
416
+ if (field.tag === 'textarea') return `textarea[placeholder*="${field.label}"], textarea[aria-label*="${field.label}"]`;
417
+ return `input[placeholder*="${field.label}"], input[aria-label*="${field.label}"]`;
418
+ }
419
+ return null;
420
+ }
421
+
422
+ // Escape CSS special chars for querySelector
423
+ const CSS = { escape: (s) => s.replace(/([!"#$%&'()*+,.\/:;<=>?@\[\\\]^`{|}~])/g, '\\$1') };
424
+
425
+ /**
426
+ * Extract all visible form fields from the current page via DOM walking.
427
+ * Uses the same 6-tier label extraction as the Chrome extension (content.js).
428
+ * Returns [{tag, type, id, name, label, value}] for all visible fields.
429
+ */
430
+ async function extractPageFields(page) {
431
+ return page.evaluate(() => {
432
+ function getFieldLabel(el) {
433
+ // 1. Native <label for="..."> association
434
+ if (el.id) {
435
+ var lbl = document.querySelector('label[for="' + el.id + '"]');
436
+ if (lbl) return lbl.textContent.trim();
437
+ }
438
+ if (el.labels && el.labels[0]) return el.labels[0].textContent.trim();
439
+ // 2. aria-label / aria-labelledby
440
+ var ariaLabel = el.getAttribute('aria-label');
441
+ if (ariaLabel) return ariaLabel.trim();
442
+ var ariaLabelledBy = el.getAttribute('aria-labelledby');
443
+ if (ariaLabelledBy) {
444
+ var lblEl = document.getElementById(ariaLabelledBy);
445
+ if (lblEl) return lblEl.textContent.trim();
446
+ }
447
+ // 3. data-label / data-title
448
+ if (el.dataset && el.dataset.label) return el.dataset.label.trim();
449
+ if (el.dataset && el.dataset.title) return el.dataset.title.trim();
450
+ // 4. Immediately preceding sibling label/span/div
451
+ var prev = el.previousElementSibling;
452
+ while (prev) {
453
+ if (/LABEL|SPAN|DIV|P|LEGEND|H1|H2|H3|H4|H5/.test(prev.tagName)) {
454
+ var t = prev.textContent.trim();
455
+ if (t.length > 0 && t.length < 120) return t;
456
+ }
457
+ prev = prev.previousElementSibling;
458
+ }
459
+ // 5. Walk up DOM — find nearest container with a label/legend/heading
460
+ var parent = el.parentElement;
461
+ var depth = 0;
462
+ while (parent && depth < 6) {
463
+ var lbl2 = parent.querySelector('label, legend, [class*=label], [class*=title], [class*=question], [class*=heading]');
464
+ if (lbl2 && lbl2 !== el) {
465
+ var t2 = lbl2.textContent.trim();
466
+ if (t2.length > 0 && t2.length < 120) return t2;
467
+ }
468
+ parent = parent.parentElement;
469
+ depth++;
470
+ }
471
+ // 6. Placeholder / name fallback
472
+ return el.placeholder || el.name || el.id || '';
473
+ }
474
+
475
+ var fields = [];
476
+ document.querySelectorAll('input, textarea, select').forEach(function(el) {
477
+ if (el.type === 'hidden' || el.type === 'submit' || el.type === 'file' || el.type === 'button' || el.type === 'image') return;
478
+ if (!el.offsetParent && el.type !== 'radio' && el.type !== 'checkbox') return;
479
+
480
+ var label = getFieldLabel(el);
481
+ label = label.replace(/\s+/g, ' ').replace(/^\*\s*/, '').replace(/\s*\*$/, '').trim();
482
+
483
+ fields.push({
484
+ tag: el.tagName.toLowerCase(),
485
+ type: el.type || '',
486
+ id: el.id || '',
487
+ name: el.name || '',
488
+ label: label.slice(0, 200),
489
+ value: el.value || '',
490
+ // Build a unique data-fingerprint so we can locate this element after evaluate()
491
+ // We use a combo of id, name, and label for matching
492
+ });
493
+ });
494
+ return fields;
495
+ });
496
+ }
497
+
498
+ /**
499
+ * Compute Jaccard word-level similarity between two strings.
500
+ * Used for semantic field matching when exact selectors fail.
501
+ */
502
+ function jaccardSimilarity(a, b) {
503
+ const normalize = s => s.toLowerCase().replace(/[^a-z0-9\s]/g, '').split(/\s+/).filter(w => w.length > 2);
504
+ const setA = new Set(normalize(a));
505
+ const setB = new Set(normalize(b));
506
+ if (setA.size === 0 || setB.size === 0) return 0;
507
+ const intersection = [...setA].filter(w => setB.has(w)).length;
508
+ const union = new Set([...setA, ...setB]).size;
509
+ return intersection / union;
510
+ }
511
+
512
+ /**
513
+ * 3-tier semantic field finder.
514
+ * Tier 1: exact ID/name match
515
+ * Tier 2: aria-label/placeholder substring match
516
+ * Tier 3: full DOM walking label extraction with Jaccard similarity scoring
517
+ *
518
+ * Returns a Playwright Locator or null.
519
+ */
520
+ async function semanticFindField(page, fieldAnswer) {
521
+ const { field_id, label } = fieldAnswer;
522
+
523
+ // Tier 1: exact ID or name match
524
+ if (field_id && !field_id.startsWith('sa_')) {
525
+ const escaped = CSS.escape(field_id);
526
+ try {
527
+ const el = page.locator(`#${escaped}, [name="${field_id}"]`).first();
528
+ if (await el.isVisible({ timeout: 500 })) return el;
529
+ } catch {}
530
+ }
531
+
532
+ // Tier 2: aria-label / placeholder substring match
533
+ if (label) {
534
+ const escapedLabel = label.replace(/['"]/g, '').trim();
535
+ if (escapedLabel) {
536
+ try {
537
+ const sel = `textarea[aria-label*="${escapedLabel}" i], input[aria-label*="${escapedLabel}" i], textarea[placeholder*="${escapedLabel}" i], input[placeholder*="${escapedLabel}" i], select[aria-label*="${escapedLabel}" i]`;
538
+ const el = page.locator(sel).first();
539
+ if (await el.isVisible({ timeout: 500 })) return el;
540
+ } catch {}
541
+ }
542
+ }
543
+
544
+ // Tier 3: full DOM walk + Jaccard similarity across all visible fields
545
+ if (label) {
546
+ try {
547
+ const pageFields = await extractPageFields(page);
548
+ let bestScore = 0;
549
+ let bestField = null;
550
+
551
+ for (const pf of pageFields) {
552
+ const score = jaccardSimilarity(label, pf.label);
553
+ if (score > bestScore) {
554
+ bestScore = score;
555
+ bestField = pf;
556
+ }
557
+ }
558
+
559
+ if (bestScore >= 0.4 && bestField) {
560
+ // Try to locate this element by its id/name/label combination
561
+ let locator = null;
562
+
563
+ if (bestField.id) {
564
+ try {
565
+ const escaped = CSS.escape(bestField.id);
566
+ const el = page.locator(`#${escaped}`).first();
567
+ if (await el.isVisible({ timeout: 300 })) locator = el;
568
+ } catch {}
569
+ }
570
+
571
+ if (!locator && bestField.name) {
572
+ try {
573
+ const el = page.locator(`[name="${bestField.name}"]`).first();
574
+ if (await el.isVisible({ timeout: 300 })) locator = el;
575
+ } catch {}
576
+ }
577
+
578
+ if (!locator && bestField.label) {
579
+ const escapedBest = bestField.label.replace(/['"]/g, '').trim();
580
+ if (escapedBest) {
581
+ try {
582
+ // Include select elements here — dropdowns matched via Tier3 Jaccard
583
+ const sel = `textarea[aria-label*="${escapedBest}" i], input[aria-label*="${escapedBest}" i], textarea[placeholder*="${escapedBest}" i], input[placeholder*="${escapedBest}" i], select[aria-label*="${escapedBest}" i]`;
584
+ const el = page.locator(sel).first();
585
+ if (await el.isVisible({ timeout: 300 })) locator = el;
586
+ } catch {}
587
+ // Final fallback: getByLabel for fields like Greenhouse dropdowns
588
+ if (!locator) {
589
+ try {
590
+ const el = page.getByLabel(new RegExp(escapedBest.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i')).first();
591
+ if (await el.isVisible({ timeout: 300 })) locator = el;
592
+ } catch {}
593
+ }
594
+ }
595
+ }
596
+
597
+ if (locator) {
598
+ const confidence = bestScore >= 0.7 ? 'high' : 'medium';
599
+ console.log(`[filler] Tier3 match (${confidence}, score=${bestScore.toFixed(2)}): "${label}" -> "${bestField.label}"`);
600
+ return locator;
601
+ }
602
+ }
603
+ } catch (e) {
604
+ console.warn(`[filler] Tier3 DOM walk failed: ${e.message}`);
605
+ }
606
+ }
607
+
608
+ console.log(`[filler] unmatched: "${label}"`);
609
+ return null;
610
+ }
611
+
612
+ /**
613
+ * Fill all fields on the current page using per-ATS scanPage() strategy.
614
+ *
615
+ * Pass 1: scanPage() identifies every visible field and classifies it as profile/custom/eeo/file.
616
+ * Pass 2: resolveFieldValues() maps each field to a value from the AEP (profile or AI answers).
617
+ * Pass 3: Fields with value=null and source='needs_ai' are returned in result.needsAI so the
618
+ * orchestrator can fetch AI answers and call fillFields again.
619
+ *
620
+ * Returns { filled, skipped, failed, needsAI: [{label, selector, tag, inputType}] }.
621
+ */
622
+ async function fillFields(page, aep, options = {}) {
623
+ const speed = options.speed || 'normal';
624
+ const ctx = options.ctx || null;
625
+ const ats = options.ats || 'generic';
626
+ const delay = () => new Promise(r => setTimeout(r, jitter(100 + Math.random() * 150)));
627
+
628
+ let filled = 0, skipped = 0, failed = 0;
629
+ const needsAI = [];
630
+
631
+ // ── Pass 1: scan all visible fields using per-ATS logic ───────────────────
632
+ const { scanPage, resolveFieldValues } = require('./scanPage');
633
+ const scannedFields = await scanPage(page, ats).catch(() => []);
634
+
635
+ // ── Pass 2: resolve values from AEP ──────────────────────────────────────
636
+ const resolved = resolveFieldValues(scannedFields, aep);
637
+
638
+ // ── Pass 3: fill each field ───────────────────────────────────────────────
639
+ for (const { field, value, source } of resolved) {
640
+ // Skip if already answered on a previous page
641
+ const trackKey = field.label || field.selector;
642
+ if (ctx && trackKey && ctx.answeredFields.has(trackKey)) {
643
+ skipped++;
644
+ continue;
645
+ }
646
+
647
+ // File uploads — handled separately by uploadResume() in orchestrator
648
+ if (source === 'file_upload') continue;
649
+
650
+ // EEO / consent — skip silently (vision handles EEO, user handles consent)
651
+ if (source === 'skip') continue;
652
+
653
+ // No value and needs AI — collect for batch AI fetch
654
+ if (!value && source === 'needs_ai') {
655
+ if (field.label && field.label.length >= 8) {
656
+ needsAI.push({ label: field.label, selector: field.selector, tag: field.tag, inputType: field.inputType, iframeSelector: field.iframeSelector });
657
+ } else {
658
+ skipped++;
659
+ }
660
+ continue;
661
+ }
662
+
663
+ // No value for a profile field — missing from profile, skip
664
+ if (!value) { skipped++; continue; }
665
+
666
+ // Locate the element — use the scanner's selector first, then fall back to semantic finder
667
+ let locator = null;
668
+
669
+ // If the field is inside an iframe (iCIMS), use frameLocator
670
+ if (field.iframeSelector) {
671
+ try {
672
+ const frame = page.frameLocator(field.iframeSelector);
673
+ locator = frame.locator(field.selector).first();
674
+ const visible = await locator.isVisible({ timeout: 500 }).catch(() => false);
675
+ if (!visible) locator = null;
676
+ } catch {}
677
+ }
678
+
679
+ // Standard locator via scanner selector
680
+ if (!locator && field.selector) {
681
+ try {
682
+ const el = page.locator(field.selector).first();
683
+ const visible = await el.isVisible({ timeout: 500 }).catch(() => false);
684
+ if (visible) locator = el;
685
+ } catch {}
686
+ }
687
+
688
+ // Fallback: semantic finder via label text
689
+ if (!locator && field.label) {
690
+ locator = await semanticFindField(page, { field_id: field.id || field.name, label: field.label }).catch(() => null);
691
+ }
692
+
693
+ if (!locator) { skipped++; continue; }
694
+
695
+ // Special case: cover letter — type character by character
696
+ if (field.category === 'cover_letter' && field.tag === 'textarea') {
697
+ try {
698
+ await locator.click();
699
+ await locator.fill('');
700
+ await page.keyboard.type(value, { delay: jitter(TYPING_SPEEDS[speed] || 40) });
701
+ await locator.dispatchEvent('change');
702
+ filled++;
703
+ if (ctx && trackKey) ctx.answeredFields.set(trackKey, { value, pageIndex: ctx.currentPageIndex, source });
704
+ await delay();
705
+ } catch { failed++; }
706
+ continue;
707
+ }
708
+
709
+ // Radio group — use name-based selector
710
+ if (field.inputType === 'radio') {
711
+ const ok = await selectRadioOption(page, field.name, value);
712
+ if (ok) {
713
+ filled++;
714
+ if (ctx && trackKey) ctx.answeredFields.set(trackKey, { value, pageIndex: ctx.currentPageIndex, source });
715
+ await delay();
716
+ } else {
717
+ // Try clicking the locator directly (custom styled radio)
718
+ const ok2 = await fillLocator(page, locator, value, field.label);
719
+ if (ok2) { filled++; if (ctx && trackKey) ctx.answeredFields.set(trackKey, { value, pageIndex: ctx.currentPageIndex, source }); await delay(); }
720
+ else { skipped++; }
721
+ }
722
+ continue;
723
+ }
724
+
725
+ // Checkbox
726
+ if (field.inputType === 'checkbox') {
727
+ const shouldCheck = /^(yes|true|1|on|agree|accept)$/i.test(value.trim());
728
+ try {
729
+ const checked = await locator.isChecked().catch(() => false);
730
+ if (shouldCheck !== checked) { await locator.click(); }
731
+ filled++;
732
+ if (ctx && trackKey) ctx.answeredFields.set(trackKey, { value, pageIndex: ctx.currentPageIndex, source });
733
+ await delay();
734
+ } catch { skipped++; }
735
+ continue;
736
+ }
737
+
738
+ // All other types — use universal fillLocator
739
+ try {
740
+ const ok = await fillLocator(page, locator, value, field.label);
741
+ if (ok) {
742
+ filled++;
743
+ if (ctx && trackKey) ctx.answeredFields.set(trackKey, { value, pageIndex: ctx.currentPageIndex, source });
744
+ await delay();
745
+ } else { failed++; }
746
+ } catch { failed++; }
747
+ }
748
+
749
+ // ── Legacy fallback: also run old profile map + semantic matcher ──────────
750
+ // This catches any fields that scanPage missed (e.g. unusual ATS not yet modeled).
751
+ const profileMap = buildProfileMap(aep.profile_fill);
752
+ for (const [labelPattern, value] of Object.entries(profileMap)) {
753
+ if (!value) continue;
754
+ if (ctx && ctx.answeredFields.has(labelPattern)) continue;
755
+ let didFill = false;
756
+
757
+ try {
758
+ const sel = buildProfileSelector(labelPattern);
759
+ if (sel) {
760
+ const el = page.locator(sel).first();
761
+ if (await el.isVisible().catch(() => false)) {
762
+ const ok = await fillLocator(page, el, value, labelPattern);
763
+ if (ok) { didFill = true; filled++; }
764
+ }
765
+ }
766
+ } catch {}
767
+
768
+ if (!didFill) {
769
+ for (const part of labelPattern.split('|').map(p => p.trim())) {
770
+ if (didFill) break;
771
+ try {
772
+ const ok = await fillProfileFieldByLabel(page, part, value, speed);
773
+ if (ok) { didFill = true; filled++; }
774
+ } catch {}
775
+ }
776
+ }
777
+
778
+ if (didFill && ctx) {
779
+ ctx.answeredFields.set(labelPattern, { value, pageIndex: ctx.currentPageIndex, source: 'profile' });
780
+ }
781
+ }
782
+
783
+ return { filled, skipped, failed, needsAI };
784
+ }
785
+
786
+ /**
787
+ * Build a CSS selector from a label string for textareas and inputs.
788
+ */
789
+ function buildLabelSelector(label) {
790
+ if (!label) return null;
791
+ const escaped = label.replace(/['"]/g, '').trim();
792
+ return `textarea[aria-label*="${escaped}" i], input[aria-label*="${escaped}" i], textarea[placeholder*="${escaped}" i], input[placeholder*="${escaped}" i]`;
793
+ }
794
+
795
+ /**
796
+ * Map profile_fill keys to CSS selector patterns.
797
+ */
798
+ function buildProfileMap(profile) {
799
+ if (!profile) return {};
800
+ const phone = profile.phone || '';
801
+ const email = profile.email || '';
802
+ const fullName = [profile.first_name, profile.last_name].filter(Boolean).join(' ');
803
+ return {
804
+ // Keys are pipe-separated substrings matched against label text (case-insensitive)
805
+ // Full name (Lever, Ashby) — must come before first/last to avoid partial matches
806
+ 'full name|full_name|your name|^name$': fullName,
807
+ 'first_name|firstname|first-name|first name|given name': profile.first_name,
808
+ 'last_name|lastname|last-name|last name|surname|family name': profile.last_name,
809
+ 'preferred|goes by': profile.preferred_name || profile.first_name || '',
810
+ 'email': email,
811
+ 'phone|mobile|telephone|cell': phone,
812
+ 'linkedin': profile.linkedin,
813
+ 'github': profile.github,
814
+ 'twitter': profile.twitter || '',
815
+ 'portfolio|personal site|personal url': profile.portfolio || '',
816
+ 'website': profile.portfolio || profile.website || '',
817
+ 'school|university|college|institution': profile.school || '',
818
+ 'degree|education|qualification': profile.degree || '',
819
+ 'gpa|grade point': profile.gpa || '',
820
+ 'address|street': profile.address || '',
821
+ 'city': profile.city || '',
822
+ 'state|province|region': profile.state || '',
823
+ 'zip|postal': profile.zip || '',
824
+ 'country': profile.country || 'United States',
825
+ 'location|current location': profile.city || profile.location || '',
826
+ 'current company|current employer|organization|employer': profile.current_company || '',
827
+ 'desired salary|expected salary|salary expectation': profile.desired_salary || '',
828
+ 'start date|available|availability': profile.start_date || 'Immediately',
829
+ // Combined phone+email (Ashby contact field pattern)
830
+ 'phone.*email|email.*phone|contact.*reach|reach.*contact': phone && email ? `${phone} | ${email}` : (phone || email),
831
+ };
832
+ }
833
+
834
+ /**
835
+ * Fill a single profile field using label-text matching via DOM walk.
836
+ * More reliable than attribute-only selectors for Greenhouse/Ashby where
837
+ * inputs have no aria-label but have a visible <label> element.
838
+ */
839
+ async function fillProfileFieldByLabel(page, labelText, value, speed) {
840
+ if (!value) return false;
841
+ try {
842
+ const el = page.getByLabel(new RegExp(labelText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i')).first();
843
+ if (!await el.isVisible({ timeout: 600 }).catch(() => false)) return false;
844
+ // Route through universal fillLocator — handles all field types correctly
845
+ return await fillLocator(page, el, value, labelText);
846
+ } catch { return false; }
847
+ }
848
+
849
+ function buildProfileSelector(labelPattern) {
850
+ // Build selectors for both input and select elements using id/name/aria-label/placeholder
851
+ const parts = labelPattern.split('|').map(p => p.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
852
+ const selectors = [];
853
+ for (const p of parts) {
854
+ // Match on id, name, aria-label, placeholder for both input and select
855
+ selectors.push(
856
+ `input[name*="${p}" i]`, `input[id*="${p}" i]`,
857
+ `input[aria-label*="${p}" i]`, `input[placeholder*="${p}" i]`,
858
+ `select[name*="${p}" i]`, `select[id*="${p}" i]`, `select[aria-label*="${p}" i]`,
859
+ `textarea[name*="${p}" i]`, `textarea[id*="${p}" i]`,
860
+ `textarea[aria-label*="${p}" i]`, `textarea[placeholder*="${p}" i]`,
861
+ );
862
+ }
863
+ return selectors.join(', ');
864
+ }
865
+
866
+ /**
867
+ * Find the "Next" or "Continue" button on a multi-page form.
868
+ */
869
+ async function findNextButton(page) {
870
+ const selectors = [
871
+ // Workday-specific
872
+ '[data-automation-id="bottom-navigation-next-button"]',
873
+ '[data-automation-id="next-button"]',
874
+ // Generic
875
+ 'button:has-text("Next")',
876
+ 'button:has-text("Continue")',
877
+ 'button:has-text("Save and Continue")',
878
+ 'button:has-text("Save & Continue")',
879
+ 'button[type="submit"]:has-text("Next")',
880
+ '[data-testid="next-button"]',
881
+ '[aria-label="Next"]',
882
+ '[aria-label="Continue"]',
883
+ ];
884
+ for (const sel of selectors) {
885
+ try {
886
+ const el = page.locator(sel).first();
887
+ if (await el.isVisible({ timeout: 500 })) {
888
+ const text = (await el.textContent().catch(() => '')).toLowerCase().trim();
889
+ if (text.includes('submit')) continue; // don't treat Submit as Next
890
+ return el;
891
+ }
892
+ } catch {}
893
+ }
894
+ return null;
895
+ }
896
+
897
+ /**
898
+ * Find the final "Submit" button on a review/submit page.
899
+ * Tries many patterns to handle SmartRecruiters, Greenhouse, Ashby, Lever, Workday, etc.
900
+ */
901
+ async function findSubmitButton(page) {
902
+ // Ordered from most specific to most generic
903
+ const selectors = [
904
+ // Workday-specific
905
+ '[data-automation-id="bottom-navigation-next-button"]:has-text("Submit")',
906
+ '[data-automation-id="submit-button"]',
907
+ '[data-automation-id="review-submit-button"]',
908
+ // Generic
909
+ 'button:has-text("Submit application")',
910
+ 'button:has-text("Submit Application")',
911
+ 'button:has-text("Submit My Application")',
912
+ 'button:has-text("Submit")',
913
+ 'button:has-text("Apply Now")',
914
+ 'button:has-text("Send Application")',
915
+ 'button:has-text("Complete Application")',
916
+ 'button:has-text("Finish")',
917
+ 'button:has-text("Review and Submit")',
918
+ '[data-testid="submit-button"]',
919
+ '[data-testid="apply-button"]',
920
+ '[aria-label*="submit" i]',
921
+ 'input[type="submit"]',
922
+ // Generic: any submit-type button that isn't navigation
923
+ 'button[type="submit"]',
924
+ 'form button:last-of-type',
925
+ ];
926
+ for (const sel of selectors) {
927
+ try {
928
+ const el = page.locator(sel).first();
929
+ if (await el.isVisible({ timeout: 500 })) {
930
+ // Skip if it looks like a Next/Continue button
931
+ const text = (await el.textContent().catch(() => '')).toLowerCase().trim();
932
+ if (text.includes('next') || text.includes('continue') || text.includes('save and') || text.includes('save &')) continue;
933
+ return el;
934
+ }
935
+ } catch {}
936
+ }
937
+ return null;
938
+ }
939
+
940
+ /**
941
+ * Wait for the DOM's form field count to stabilize (smart replacement for waitForTimeout).
942
+ * Stops polling as soon as the count has been stable for 2 consecutive 300ms intervals.
943
+ */
944
+ async function waitForStableDOM(page, maxWait = 4000) {
945
+ const start = Date.now();
946
+ let lastCount = -1;
947
+ let stableCount = 0;
948
+
949
+ while (Date.now() - start < maxWait) {
950
+ await page.waitForTimeout(300);
951
+ const count = await page.evaluate(() =>
952
+ document.querySelectorAll('input:not([type=hidden]):not([type=submit]), textarea, select').length
953
+ ).catch(() => 0);
954
+
955
+ if (count === lastCount && count > 0) {
956
+ stableCount++;
957
+ if (stableCount >= 2) break; // stable for 600ms — good enough
958
+ } else {
959
+ stableCount = 0;
960
+ }
961
+ lastCount = count;
962
+ }
963
+ }
964
+
965
+ /**
966
+ * Snapshot all current visible field labels on the page.
967
+ * Used by orchestrator to detect conditional fields that appear after clicking Next.
968
+ */
969
+ async function snapshotFieldLabels(page) {
970
+ const fields = await extractPageFields(page).catch(() => []);
971
+ return new Set(fields.map(f => f.label).filter(Boolean));
972
+ }
973
+
974
+ module.exports = {
975
+ fillFields,
976
+ uploadFile,
977
+ findNextButton,
978
+ findSubmitButton,
979
+ setFieldValue,
980
+ typeFieldValue,
981
+ extractPageFields,
982
+ semanticFindField,
983
+ waitForStableDOM,
984
+ snapshotFieldLabels,
985
+ selectRadioOption,
986
+ clickCheckbox,
987
+ };