halo-agent 2.0.4 → 2.0.5

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,51 @@ 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 role with no inner input (custom dropdown trigger): look
147
+ // for the displayed value via aria-activedescendant or innerText.
148
+ if (el.getAttribute('role') === 'combobox') {
149
+ const v = (el.value || el.innerText || '').trim();
150
+ // Empty AND no chip/pill rendered as a sibling means truly empty.
151
+ const wrap = el.closest('[class*="select"], [class*="combobox"]');
152
+ const chip = wrap?.querySelector('[class*="chip"], [class*="multi-value"], [class*="tag"]');
153
+ if (chip && (chip.innerText || '').trim()) return false;
154
+ return !v;
155
+ }
156
+ // Plain inputs / textareas
157
+ return !(el.value || '').trim();
158
+ }
159
+
121
160
  function addField(el, errorText) {
122
161
  if (!el) return;
162
+ // Skip fields that are already filled — they're not the cause of
163
+ // the form rejection even if their label happens to be near
164
+ // required-text styling.
165
+ if (!isEmptyField(el)) return;
123
166
  const sel = bestSelector(el);
124
167
  const key = sel.mmid || sel.selector || el.outerHTML.slice(0, 80);
125
168
  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.5",
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: