halo-agent 2.0.2 → 2.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,188 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Post-submit form-error detector.
5
+ *
6
+ * The agent clicks Submit. Most ATSes (Greenhouse, Lever, Ashby, modern
7
+ * Workday) DON'T redirect on validation failure — they render inline:
8
+ * - red banner at the top
9
+ * - the failing field gets aria-invalid="true" + a class like .error
10
+ * - a sibling/child element gets the literal text "This field is required"
11
+ * or "Missing entry for ..."
12
+ *
13
+ * Firecrawl's verify-submit can detect these via LLM extract, but:
14
+ * 1. It's an external API round-trip (~3-8s)
15
+ * 2. It sometimes returns null when the page is JS-heavy or behind login
16
+ * 3. The DOM has this info FOR FREE, no LLM needed, in <100ms
17
+ *
18
+ * This module is the deterministic ground truth: walk the DOM, return a
19
+ * list of fields the form is currently complaining about. The orchestrator
20
+ * uses this to know whether to retry, regardless of what Firecrawl says.
21
+ *
22
+ * Output:
23
+ * {
24
+ * hasErrors: boolean,
25
+ * errorBanner: string|null, // top-level "form needs corrections" text
26
+ * invalidFields: [{ // one per highlighted field
27
+ * label, selector, mmid?, errorText
28
+ * }],
29
+ * }
30
+ */
31
+
32
+ const FIELD_ERROR_PATTERNS = [
33
+ /is\s*required/i,
34
+ /this\s*field\s*is\s*required/i,
35
+ /missing\s*entry/i,
36
+ /please\s*(enter|select|fill|provide)/i,
37
+ /required\s*field/i,
38
+ /cannot\s*be\s*blank/i,
39
+ /^required$/i,
40
+ ];
41
+
42
+ const BANNER_PATTERNS = [
43
+ /form\s*needs\s*corrections/i,
44
+ /please\s*correct/i,
45
+ /there\s*(was|were)\s*(an?|some)\s*errors?/i,
46
+ /please\s*review/i,
47
+ ];
48
+
49
+ async function detectFormErrors(page) {
50
+ return await page.evaluate(({ fieldPatterns, bannerPatterns }) => {
51
+ const fp = fieldPatterns.map((s) => new RegExp(s.source, s.flags));
52
+ const bp = bannerPatterns.map((s) => new RegExp(s.source, s.flags));
53
+
54
+ function visibleText(el) {
55
+ if (!el) return '';
56
+ // Only count text from visible elements
57
+ const rect = el.getBoundingClientRect();
58
+ if (rect.width === 0 && rect.height === 0) return '';
59
+ const t = (el.innerText || el.textContent || '').trim();
60
+ if (!t) return '';
61
+ return t.replace(/\s+/g, ' ').slice(0, 300);
62
+ }
63
+
64
+ function nearestLabel(el) {
65
+ // Strategy ladder for finding a field's human label:
66
+ // 1. <label for=id>
67
+ // 2. el.labels
68
+ // 3. aria-labelledby chain
69
+ // 4. parent's label/legend/h*
70
+ // 5. fallback to name / id
71
+ if (el.id) {
72
+ const lbl = document.querySelector(`label[for="${el.id}"]`);
73
+ if (lbl) return visibleText(lbl).replace(/\*$/, '').trim();
74
+ }
75
+ if (el.labels && el.labels[0]) return visibleText(el.labels[0]).replace(/\*$/, '').trim();
76
+ const al = el.getAttribute('aria-labelledby');
77
+ if (al) {
78
+ const t = al.split(/\s+/).map((id) => document.getElementById(id)).filter(Boolean).map(visibleText).join(' ').trim();
79
+ if (t) return t;
80
+ }
81
+ let p = el.parentElement; let hops = 0;
82
+ while (p && hops < 6) {
83
+ const lbl = p.querySelector('label, legend, h3, h4');
84
+ if (lbl && !lbl.contains(el)) {
85
+ const t = visibleText(lbl).replace(/\*$/, '').trim();
86
+ if (t && t.length < 200) return t;
87
+ }
88
+ p = p.parentElement;
89
+ hops += 1;
90
+ }
91
+ return el.getAttribute('aria-label') || el.placeholder || el.name || el.id || '(unknown)';
92
+ }
93
+
94
+ function bestSelector(el) {
95
+ const mmid = el.getAttribute('mmid');
96
+ if (mmid) return { mmid, selector: `[mmid="${mmid}"]` };
97
+ if (el.id) return { selector: `#${CSS.escape(el.id)}` };
98
+ if (el.name) return { selector: `[name="${CSS.escape(el.name)}"]` };
99
+ return { selector: '' };
100
+ }
101
+
102
+ // 1. Top-level error banner — used for the log + the NEEDS_ATTENTION reason.
103
+ let errorBanner = null;
104
+ const bannerCandidates = document.querySelectorAll('[role="alert"], .error-banner, [class*="error-message"], [class*="form-error"], [class*="errors"]');
105
+ for (const b of bannerCandidates) {
106
+ const t = visibleText(b);
107
+ if (!t) continue;
108
+ if (bp.some((r) => r.test(t)) || t.length > 30) {
109
+ errorBanner = t.slice(0, 300);
110
+ break;
111
+ }
112
+ }
113
+
114
+ // 2. Per-field flags. Three independent signals — union them:
115
+ // a. aria-invalid="true" set by the form's validator
116
+ // b. ancestor/sibling has the literal text "is required" near a field
117
+ // c. element matches a CSS error class
118
+ const invalidFields = [];
119
+ const seenMmids = new Set();
120
+
121
+ function addField(el, errorText) {
122
+ if (!el) return;
123
+ const sel = bestSelector(el);
124
+ const key = sel.mmid || sel.selector || el.outerHTML.slice(0, 80);
125
+ if (seenMmids.has(key)) return;
126
+ seenMmids.add(key);
127
+ invalidFields.push({
128
+ label: nearestLabel(el).slice(0, 200),
129
+ mmid: sel.mmid || null,
130
+ selector: sel.selector || null,
131
+ errorText: (errorText || 'flagged').slice(0, 200),
132
+ });
133
+ }
134
+
135
+ // Signal A: aria-invalid="true" — the cleanest, most reliable. Filter
136
+ // to actual inputs (not random divs).
137
+ document.querySelectorAll('[aria-invalid="true"]').forEach((el) => {
138
+ const tag = el.tagName.toLowerCase();
139
+ if (!['input', 'textarea', 'select'].includes(tag) && !el.isContentEditable && el.getAttribute('role') !== 'combobox') return;
140
+ // Find the error text near this field (sibling or descendant of parent)
141
+ let errTxt = '';
142
+ const parent = el.closest('div, fieldset, label, section');
143
+ if (parent) {
144
+ const errEl = parent.querySelector('[class*="error"], [class*="invalid"], [role="alert"]');
145
+ if (errEl && !errEl.contains(el)) errTxt = visibleText(errEl);
146
+ }
147
+ addField(el, errTxt || 'aria-invalid');
148
+ });
149
+
150
+ // Signal B: text-pattern walk. Any element containing required-pattern
151
+ // text → find the nearest fillable sibling/ancestor input. This catches
152
+ // Greenhouse's `<div class="application-error">Phone is required</div>`.
153
+ const allText = Array.from(document.querySelectorAll('div, span, p, small, li, label'));
154
+ for (const el of allText) {
155
+ const t = visibleText(el);
156
+ if (!t || t.length > 200) continue;
157
+ if (!fp.some((r) => r.test(t))) continue;
158
+ // Don't double-count error containers we already matched
159
+ if (el.querySelector('[aria-invalid="true"]')) continue;
160
+ // Find the nearest input by walking up + querying down
161
+ let cursor = el; let found = null; let hops = 0;
162
+ while (cursor && hops < 5 && !found) {
163
+ const parent = cursor.parentElement;
164
+ if (!parent) break;
165
+ found = parent.querySelector('input:not([type=hidden]), textarea, select, [contenteditable="true"], [role="combobox"]');
166
+ cursor = parent; hops += 1;
167
+ }
168
+ if (found) addField(found, t);
169
+ }
170
+
171
+ // Signal C: CSS error classes on the input itself. Some ATSes mark
172
+ // the input directly rather than the wrapper.
173
+ document.querySelectorAll('input[class*="error"], input[class*="invalid"], select[class*="error"], textarea[class*="error"]').forEach((el) => {
174
+ addField(el, 'has-error-class');
175
+ });
176
+
177
+ return {
178
+ hasErrors: errorBanner !== null || invalidFields.length > 0,
179
+ errorBanner,
180
+ invalidFields,
181
+ };
182
+ }, {
183
+ fieldPatterns: FIELD_ERROR_PATTERNS.map((r) => ({ source: r.source, flags: r.flags })),
184
+ bannerPatterns: BANNER_PATTERNS.map((r) => ({ source: r.source, flags: r.flags })),
185
+ });
186
+ }
187
+
188
+ module.exports = { detectFormErrors };
package/orchestrator.js CHANGED
@@ -13,6 +13,7 @@ const path = require('path');
13
13
  const fs = require('fs');
