halo-agent 2.0.4 → 2.0.6

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.
@@ -118,8 +118,74 @@ async function detectFormErrors(page) {
118
118
  const invalidFields = [];
119
119
  const seenMmids = new Set();
120
120
 
121
+ // CRITICAL: only flag fields that are ACTUALLY empty. Otherwise we
122
+ // get a cascade of false positives — Greenhouse marks every required
123
+ // field's LABEL red after a failed submit (visual highlight), and the
124
+ // text-pattern walk matches "First Name is required" near a perfectly-
125
+ // filled First Name field. That triggered the retry to re-touch fields
126
+ // that didn't need fixing, which is how Gender went from Male → Female
127
+ // and Ethnicity East Asian → South Asian.
128
+ function isEmptyField(el) {
129
+ const tag = el.tagName.toLowerCase();
130
+ const type = (el.type || '').toLowerCase();
131
+ // Checkboxes / radios: empty means !checked.
132
+ if (type === 'checkbox' || type === 'radio') return !el.checked;
133
+ // Native select: empty value or selected option is the placeholder.
134
+ if (tag === 'select') {
135
+ if (!el.value) return true;
136
+ const selOpt = el.options[el.selectedIndex];
137
+ // A select whose first option is "Select..." with empty value AND
138
+ // that option is selected → empty.
139
+ if (selOpt && !selOpt.value) return true;
140
+ return false;
141
+ }
142
+ // File inputs: empty means files.length === 0.
143
+ if (type === 'file') return !el.files || el.files.length === 0;
144
+ // contenteditable: empty when innerText is whitespace.
145
+ if (el.isContentEditable) return !(el.innerText || '').trim();
146
+ // Combobox / custom-select: React-Select (which Greenhouse uses for
147
+ // ALL its dropdowns) renders the selected value as a
148
+ // .select__single-value or .select__multi-value chip INSIDE the
149
+ // control wrapper, NOT as the input's .value. We must look there or
150
+ // we falsely flag every filled dropdown as empty (the bug that made
151
+ // the retry re-pick Gender + Ethnicity).
152
+ if (el.getAttribute('role') === 'combobox' || tag === 'select') {
153
+ // Walk up to the React-Select control wrapper (a few class variants).
154
+ const wrap = el.closest(
155
+ '[class*="select__control"], [class*="select-control"], ' +
156
+ '[class*="select__value-container"], [class*="select"], ' +
157
+ '[class*="combobox"], [class*="Select"]'
158
+ ) || el.parentElement;
159
+ if (wrap) {
160
+ // Any rendered value chip → NOT empty.
161
+ const valueEl = wrap.querySelector(
162
+ '[class*="single-value"], [class*="multi-value"], ' +
163
+ '[class*="multiValue"], [class*="singleValue"], ' +
164
+ '[class*="chip"], [class*="tag"], [class*="__value"]'
165
+ );
166
+ if (valueEl && (valueEl.innerText || valueEl.textContent || '').trim()) return false;
167
+ // Also: the control's own text, minus the placeholder. If the
168
+ // control shows "Male" it's filled; if it shows "Select..." it's empty.
169
+ const wrapText = (wrap.innerText || '').trim();
170
+ const placeholderEl = wrap.querySelector('[class*="placeholder"]');
171
+ const placeholderText = placeholderEl ? (placeholderEl.innerText || '').trim() : '';
172
+ if (wrapText && wrapText !== placeholderText && !/^select\b|^choose\b|^\.\.\./i.test(wrapText)) {
173
+ return false;
174
+ }
175
+ }
176
+ const v = (el.value || el.innerText || '').trim();
177
+ return !v;
178
+ }
179
+ // Plain inputs / textareas
180
+ return !(el.value || '').trim();
181
+ }
182
+
121
183
  function addField(el, errorText) {
122
184
  if (!el) return;
185
+ // Skip fields that are already filled — they're not the cause of
186
+ // the form rejection even if their label happens to be near
187
+ // required-text styling.
188
+ if (!isEmptyField(el)) return;
123
189
  const sel = bestSelector(el);
124
190
  const key = sel.mmid || sel.selector || el.outerHTML.slice(0, 80);
125
191
  if (seenMmids.has(key)) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "halo-agent",
3
- "version": "2.0.4",
3
+ "version": "2.0.6",
4
4
  "description": "HALO local apply agent — auto-fills job applications using your real Chrome session",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -429,6 +429,20 @@ async function scanAccessibility(page) {
429
429
  const label = pickLabel(f);
430
430
  // Filter noise: no label AND no visible role → skip
431
431
  if (!label && !['button', 'link'].includes(normalizeRole(f))) continue;
432
+ // Filter out the "Remove X" chip-clear buttons that React-Select
433
+ // renders next to selected items in multi-select dropdowns. The
434
+ // planner was treating these as separate fields and "filling" them
435
+ // (clicking them, which DELETED the correct answer) on the retry
436
+ // pass — that's how Gender flipped Male → Female on retry. Same for
437
+ // the X button that clears a single-select's value.
438
+ if (normalizeRole(f) === 'button' && (
439
+ /^remove\s+\S/i.test(label) ||
440
+ /^clear\s+selection/i.test(label) ||
441
+ /^remove\s+item/i.test(label) ||
442
+ label === '×' || label === '✕' || label === 'x'
443
+ )) {
444
+ continue;
445
+ }
432
446
  out.push({
433
447
  mmid: f.mmid,
434
448
  role: normalizeRole(f),
package/smartFill.js CHANGED
@@ -208,53 +208,81 @@ async function executePlanItem(page, item, fieldByMmid, ctx) {
208
208
  */
209
209
  async function tryIntlTelCountry(page, triggerLocator, value) {
210
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.
211
+ // Detect intl-tel-input by looking for ANY characteristic class within
212
+ // a reasonable ancestor radius. Newer versions (v18+) shifted from
213
+ // .iti to .iti--allow-dropdown / .iti-mobile, plus they may wrap
214
+ // in .country-select instead. Scan up to 6 ancestors.
215
+ const probe = await triggerLocator.evaluate((el) => {
216
+ let p = el; let hops = 0;
217
+ while (p && hops < 6) {
218
+ const cls = String(p.className || '');
219
+ if (/\biti\b|\biti--|country-select|country-picker/i.test(cls)) {
220
+ // Found the wrapper. Find the flag/trigger inside.
221
+ const flag = p.querySelector(
222
+ '.iti__selected-flag, .iti__country-container, ' +
223
+ '[class*="selected-flag"], [class*="country-button"], ' +
224
+ '[aria-haspopup="listbox"][class*="iti"], ' +
225
+ 'button[class*="country"]'
226
+ );
227
+ return { found: true, wrapperOuterClass: cls.slice(0, 200), hasFlag: !!flag };
228
+ }
229
+ p = p.parentElement; hops += 1;
230
+ }
231
+ return { found: false };
232
+ }).catch(() => ({ found: false }));
233
+ if (!probe.found) return null;
234
+
235
+ // Click the flag/trigger element to open the country list.
220
236
  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;
237
+ let p = el; let hops = 0;
238
+ while (p && hops < 6) {
239
+ const cls = String(p.className || '');
240
+ if (/\biti\b|\biti--|country-select|country-picker/i.test(cls)) {
241
+ return p.querySelector(
242
+ '.iti__selected-flag, .iti__country-container, ' +
243
+ '[class*="selected-flag"], [class*="country-button"], ' +
244
+ '[aria-haspopup="listbox"][class*="iti"], ' +
245
+ 'button[class*="country"]'
246
+ ) || null;
247
+ }
248
+ p = p.parentElement; hops += 1;
249
+ }
250
+ return null;
223
251
  });
224
- if (!flag || !(await flag.evaluate((n) => !!n).catch(() => false))) return null;
252
+ if (!flag || !(await flag.evaluate((n) => !!n).catch(() => false))) {
253
+ return { ok: false, reason: `intl-tel wrapper found (${probe.wrapperOuterClass}) but no flag button inside` };
254
+ }
225
255
  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
256
  await flag.click({ force: true, timeout: 1500 }).catch(() => {});
229
257
  });
230
- await page.waitForTimeout(400);
258
+ await page.waitForTimeout(500);
231
259
 
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');
260
+ // Country items try several known class patterns
261
+ const itemSel = '.iti__country-list:visible .iti__country, .iti__dropdown-content .iti__country, [class*="country-list"] [class*="country-item"], [role="listbox"] [role="option"][class*="iti"]';
262
+ const items = page.locator(itemSel);
236
263
  const count = await items.count().catch(() => 0);
237
264
  if (count === 0) {
238
- // Country list never opened
239
265
  await page.keyboard.press('Escape').catch(() => {});
240
- return { ok: false, reason: 'intl-tel country list did not open' };
266
+ return { ok: false, reason: `intl-tel country list did not open (wrapper=${probe.wrapperOuterClass})` };
241
267
  }
242
- // Collect the country names to find the right one
268
+ const v = String(value).toLowerCase().replace(/\s*\+\d+\s*$/, '').trim();
243
269
  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() || '';
270
+ const nm = n.querySelector('.iti__country-name, [class*="country-name"]')?.textContent?.trim()
271
+ || (n.getAttribute('data-country-name') || '').trim()
272
+ || (n.textContent || '').replace(/\+\d+/, '').trim();
273
+ const dial = n.querySelector('.iti__dial-code, [class*="dial-code"]')?.textContent?.trim()
274
+ || (n.getAttribute('data-dial-code') ? `+${n.getAttribute('data-dial-code')}` : '');
246
275
  return { name: nm, dial };
247
276
  })).catch(() => []);
