halo-agent 1.3.3 → 1.3.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.
package/filler.js CHANGED
@@ -17,22 +17,11 @@ function jitter(base) {
17
17
  * Fires the full event sequence that frameworks listen for.
18
18
  */
19
19
  async function setFieldValue(page, selector, value) {
20
+ // Wrapper that delegates to the React-aware locator variant.
20
21
  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;
22
+ const loc = page.locator(selector).first();
23
+ await loc.waitFor({ timeout: 3000 });
24
+ return await setFieldValueByHandle(page, loc, value);
36
25
  } catch {
37
26
  return false;
38
27
  }
@@ -41,23 +30,53 @@ async function setFieldValue(page, selector, value) {
41
30
  /**
42
31
  * Set a field value by Playwright element handle (used after semantic matching).
43
32
  */
44
- async function setFieldValueByHandle(page, elementHandle, value) {
33
+ async function setFieldValueByHandle(page, elementOrLocator, value) {
34
+ // Strategy ladder, hardest-React-friendly first.
35
+ // The Reddit-Greenhouse run proved everything below the FIRST strategy
36
+ // fired but the field still appeared empty: React's controlled-input
37
+ // validator rejects programmatic value writes that don't go through a
38
+ // real event pipeline. Playwright's locator.fill() simulates a real
39
+ // user typing (pointerdown → focus → keystroke events → blur) and IS
40
+ // the only thing modern React forms (Greenhouse, Ashby, Lever, Workday)
41
+ // reliably accept. We try it first and fall back to native-setter only
42
+ // if Playwright's path fails (rare; mostly when the element isn't a
43
+ // standard input).
44
+
45
+ // 1. Playwright native fill — works for >99% of React forms
46
+ try {
47
+ await elementOrLocator.fill(String(value), { timeout: 5000 });
48
+ // Verify the value actually stuck (React might have rejected it)
49
+ const got = await elementOrLocator.inputValue({ timeout: 1000 }).catch(() => null);
50
+ if (got && got.trim()) return true;
51
+ // If empty after fill, React rejected it — fall through to manual setter
52
+ } catch {
53
+ // Locator may not support .fill() (custom div pretending to be input).
54
+ // Fall through.
55
+ }
56
+
57
+ // 2. Click + keyboard.type — slower but bullets through React harder
58
+ try {
59
+ await elementOrLocator.click({ timeout: 2000 });
60
+ await elementOrLocator.fill('', { timeout: 2000 }).catch(() => {});
61
+ await page.keyboard.type(String(value), { delay: 20 });
62
+ const got = await elementOrLocator.inputValue({ timeout: 1000 }).catch(() => null);
63
+ if (got && got.trim()) return true;
64
+ } catch {}
65
+
66
+ // 3. Native setter dance — last resort for non-standard elements
45
67
  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) => {
68
+ await elementOrLocator.evaluate((el, val) => {
51
69
  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 }));
70
+ const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
71
+ const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
72
+ if (setter) setter.call(el, val); else el.value = val;
56
73
  el.dispatchEvent(new Event('input', { bubbles: true }));
57
74
  el.dispatchEvent(new Event('change', { bubbles: true }));
58
- el.dispatchEvent(new Event('blur', { bubbles: true }));
59
- }, value);
60
- return true;
75
+ el.blur();
76
+ }, String(value));
77
+ // Verify
78
+ const got = await elementOrLocator.inputValue({ timeout: 1000 }).catch(() => null);
79
+ return !!(got && got.trim());
61
80
  } catch {
62
81
  return false;
63
82
  }