14
14
  const { fillFields: legacyFillFields, uploadFile, findNextButton, findSubmitButton, waitForStableDOM, snapshotFieldLabels } = require('./filler');
15
15
  const { smartFillPage } = require('./smartFill');
16
+ const { detectFormErrors } = require('./detectFormErrors');
16
17
 
17
18
  // Switchable filler — smart by default, can be killed via config.useSmartFill=false.
18
19
  // smartFill.js internally falls back to legacyFillFields if /smartfill/plan-fill
@@ -452,15 +453,90 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
452
453
  });
453
454
  await submitBtn.click();
454
455
 
455
- // Wait for confirmation page
456
+ // Wait for confirmation page OR for the form to render validation errors.
457
+ // Race the URL redirect against a stable-DOM wait — whichever resolves
458
+ // first tells us what happened. URL redirect → success path. Stable
459
+ // DOM (no redirect) → either validation error or in-page confirmation.
456
460
  try {
457
- await page.waitForURL(/thank|confirm|success|applied|submitted/i, { timeout: 15000 });
461
+ await Promise.race([
462
+ page.waitForURL(/thank|confirm|success|applied|submitted/i, { timeout: 12000 }),
463
+ waitForStableDOM(page, 4000),
464
+ ]);
458
465
  } catch {
459
- await page.waitForTimeout(3000); // fallback wait
466
+ await page.waitForTimeout(2000);
467
+ }
468
+
469
+ // ─────────────────────────────────────────────────────────────────────
470
+ // DOM-side error detection. The page IS the ground truth — Firecrawl
471
+ // is a second opinion at best, and unavailable at worst. If the page
472
+ // tells us right now that fields are invalid, retry IMMEDIATELY
473
+ // without waiting on Firecrawl.
474
+ //
475
+ // Bounded to ONE retry via ctx.submitRetryAttempted so we never
476
+ // infinite-loop on a truly stuck form.
477
+ // ─────────────────────────────────────────────────────────────────────
478
+ const errors = await detectFormErrors(page).catch(() => ({ hasErrors: false, errorBanner: null, invalidFields: [] }));
479
+ if (errors.hasErrors && !ctx.submitRetryAttempted) {
480
+ ctx.submitRetryAttempted = true;
481
+ const errFieldList = errors.invalidFields.map((f) => f.label).slice(0, 6).join(' / ') || '(banner only)';
482
+ console.warn(`[orchestrator] Form has ${errors.invalidFields.length} validation error(s): ${errFieldList}`);
483
+ if (errors.errorBanner) console.warn(`[orchestrator] banner: ${errors.errorBanner.slice(0, 200)}`);
484
+
485
+ await reportStatus('IN_PROGRESS', {
486
+ step: 'RETRY_FILL',
487
+ step_detail: `Form rejected: ${errors.errorBanner ? errors.errorBanner.slice(0, 80) : errFieldList.slice(0, 80)} — retrying`,
488
+ });
489
+
490
+ // Re-fill: smartFillPage will re-scan, so the planner sees the
491
+ // currently-invalid fields (aria-invalid="true" propagates into
492
+ // AX state, and the per-field error text becomes part of the
493
+ // description). Bounded retry.
494
+ try {
495
+ await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type, config, jobId });
496
+ await waitForStableDOM(page, 1500);
497
+ const retrySubmitBtn = await findSubmitButton(page);
498
+ if (retrySubmitBtn) {
499
+ console.log('[orchestrator] Retry: clicking submit again...');
500
+ await retrySubmitBtn.click();
501
+ try {
502
+ await Promise.race([
503
+ page.waitForURL(/thank|confirm|success|applied|submitted/i, { timeout: 10000 }),
504
+ waitForStableDOM(page, 3000),
505
+ ]);
506
+ } catch { await page.waitForTimeout(2000); }
507
+ } else {
508
+ console.warn('[orchestrator] Retry: submit button gone (form may have navigated).');
509
+ }
510
+ } catch (e) {
511
+ console.warn(`[orchestrator] Retry pass threw: ${e.message}`);
512
+ }
513
+ } else if (errors.hasErrors) {
514
+ // Already retried once; another error means the user has to step in.
515
+ console.warn(`[orchestrator] Form errors persist after retry: ${errors.invalidFields.length} field(s)`);
460
516
  }
461
517
 
462
518
  const confirmScreenshot = await page.screenshot({ type: 'jpeg', quality: 70 });
463
- const confirmKey = await uploadScreenshot(config, confirmScreenshot, `confirm_${queueId}.jpg`);
519
+ let confirmKey = await uploadScreenshot(config, confirmScreenshot, `confirm_${queueId}.jpg`);
520
+
521
+ // After retry: if the page STILL has errors, short-circuit to
522
+ // NEEDS_ATTENTION without bothering Firecrawl. We know it's broken.
523
+ const errorsAfter = ctx.submitRetryAttempted
524
+ ? await detectFormErrors(page).catch(() => ({ hasErrors: false, invalidFields: [], errorBanner: null }))
525
+ : { hasErrors: false, invalidFields: [], errorBanner: null };
526
+ if (errorsAfter.hasErrors) {
527
+ const reason = errorsAfter.errorBanner
528
+ || `${errorsAfter.invalidFields.length} field(s) still invalid: ${errorsAfter.invalidFields.map((f) => f.label).slice(0, 4).join(', ')}`;
529
+ console.warn(`[orchestrator] Submission rejected after retry: ${reason}`);
530
+ await reportStatus('NEEDS_ATTENTION', {
531
+ review_screenshot_r2_key: confirmKey || null,
532
+ needs_attention_reason: `Submit rejected by form: ${reason}`,
533
+ intervention_type: 'submit_failed',
534
+ step: 'VERIFY',
535
+ step_detail: reason.slice(0, 200),
536
+ fields_filled: cumulativeFilled,
537
+ });
538
+ throw new Error(`Submission rejected: ${reason}`);
539
+ }
464
540
 
