halo-agent 2.0.5 → 2.0.7

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.
@@ -143,14 +143,47 @@ async function detectFormErrors(page) {
143
143
  if (type === 'file') return !el.files || el.files.length === 0;
144
144
  // contenteditable: empty when innerText is whitespace.
145
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') {
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 specifically (not the inner
154
+ // value-container — we want the whole control so single-value is
155
+ // in scope). react-select structure:
156
+ // .select__control > .select__value-container > .select__single-value
157
+ // > .select__input-container > input[role=combobox]
158
+ // The aria-invalid lands on the inner input, so we MUST climb to
159
+ // .select__control to see the rendered value chip.
160
+ let wrap = el.closest('[class*="select__control"]');
161
+ if (!wrap) {
162
+ // Climb manually for class variants
163
+ let p = el;
164
+ for (let i = 0; i < 8 && p; i++, p = p.parentElement) {
165
+ if (/select__control|select-control|\bSelect\b.*control/i.test(p.className || '')) { wrap = p; break; }
166
+ }
167
+ }
168
+ if (!wrap) wrap = el.closest('[class*="select"], [class*="combobox"], [class*="Select"]') || el.parentElement;
169
+ if (wrap) {
170
+ // Any rendered value chip → NOT empty.
171
+ const valueEl = wrap.querySelector(
172
+ '[class*="single-value"], [class*="multi-value"], ' +
173
+ '[class*="multiValue"], [class*="singleValue"], ' +
174
+ '[class*="chip"], [class*="tag"]'
175
+ );
176
+ if (valueEl && (valueEl.innerText || valueEl.textContent || '').trim()) return false;
177
+ // Also: the control's own text, minus the placeholder. If the
178
+ // control shows "Male" it's filled; if it shows "Select..." it's empty.
179
+ const wrapText = (wrap.innerText || '').trim();
180
+ const placeholderEl = wrap.querySelector('[class*="placeholder"]');
181
+ const placeholderText = placeholderEl ? (placeholderEl.innerText || '').trim() : '';
182
+ if (wrapText && wrapText !== placeholderText && !/^select\b|^choose\b|^\.\.\./i.test(wrapText)) {
183
+ return false;
184
+ }
185
+ }
149
186
  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
187
  return !v;
155
188
  }
156
189
  // Plain inputs / textareas
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "halo-agent",
3
- "version": "2.0.5",
3
+ "version": "2.0.7",
4
4
  "description": "HALO local apply agent — auto-fills job applications using your real Chrome session",
5
5
  "main": "index.js",
6
6
  "bin": {
package/smartFill.js CHANGED
@@ -293,14 +293,125 @@ async function tryIntlTelCountry(page, triggerLocator, value) {
293
293
  }
294
294
  }
295
295
 
296
+ /**
297
+ * React-Select handler. Greenhouse migrated its ENTIRE form to react-select
298
+ * (.select__control / .select__input / .select__menu / .select__option,
299
+ * with remix-css-* emotion classes). The diagnostic dump confirmed every
300
+ * failing dropdown — Country, Location — is this widget.
301
+ *
302
+ * The reason a plain click+look-for-options failed: react-select renders
303
+ * its .select__menu ONLY after the input is focused, and for long lists
304
+ * (countries) it filters by typed text. So the flow is:
305
+ * 1. click the .select__control to focus + open
306
+ * 2. type the value to filter the menu (react-select narrows as you type)
307
+ * 3. wait for .select__option to appear
308
+ * 4. click the best-matching option (or press Enter to take the highlight)
309
+ *
310
+ * For Location it's the same widget but backed by an async (Google Places)
311
+ * option source — typing triggers a network fetch, options arrive ~700ms
312
+ * later as .select__option. Same handler covers it.
313
+ *
314
+ * Returns null if this isn't a react-select field (so other handlers run).
315
+ */
316
+ async function tryReactSelect(page, triggerLocator, value) {
317
+ try {
318
+ // Detect: the trigger or an ancestor carries a .select__ class.
319
+ const info = await triggerLocator.evaluate((el) => {
320
+ const control = el.closest('[class*="select__control"]')
321
+ || el.closest('[class*="select__value-container"]')?.closest('[class*="select__control"]')
322
+ || (el.className && /select__/.test(el.className) ? el.closest('[class*="select__"]')?.closest('[class*="control"]') : null)
323
+ // Last resort: walk up to any node whose class has select__control
324
+ || (() => { let p = el; for (let i = 0; i < 8 && p; i++, p = p.parentElement) { if (/select__control/.test(p.className || '')) return p; } return null; })();
325
+ if (!control) return null;
326
+ // The real text input inside the control:
327
+ const input = control.querySelector('input.select__input, input[class*="select__input"], input[role="combobox"]') || el;
328
+ // Tag both with a temp id so Playwright can target them precisely.
329
+ const cid = 'halo-rs-control-' + Math.random().toString(36).slice(2, 8);
330
+ control.setAttribute('data-halo-rs', cid);
331
+ if (input) input.setAttribute('data-halo-rs-input', cid);
332
+ return { cid };
333
+ }).catch(() => null);
334
+ if (!info) return null;
335
+
336
+ const control = page.locator(`[data-halo-rs="${info.cid}"]`).first();
337
+ const input = page.locator(`[data-halo-rs-input="${info.cid}"]`).first();
338
+
339
+ // 1. Open: click the control. react-select opens the menu on control click.
340
+ await control.click({ timeout: 2500 }).catch(() => {});
341
+ await page.waitForTimeout(250);
342
+
343
+ // 2. Type the value to filter. Strip "+1" / parens noise from country
344
+ // values ("United States +1" → "United States"). For location, type
345
+ // the city ("Tempe").
346
+ const typed = String(value).replace(/\s*\+\d+\s*$/, '').split(/[,;]/)[0].trim();
347
+ // Focus the input then type char-by-char so react-select's onChange fires.
348
+ await input.click({ timeout: 1500 }).catch(() => {});
349
+ await input.fill('').catch(() => {});
350
+ await page.keyboard.type(typed, { delay: 50 });
351
+
352
+ // 3. Wait for options. react-select menu is .select__menu containing
353
+ // .select__option. Async (Places) options take longer — wait up to 2s.
354
+ const optSel = '.select__option, [class*="select__option"], [id*="react-select"][id*="option"]';
355
+ try {
356
+ await page.locator(optSel).first().waitFor({ state: 'visible', timeout: 2200 });
357
+ } catch {
358
+ // No options appeared. For react-select, pressing Enter on a typed
359
+ // value sometimes commits a free-text/create option. Try it, then
360
+ // verify the control now shows a value.
361
+ await page.keyboard.press('Enter').catch(() => {});
362
+ await page.waitForTimeout(200);
363
+ const filled = await control.evaluate((c) => {
364
+ const v = c.querySelector('[class*="single-value"], [class*="multi-value"]');
365
+ return v ? (v.textContent || '').trim() : '';
366
+ }).catch(() => '');
367
+ if (filled) return { ok: true, reason: `react-select (enter-commit): ${filled}` };
368
+ await page.keyboard.press('Escape').catch(() => {});
369
+ return { ok: false, reason: 'react-select menu showed no options after typing' };
370
+ }
371
+
372
+ // 4. Pick best option.
373
+ const opts = page.locator(optSel);
374
+ const texts = await opts.allTextContents().catch(() => []);
375
+ const tl = typed.toLowerCase();
376
+ let idx = texts.findIndex((t) => t.toLowerCase().trim() === tl);
377
+ if (idx === -1) idx = texts.findIndex((t) => t.toLowerCase().trim().startsWith(tl));
378
+ if (idx === -1) idx = texts.findIndex((t) => t.toLowerCase().includes(tl) || tl.includes(t.toLowerCase().trim()));
379
+ if (idx === -1) idx = 0; // react-select already filtered; first is best
380
+ await opts.nth(idx).click({ timeout: 2000 }).catch(async () => {
381
+ // react-select options sometimes need Enter on the highlighted item
382
+ await page.keyboard.press('Enter');
383
+ });
384
+ await page.waitForTimeout(150);
385
+ // Verify a value chip rendered
386
+ const got = await control.evaluate((c) => {
387
+ const v = c.querySelector('[class*="single-value"], [class*="multi-value"]');
388
+ return v ? (v.textContent || '').trim() : '';
389
+ }).catch(() => '');
390
+ if (got) return { ok: true, reason: `react-select picked: ${got}` };
391
+ return { ok: true, reason: `react-select clicked option: ${texts[idx] || typed}` };
392
+ } catch (e) {
393
+ return { ok: false, reason: `react-select handler threw: ${e.message}` };
394
+ }
395
+ }
396
+
296
397
  async function openAndPickOption(page, triggerLocator, value, llmCtx) {
398
+ // React-Select FIRST — Greenhouse uses it for every dropdown. The
399
+ // diagnostic confirmed Country + Location are both .select__ widgets.
400
+ // Returns null if not react-select, so other handlers still run.
401
+ try {
402
+ const rs = await tryReactSelect(page, triggerLocator, value);
403
+ if (rs !== null) {
404
+ if (rs.ok) return rs;
405
+ // react-select detected but pick failed — log + fall through to the
406
+ // generic path as a last resort (rare).
407
+ console.warn(`[smartFill] react-select pick failed: ${rs.reason}`);
408
+ }
409
+ } catch {}
410
+
297
411
  try {
298
- // First: intl-tel-input special case. The Country dial-code picker on
299
- // Greenhouse is a non-standard widget that ignores clicks to its
300
- // input — has to click the flag button. tryIntlTelCountry returns
301
- // null when the field isn't intl-tel, so other dropdowns continue.
412
+ // intl-tel-input special case (kept for forms that genuinely use it).
302
413
  const itlResult = await tryIntlTelCountry(page, triggerLocator, value);
303
- if (itlResult !== null) return itlResult;
414
+ if (itlResult !== null && itlResult.ok) return itlResult;
304
415
  } catch {}
305
416
 
306
417
  // Second: Google Places typeahead. The Greenhouse Location field is
@@ -362,6 +473,21 @@ async function openAndPickOption(page, triggerLocator, value, llmCtx) {
362
473
  }
363
474
 
364
475
  if (candidates.length === 0) {
476
+ // DIAGNOSTIC: dump the field's actual DOM so we can see what widget
477
+ // this really is (intl-tel? react-select? custom?). One-line summary
478
+ // of the element + its 2 nearest ancestors' classes + any sibling
479
+ // input/button. This is how we stop guessing selectors.
480
+ const diag = await triggerLocator.evaluate((el) => {
481
+ 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';
482
+ const p1 = el.parentElement;
483
+ const p2 = p1?.parentElement;
484
+ const siblingInputs = Array.from((p2 || p1 || el).querySelectorAll('input, button, [role="combobox"], [role="button"]'))
485
+ .slice(0, 5)
486
+ .map((s) => `${s.tagName.toLowerCase()}.${(s.className || '').toString().split(' ')[0]}`)
487
+ .join(', ');
488
+ return `self=${summ(el)} | p1=${summ(p1)} | p2=${summ(p2)} | nearby=[${siblingInputs}]`;
489
+ }).catch(() => 'diag failed');
490
+ console.warn(`[smartFill] DROPDOWN-DIAG: ${diag}`);
365
491
  await page.keyboard.press('Escape').catch(() => {});
366
492
  return { ok: false, reason: 'dropdown opened but no visible options found' };
367
493
  }