@@ -252,7 +271,35 @@ async function fillLocator(page, locator, value, label) {
252
271
  isContentEditable: el.isContentEditable,
253
272
  hasOptions: el.tagName === 'SELECT',
254
273
  optionCount: el.tagName === 'SELECT' ? el.options.length : 0,
255
- })).catch(() => ({ tag: 'input', type: 'text', role: '', isContentEditable: false, hasOptions: false, optionCount: 0 }));
274
+ // Typeahead heuristics: any of these signals means "type then pick a
275
+ // suggestion." Greenhouse Location, Lever City, Workday locations all
276
+ // use this pattern — a plain text input that mounts a <ul role=listbox>
277
+ // of city matches as you type. Plain .fill() leaves the field
278
+ // visually populated but React clears it on blur because no
279
+ // suggestion was committed.
280
+ isTypeahead: !!(
281
+ el.getAttribute('aria-autocomplete') === 'list' ||
282
+ el.getAttribute('aria-autocomplete') === 'both' ||
283
+ el.getAttribute('aria-haspopup') === 'listbox' ||
284
+ el.getAttribute('aria-haspopup') === 'true' ||
285
+ el.getAttribute('autocomplete') === 'off' && el.getAttribute('aria-expanded') !== null ||
286
+ // Greenhouse + Lever wrap their typeahead in role=combobox parent
287
+ el.closest('[role="combobox"]') ||
288
+ // "Locate me" sibling button is a strong Greenhouse Location signal
289
+ Array.from((el.closest('div') || el.parentElement || document).querySelectorAll('button, a')).some(b => /locate\s*me/i.test(b.textContent || ''))
290
+ ),
291
+ })).catch(() => ({ tag: 'input', type: 'text', role: '', isContentEditable: false, hasOptions: false, optionCount: 0, isTypeahead: false }));
292
+
293
+ // ── Typeahead text input (Greenhouse Location, Lever City) ──
294
+ // Check BEFORE the generic combobox path so a plain <input> with
295
+ // typeahead-flagged ancestors still gets the type-then-pick treatment
296
+ // (those inputs aren't role=combobox themselves).
297
+ if (meta.isTypeahead && (meta.tag === 'input' || meta.tag === 'textarea')) {
298
+ const ok = await fillTypeahead(page, locator, value);
299
+ if (ok) return true;
300
+ // Fall through to default text-input path if typeahead pick failed —
301
+ // some forms accept the typed value even without selecting a suggestion.
302
+ }
256
303
 
257
304
  // ── Native <select> ──
258
305
  if (meta.tag === 'select' || meta.hasOptions) {
@@ -343,6 +390,65 @@ async function fillLocator(page, locator, value, label) {
343
390
  * Strategy: click trigger → wait for option list → find closest matching option → click it.
344
391
  * Handles React Select, Ashby custom select, Lever, Greenhouse custom dropdowns.
345
392
  */
393
+ /**
394
+ * Type-and-pick handler for autocomplete/typeahead inputs (Greenhouse
395
+ * Location, Lever City, Workday location pickers). Strategy:
396
+ *
397
+ * 1. Click the input to focus
398
+ * 2. Clear any existing text
399
+ * 3. Type the value SLOWLY, character-by-character (suggestions fire
400
+ * on each keystroke; typing too fast can race the network)
401
+ * 4. Wait for a listbox/option container to render
402
+ * 5. Pick the best matching option (exact > startsWith > first)
403
+ * 6. Verify the input now shows a committed value
404
+ *
405
+ * Returns false if no suggestion list appears — caller can fall back to
406
+ * a plain fill.
407
+ */
408
+ async function fillTypeahead(page, locator, value) {
409
+ try {
410
+ await locator.click({ timeout: 2000 });
411
+ // Clear via select-all + delete (more reliable than .fill('') for
412
+ // autocompletes that have their own state)
413
+ await locator.press('Meta+A').catch(() => locator.press('Control+A')).catch(() => {});
414
+ await locator.press('Delete').catch(() => {});
415
+ // Type the FIRST PART (city name only) — typing "San Francisco, CA"
416
+ // when the typeahead expects "San Francisco" can suppress matches.
417
+ const firstChunk = String(value).split(/[,;]/)[0].trim();
418
+ await page.keyboard.type(firstChunk, { delay: 60 });
419
+ // Wait briefly for suggestions to render
420
+ await page.waitForTimeout(700);
421
+ // Look for a listbox / option container — multiple common patterns
422
+ const optionSel = [
423
+ '[role="option"]',
424
+ '[role="listbox"] li',
425
+ '[role="listbox"] [role="option"]',
426
+ '[class*="option"][class*="select"]',
427
+ '[class*="suggestion"]',
428
+ 'ul[class*="autocomplete"] li',
429
+ // Greenhouse-specific
430
+ '.select__option',
431
+ '.select__menu [role="option"]',
432
+ ].join(', ');
433
+ const options = page.locator(optionSel);
434
+ const count = await options.count().catch(() => 0);
435
+ if (count === 0) return false;
436
+ // Prefer exact-match option; fall back to "starts with"; else first.
437
+ const allTexts = await options.allTextContents().catch(() => []);
438
+ const v = firstChunk.toLowerCase();
439
+ let pickIdx = allTexts.findIndex(t => t.toLowerCase().trim() === v);
440
+ if (pickIdx === -1) pickIdx = allTexts.findIndex(t => t.toLowerCase().trim().startsWith(v));
441
+ if (pickIdx === -1) pickIdx = 0;
442
+ await options.nth(pickIdx).click({ timeout: 2000 });
443
+ await page.waitForTimeout(200);
444
+ // Verify the input now shows a real value
445
+ const got = await locator.inputValue({ timeout: 1000 }).catch(() => null);
446
+ return !!(got && got.trim());
447
+ } catch {
448
+ return false;
449
+ }
450
+ }
451
+
346
452
  async function fillCustomDropdown(page, triggerLocator, value) {
347
453
  try {
348
454
  await triggerLocator.click();
@@ -646,8 +752,37 @@ async function fillFields(page, aep, options = {}) {
646
752
  continue;
647
753
  }
648
754
 
649
- // File uploads — handled separately by uploadResume() in orchestrator
650
- if (source === 'file_upload') continue;
755
+ // File uploads — resume is handled separately by uploadResume() in the
756
+ // gate handler. Cover letter is its own thing: ATSes like Greenhouse and
757
+ // Ashby render a dedicated cover-letter file input alongside the resume
758
+ // one. When the user compiled a cover letter PDF (in PacketPrep studio),
759
+ // upload it here against the matching field.
760
+ if (source === 'file_upload') {
761
+ if (field.category === 'file:cover_letter' && aep.__coverLetterLocalPath) {
762
+ try {
763
+ // setInputFiles works directly on hidden <input type=file>; no need
764
+ // to traverse to a sibling button when we already have the input.
765
+ const inputLoc = field.selector ? page.locator(field.selector).first() : null;
766
+ if (inputLoc) {
767
+ // Some forms hide the input via CSS — unhide briefly so Playwright
768
+ // doesn't reject the setInputFiles call on a 0x0 element.
769
+ await inputLoc.evaluate(el => {
770
+ el.style.display = 'block'; el.style.opacity = '1'; el.style.visibility = 'visible';
771
+ el.style.width = '1px'; el.style.height = '1px';
772
+ }).catch(() => {});
773
+ await inputLoc.setInputFiles(aep.__coverLetterLocalPath, { timeout: 5000 });
774
+ console.log(`[filler] filled (file): "${labelShort}" = cover-letter.pdf`);
775
+ filled++;
776
+ if (ctx && trackKey) ctx.answeredFields.set(trackKey, { value: 'cover-letter.pdf', pageIndex: ctx.currentPageIndex, source: 'file' });
777
+ await delay();
778
+ continue;
779
+ }
780
+ } catch (e) {
781
+ console.warn(`[filler] cover-letter upload failed for "${labelShort}": ${e.message}`);
782
+ }
783
+ }
784
+ continue;
785
+ }
651
786
 
652
787
  // EEO / consent — skip silently (vision handles EEO, user handles consent)
653
788
  if (source === 'skip') continue;
package/orchestrator.js CHANGED
@@ -68,6 +68,18 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
68
68
  tempResumeFile = await downloadResume(aep.recommended_resume.pdf_presigned_url);
69
69
  }
70
70
 
71
+ // Download cover-letter PDF too — separate file because Greenhouse/Ashby
72
+ // have separate file inputs for each. Only present when the user
73
+ // compiled it in PacketPrep (cover_letter_pdf_status='ready').
74
+ let tempCoverLetterFile = null;
75
+ if (aep.cover_letter_pdf?.pdf_presigned_url) {
76
+ tempCoverLetterFile = await downloadResume(aep.cover_letter_pdf.pdf_presigned_url);
77
+ if (tempCoverLetterFile) console.log('[orchestrator] Cover letter PDF downloaded');
78
+ }
79
+ // Expose to fillFields via aep so the file:cover_letter category resolver
80
+ // can hand it to the uploader.
81
+ aep.__coverLetterLocalPath = tempCoverLetterFile;
82
+
71
83
  // Check for an existing checkpoint — if a previous run got past page 1
72
84
  // before crashing, resume from where it stopped. The fillFields path uses
73
85
  // ctx.answeredFields to skip already-completed labels, so restoring the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "halo-agent",
3
- "version": "1.3.3",
3
+ "version": "1.3.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": {
package/scanPage.js CHANGED
@@ -597,8 +597,57 @@ function resolveFieldValues(fields, aep) {
597
597
  return { field, value: null, source: 'file_upload' };
598
598
  }
599
599
 
600
- // EEO/consentskip (handled by vision or user)
601
- if (cat === 'eeo' || cat === 'consent') {
600
+ // Consent checkboxes user-only (we don't auto-accept terms).
601
+ if (cat === 'consent') {
602
+ return { field, value: null, source: 'skip' };
603
+ }
604
+
605
+ // EEO — answer from profile self-ID when set, OR infer common
606
+ // implications from what IS set. The profile already collects
607
+ // gender_self_id / race / veteran / disability. We hand those back
608
+ // directly for matching dropdown labels, plus do a small amount of
609
+ // inference for derived questions ("transgender experience?" → No
610
+ // when user is cisgender, "veteran?" → No is the safe default the
611
+ // user can override). Anything we can't infer falls through to
612
+ // 'skip' so it gets reviewed.
613
+ if (cat === 'eeo') {
614
+ const label = (field.label || '').toLowerCase();
615
+ const pf = aep.profile_fill || {};
616
+ // Direct mapping by question text
617
+ if (/gender|sex/.test(label) && !/transgender|trans\s*experience/.test(label)) {
618
+ if (pf.gender) return { field, value: pf.gender, source: 'profile:eeo' };
619
+ }
620
+ if (/race|ethnicit|hispanic/.test(label)) {
621
+ if (pf.race) return { field, value: pf.race, source: 'profile:eeo' };
622
+ }
623
+ if (/veteran|military/.test(label)) {
624
+ if (pf.veteran) return { field, value: pf.veteran, source: 'profile:eeo' };
625
+ // Inference: most users are non-veterans; safer default is "No"
626
+ // (recruiters expect this field answered; blank → required-field
627
+ // error). User can change in profile.
628
+ return { field, value: 'I am not a protected veteran', source: 'profile:eeo-default' };
629
+ }
630
+ if (/disabilit/.test(label)) {
631
+ if (pf.disability) return { field, value: pf.disability, source: 'profile:eeo' };
632
+ return { field, value: "I don't wish to answer", source: 'profile:eeo-default' };
633
+ }
634
+ // Transgender / trans experience: cisgender implication when gender
635
+ // is set to non-trans. Cisgender users almost never want to
636
+ // accidentally self-ID as trans; defaulting to "No" when gender is
637
+ // explicit is safer than skipping (which causes a required-field
638
+ // error). Don't infer when gender is unset — that becomes 'skip'.
639
+ if (/transgender|trans\s*experience/.test(label)) {
640
+ if (pf.gender) return { field, value: 'No', source: 'profile:eeo-inferred' };
641
+ }
642
+ // Sexual orientation — never infer. User must self-ID.
643
+ if (/sexual\s*orientation|orientation\b/.test(label)) {
644
+ return { field, value: "I don't wish to answer", source: 'profile:eeo-default' };
645
+ }
646
+ // Pronouns — derivable from gender
647
+ if (/pronoun/.test(label)) {
648
+ if (pf.gender && /female|woman/i.test(pf.gender)) return { field, value: 'she/her', source: 'profile:eeo-inferred' };
649
+ if (pf.gender && /male|man/i.test(pf.gender)) return { field, value: 'he/him', source: 'profile:eeo-inferred' };
650
+ }
602
651
  return { field, value: null, source: 'skip' };
603
652
  }
604
653