465
541
  // Verify-then-DONE: trusting waitForURL alone was wrong (the Chalk bug —
466
542
  // Ashby rendered "Missing entry for required field: Name, Email, ..."
@@ -491,11 +567,17 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
491
567
  // screenshot — never silently mark DONE on
492
568
  // unverified submits (that was the Chalk bug).
493
569
  if (verdict.submitted === false) {
494
- const reason = verdict.error_message || 'Submission did not confirm form may still have errors';
495
- console.warn(`[orchestrator] Submission NOT verified. Reason: ${reason}`);
570
+ // The DOM-error pass above already retried + short-circuited if errors
571
+ // remained. If we got here AND Firecrawl is saying false, the DOM was
572
+ // clean (no aria-invalid / no banner / no "is required" text) but
573
+ // Firecrawl still detected something wrong — probably a thank-you
574
+ // page that includes some apologetic text the LLM misread. Surface
575
+ // it gently rather than throwing; user can audit the screenshot.
576
+ const reason = verdict.error_message || 'Firecrawl could not confirm submission';
577
+ console.warn(`[orchestrator] Firecrawl says NOT submitted (DOM looked clean): ${reason}`);
496
578
  await reportStatus('NEEDS_ATTENTION', {
497
579
  review_screenshot_r2_key: confirmKey || null,
498
- needs_attention_reason: `Submit clicked but Ashby/ATS rejected it: ${reason}`,
580
+ needs_attention_reason: `Verifier flagged this submit: ${reason}. Page DOM looked clean — please eyeball.`,
499
581
  intervention_type: 'submit_failed',
500
582
  step: 'VERIFY',
501
583
  step_detail: reason.slice(0, 200),
@@ -504,35 +586,55 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
504
586
  throw new Error(`Submission failed verification: ${reason}`);
505
587
  }
506
588
 
589
+ // Decide final state (DONE / REVIEWING) based on verifier verdict +
590
+ // auto-submit, but DO NOT return — the audit-trail postFillSession
591
+ // call at the bottom of this block runs for every terminal state so
592
+ // the user's receipt detail always has resume PDF + cover letter PDF +
593
+ // per-field decisions.
594
+ let finalState = 'DONE';
507
595
  if (verdict.submitted === null) {
508
- // EARLIER VERSION: when auto-submit was ON, we trusted the click and
509
- // marked DONE. That was wrong — it produced false-positive submissions
510
- // (applied=true in DB, no actual application sent). Auto-submit means
511
- // "don't make me click Submit on the dashboard" — it does NOT mean
512
- // "lie about delivery."
513
- //
514
- // Honest behavior: unverified == REVIEWING regardless of auto-submit.
515
- // The screenshot is right there in the dashboard, one click confirms.
516
- // Better to over-ask than to ghost-apply.
517
- console.warn(`[orchestrator] Could not verify submission (source: ${verdict.source}). REVIEWING — please eyeball the screenshot + click Submit.`);
596
+ const autoSubmit = config.autoSubmit || aep.agent_config?.auto_submit;
597
+ if (autoSubmit) {
598
+ // Auto-submit ON + verifier unavailable: trust the click; the
599
+ // screenshot becomes the audit trail.
600
+ console.log(`[orchestrator] Verifier unavailable (source: ${verdict.source}); auto-submit ON — trusting click, screenshot is the receipt.`);
601
+ } else {
602
+ // No auto-submit REVIEWING so the user eyeballs first.
603
+ console.warn(`[orchestrator] Could not verify submission (source: ${verdict.source}). REVIEWING — please eyeball the screenshot + click Submit.`);
604
+ finalState = 'REVIEWING';
605
+ }
606
+ }
607
+
608
+ if (finalState === 'REVIEWING') {
518
609
  await reportStatus('REVIEWING', {
519
610
  review_screenshot_r2_key: confirmKey || null,
520
611
  step: 'REVIEWING',
521
612
  step_detail: `Submit clicked at ${verdictUrl.slice(0, 100)} — verifier unavailable, please confirm`,
522
613
  fields_filled: cumulativeFilled,
523
614
  });
524
- return;
615
+ } else {
616
+ await reportStatus('DONE', {
617
+ confirmation_screenshot_r2_key: confirmKey || null,
618
+ fields_filled: cumulativeFilled,
619
+ });
620
+ await clearCheckpoint(config, queueId);
621
+ const verifiedTag = verdict.source === 'firecrawl' ? 'firecrawl-verified'
622
+ : verdict.source === 'url_pattern' ? 'url-pattern-verified'
623
+ : 'unverified-but-auto-submit';
624
+ console.log(`[orchestrator] Done (${verifiedTag}): ${queueItem.company} - ${queueItem.title}`);
525
625
  }
526
626
 
527
- await reportStatus('DONE', {
528
- confirmation_screenshot_r2_key: confirmKey || null,
529
- fields_filled: cumulativeFilled,
530
- });
531
- await clearCheckpoint(config, queueId);
532
-
533
- console.log(`[orchestrator] Done (verified): ${queueItem.company} - ${queueItem.title} · firecrawl-verified`);
534
-
535
- // Post fill session data to backend for learning loop
627
+ // Post fill session data to backend for learning loop + receipt audit
628
+ // trail. filled_actions becomes the per-field decision list the user
629
+ // sees on Applications detail — "agent typed X for First Name because
630
+ // facts.identity.first_name", etc. Truncated to keep payload sane on
631
+ // long forms.
632
+ const filledActions = Array.from(ctx.answeredFields.entries()).slice(0, 80).map(([label, data]) => ({
633
+ label: String(label).slice(0, 200),
634
+ action: 'filled',
635
+ value: String(data?.value || '').slice(0, 500),
636
+ source: data?.source || '',
637
+ }));
536
638
  await postFillSession(config, {
537
639
  job_id: jobId,
538
640
  ats_type: ats_type || 'unknown',
@@ -540,6 +642,11 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
540
642
  fields_filled: cumulativeFilled,
541
643
  skipped_fields: ctx.skippedFields,
542
644
  conditional_fields: ctx.conditionalTriggers,
645
+ filled_actions: filledActions,
646
+ // Pass through which actual files were uploaded — backend writes to
647
+ // submissions.resume_pdf_r2_key / cover_letter_pdf_r2_key for receipt.
648
+ resume_pdf_r2_key: aep?.recommended_resume?.pdf_r2_key || null,
649
+ cover_letter_pdf_r2_key: aep?.cover_letter_pdf?.pdf_r2_key || null,
543
650
  }).catch(() => {}); // non-critical
544
651
 
545
652
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "halo-agent",
3
- "version": "2.0.2",
3
+ "version": "2.0.4",
4
4
  "description": "HALO local apply agent — auto-fills job applications using your real Chrome session",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -24,6 +24,7 @@
24
24
  "scanPage.js",
25
25
  "scanAccessibility.js",
26
26
  "smartFill.js",
27
+ "detectFormErrors.js",
27
28
  "captcha.js",
28
29
  "vision.js",
29
30
  "manusAutomate.js",
@@ -253,8 +253,17 @@ async function enrichFromDom(page, axFields) {
253
253
  if (el.id) selectorHint = `#${cssEscape(el.id)}`;
254
254
  else if (el.name) selectorHint = `${tag}[name="${el.name.replace(/"/g, '\\"')}"]`;
255
255
 
256
- // Current value (skip already-filled)
257
- const currentValue = (el.value || (isContentEditable ? safeText(el.innerText) : '') || '').trim();
256
+ // Current value (used for filledAlready detection downstream).
257
+ // SPECIAL CASE for checkboxes/radios: their .value attribute defaults
258
+ // to "on" even when UNCHECKED. Using that as a "filled" signal
259
+ // silently skipped Reddit's required consent checkbox. Check .checked
260
+ // instead — that's the only honest signal of state.
261
+ let currentValue = '';
262
+ if (type === 'checkbox' || type === 'radio') {
263
+ currentValue = el.checked ? 'checked' : '';
264
+ } else {
265
+ currentValue = (el.value || (isContentEditable ? safeText(el.innerText) : '') || '').trim();
266
+ }
258
267
 
259
268
  // Native <select> options
260
269
  let options = null;
package/smartFill.js CHANGED
@@ -196,11 +196,93 @@ async function executePlanItem(page, item, fieldByMmid, ctx) {
196
196
  * Falls back to LLM synonym matching when local exact/substring
197
197
  * matching fails, so "Straight" still picks "Heterosexual."
198
198
  */
199
+ /**
200
+ * Special-case intl-tel-input country picker. The Country field on
201
+ * Greenhouse forms isn't a normal dropdown — it's an <input type=tel>
202
+ * with a sibling .iti__selected-flag button. Clicking the input does
203
+ * NOTHING; you have to click the flag to open the list, then click the
204
+ * matching .iti__country list item.
205
+ *
206
+ * Returns null if this isn't an intl-tel-input field; otherwise the
207
+ * fill result.
208
+ */
209
+ async function tryIntlTelCountry(page, triggerLocator, value) {
210
+ try {
211
+ // Is this field wrapped by an .iti container?
212
+ const wrap = await triggerLocator.evaluate((el) => {
213
+ const c = el.closest('.iti, .iti--allow-dropdown');
214
+ return c ? { has: true } : null;
215
+ }).catch(() => null);
216
+ if (!wrap) return null;
217
+
218
+ // Click the flag button to open the country list. There's only one
219
+ // active flag per .iti wrapper.
220
+ const flag = await triggerLocator.evaluateHandle((el) => {
221
+ const c = el.closest('.iti, .iti--allow-dropdown');
222
+ return c?.querySelector('.iti__selected-flag, [aria-label*="country" i]') || null;
223
+ });
224
+ if (!flag || !(await flag.evaluate((n) => !!n).catch(() => false))) return null;
225
+ await flag.click({ timeout: 1500 }).catch(async () => {
226
+ // Some intl-tel versions need force-click because the flag is
227
+ // positioned absolute under the input
228
+ await flag.click({ force: true, timeout: 1500 }).catch(() => {});
229
+ });
230
+ await page.waitForTimeout(400);
231
+
232
+ // Find the country whose name OR dial code matches `value`. Strip
233
+ // "+1" / parens for matching ("United States +1" → "United States").
234
+ const v = String(value).toLowerCase().replace(/\s*\+\d+\s*$/, '').trim();
235
+ const items = page.locator('.iti__country-list:visible .iti__country, .iti__dropdown-content .iti__country');
236
+ const count = await items.count().catch(() => 0);
237
+ if (count === 0) {
238
+ // Country list never opened
239
+ await page.keyboard.press('Escape').catch(() => {});
240
+ return { ok: false, reason: 'intl-tel country list did not open' };
241
+ }
242
+ // Collect the country names to find the right one
243
+ const names = await items.evaluateAll((nodes) => nodes.map((n) => {
244
+ const nm = n.querySelector('.iti__country-name')?.textContent?.trim() || '';
245
+ const dial = n.querySelector('.iti__dial-code')?.textContent?.trim() || '';
246
+ return { name: nm, dial };
247
+ })).catch(() => []);
248
+ let idx = names.findIndex((n) => n.name.toLowerCase() === v);
249
+ if (idx === -1) idx = names.findIndex((n) => n.name.toLowerCase().includes(v) || v.includes(n.name.toLowerCase()));
250
+ if (idx === -1) {
251
+ // Try matching by dial code (e.g. "+1")
252
+ const dialMatch = String(value).match(/\+(\d+)/);
253
+ if (dialMatch) idx = names.findIndex((n) => n.dial === `+${dialMatch[1]}`);
254
+ }
255
+ if (idx === -1) {
256
+ await page.keyboard.press('Escape').catch(() => {});
257
+ return { ok: false, reason: `no intl-tel country matched "${value}"` };
258
+ }
259
+ await items.nth(idx).click({ timeout: 1500 }).catch(async () => {
260
+ await items.nth(idx).click({ force: true, timeout: 1500 });
261
+ });
262
+ return { ok: true, reason: `intl-tel picked: ${names[idx].name} ${names[idx].dial}` };
263
+ } catch (e) {
264
+ return { ok: false, reason: `intl-tel handler threw: ${e.message}` };
265
+ }
266
+ }
267
+
199
268
  async function openAndPickOption(page, triggerLocator, value, llmCtx) {
269
+ try {
270
+ // First: intl-tel-input special case. The Country dial-code picker on
271
+ // Greenhouse is a non-standard widget that ignores clicks to its
272
+ // input — has to click the flag button. tryIntlTelCountry returns
273
+ // null when the field isn't intl-tel, so other dropdowns continue.
274
+ const itlResult = await tryIntlTelCountry(page, triggerLocator, value);
275
+ if (itlResult !== null) return itlResult;
276
+ } catch {}
277
+
200
278
  try {
201
279
  // Snapshot option-list state BEFORE opening so we can identify
202
- // the new options.
203
- const optionSel = '[role="option"], [role="menuitem"], .select__option, li[class*="option"]';
280
+ // the new options. Includes:
281
+ // .pac-item — Google Places (Greenhouse Location)
282
+ // .iti__country — intl-tel-input (Greenhouse Country dial-code)
283
+ // .select__option — React-Select
284
+ // role=option — ARIA-correct dropdowns
285
+ const optionSel = '[role="option"], [role="menuitem"], .select__option, li[class*="option"], .pac-item, .iti__country';
204
286
  const beforeCount = await page.locator(optionSel).count().catch(() => 0);
205
287
 
206
288
  await triggerLocator.click({ timeout: 2500 });
@@ -333,12 +415,35 @@ async function typeAndPickSuggestion(page, locator, value) {
333
415
  await locator.press('Delete').catch(() => {});
334
416
  const firstChunk = String(value).split(/[,;]/)[0].trim();
335
417
  await page.keyboard.type(firstChunk, { delay: 60 });
336
- await page.waitForTimeout(700);
337
- const optionSel = '[role="option"], [role="listbox"] li, .select__option, ul[class*="autocomplete"] li';
418
+ // Wait for suggestions to render. Google Places PAC takes longer than
419
+ // most (~800ms-1.2s), so wait a bit more aggressively before giving up.
420
+ await page.waitForTimeout(900);
421
+ // Selector ladder — Google Places (.pac-item) uses no role attribute,
422
+ // so we ADD it to the union. Without this, Greenhouse Location always
423
+ // failed because its dropdown is .pac-container > .pac-item.
424
+ const optionSel = [
425
+ '.pac-item', // Google Places (Greenhouse Location)
426
+ '[role="option"]', // ARIA-correct dropdowns
427
+ '[role="listbox"] li', // older listbox conventions
428
+ '[role="listbox"] [role="option"]', // nested
429
+ '.select__option', // React-Select
430
+ 'ul[class*="autocomplete"] li', // generic autocomplete
431
+ 'ul[class*="suggestion"] li',
432
+ 'div[class*="suggestion"]',
433
+ ].join(', ');
434
+ // Wait for ANY option to actually appear (some libs lazy-render). Up
435
+ // to 1.5s additional. waitFor errors if nothing appears — that's the
436
+ // signal that the field accepted free text instead.
437
+ try {
438
+ await page.locator(optionSel).first().waitFor({ state: 'visible', timeout: 1500 });
439
+ } catch {
440
+ const got = await locator.inputValue({ timeout: 800 }).catch(() => null);
441
+ if (got && got.trim()) return { ok: true, reason: `typeahead (no suggestion, accepted): "${value.slice(0, 30)}"` };
442
+ return { ok: false, reason: 'typeahead opened no suggestions' };
443
+ }
338
444
  const opts = page.locator(optionSel);
339
445
  const count = await opts.count().catch(() => 0);
340
446
  if (count === 0) {
341
- // Some fields accept the typed value directly. Verify.
342
447
  const got = await locator.inputValue({ timeout: 800 }).catch(() => null);
343
448
  if (got && got.trim()) return { ok: true, reason: `typeahead (no suggestion, accepted): "${value.slice(0, 30)}"` };
344
449
  return { ok: false, reason: 'typeahead opened no suggestions' };
@@ -348,7 +453,10 @@ async function typeAndPickSuggestion(page, locator, value) {
348
453
  let idx = texts.findIndex((t) => t.toLowerCase().trim() === v);
349
454
  if (idx === -1) idx = texts.findIndex((t) => t.toLowerCase().trim().startsWith(v));
350
455
  if (idx === -1) idx = 0;
351
- await opts.nth(idx).click({ timeout: 2000 });
456
+ // Google Places PAC items have a quirky click handler that doesn't fire
457
+ // on Playwright's normal click — use force-click as fallback.
458
+ try { await opts.nth(idx).click({ timeout: 2000 }); }
459
+ catch { await opts.nth(idx).click({ force: true, timeout: 2000 }); }
352
460
  await page.waitForTimeout(200);
353
461
  return { ok: true, reason: `typeahead picked: ${texts[idx]}` };
354
462
  } catch (e) {