248
277
  let idx = names.findIndex((n) => n.name.toLowerCase() === v);
249
278
  if (idx === -1) idx = names.findIndex((n) => n.name.toLowerCase().includes(v) || v.includes(n.name.toLowerCase()));
250
279
  if (idx === -1) {
251
- // Try matching by dial code (e.g. "+1")
252
280
  const dialMatch = String(value).match(/\+(\d+)/);
253
281
  if (dialMatch) idx = names.findIndex((n) => n.dial === `+${dialMatch[1]}`);
254
282
  }
255
283
  if (idx === -1) {
256
284
  await page.keyboard.press('Escape').catch(() => {});
257
- return { ok: false, reason: `no intl-tel country matched "${value}"` };
285
+ return { ok: false, reason: `no intl-tel country matched "${value}" (${count} items)` };
258
286
  }
259
287
  await items.nth(idx).click({ timeout: 1500 }).catch(async () => {
260
288
  await items.nth(idx).click({ force: true, timeout: 1500 });
@@ -275,6 +303,30 @@ async function openAndPickOption(page, triggerLocator, value, llmCtx) {
275
303
  if (itlResult !== null) return itlResult;
276
304
  } catch {}
277
305
 
306
+ // Second: Google Places typeahead. The Greenhouse Location field is
307
+ // marked role=combobox in AX, so the planner sends click_option — but
308
+ // it's really a type-then-pick autocomplete that opens a .pac-container
309
+ // mounted at document.body (NOT inside the field's wrapper). Detect
310
+ // by checking for adjacent "Locate me" UI or for an existing pac-container.
311
+ try {
312
+ const isPac = await triggerLocator.evaluate((el) => {
313
+ // Heuristic 1: a "Locate me" link/button nearby (Greenhouse-specific)
314
+ const ancestor = el.closest('div, section, fieldset') || el.parentElement;
315
+ const hasLocateMe = ancestor && Array.from(ancestor.querySelectorAll('a, button')).some((b) => /locate\s*me/i.test(b.textContent || ''));
316
+ // Heuristic 2: a pac-container is mounted somewhere on the page
317
+ const hasPacContainer = !!document.querySelector('.pac-container, .pac-target-input');
318
+ // Heuristic 3: the input itself is a pac-target-input (Google sets this when initialized)
319
+ const isPacInput = el.classList?.contains('pac-target-input');
320
+ return hasLocateMe || hasPacContainer || isPacInput;
321
+ }).catch(() => false);
322
+ if (isPac) {
323
+ const r = await typeAndPickSuggestion(page, triggerLocator, value);
324
+ // If typeahead failed, fall through to the normal dropdown path
325
+ // (some fields are both, weirdly)
326
+ if (r.ok) return r;
327
+ }
328
+ } catch {}
329
+
278
330
  try {
279
331
  // Snapshot option-list state BEFORE opening so we can identify
280
332
  // the new options. Includes:
@@ -310,6 +362,21 @@ async function openAndPickOption(page, triggerLocator, value, llmCtx) {
310
362
  }
311
363
 
312
364
  if (candidates.length === 0) {
365
+ // DIAGNOSTIC: dump the field's actual DOM so we can see what widget
366
+ // this really is (intl-tel? react-select? custom?). One-line summary
367
+ // of the element + its 2 nearest ancestors' classes + any sibling
368
+ // input/button. This is how we stop guessing selectors.
369
+ const diag = await triggerLocator.evaluate((el) => {
370
+ const summ = (n) => n ? `<${n.tagName.toLowerCase()} class="${(n.className || '').toString().slice(0, 120)}" role="${n.getAttribute?.('role') || ''}" aria-haspopup="${n.getAttribute?.('aria-haspopup') || ''}">` : 'null';
371
+ const p1 = el.parentElement;
372
+ const p2 = p1?.parentElement;
373
+ const siblingInputs = Array.from((p2 || p1 || el).querySelectorAll('input, button, [role="combobox"], [role="button"]'))
374
+ .slice(0, 5)
375
+ .map((s) => `${s.tagName.toLowerCase()}.${(s.className || '').toString().split(' ')[0]}`)
376
+ .join(', ');
377
+ return `self=${summ(el)} | p1=${summ(p1)} | p2=${summ(p2)} | nearby=[${siblingInputs}]`;
378
+ }).catch(() => 'diag failed');
379
+ console.warn(`[smartFill] DROPDOWN-DIAG: ${diag}`);
313
380
  await page.keyboard.press('Escape').catch(() => {});
314
381
  return { ok: false, reason: 'dropdown opened but no visible options found' };
315
382
  }