halo-agent 1.3.4 → 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 +119 -3
- package/orchestrator.js +12 -0
- package/package.json +1 -1
- package/scanPage.js +51 -2
package/filler.js
CHANGED
|
@@ -271,7 +271,35 @@ async function fillLocator(page, locator, value, label) {
|
|
|
271
271
|
isContentEditable: el.isContentEditable,
|
|
272
272
|
hasOptions: el.tagName === 'SELECT',
|
|
273
273
|
optionCount: el.tagName === 'SELECT' ? el.options.length : 0,
|
|
274
|
-
|
|
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
|
+
}
|
|
275
303
|
|
|
276
304
|
// ── Native <select> ──
|
|
277
305
|
if (meta.tag === 'select' || meta.hasOptions) {
|
|
@@ -362,6 +390,65 @@ async function fillLocator(page, locator, value, label) {
|
|
|
362
390
|
* Strategy: click trigger → wait for option list → find closest matching option → click it.
|
|
363
391
|
* Handles React Select, Ashby custom select, Lever, Greenhouse custom dropdowns.
|
|
364
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
|
+
|
|
365
452
|
async function fillCustomDropdown(page, triggerLocator, value) {
|
|
366
453
|
try {
|
|
367
454
|
await triggerLocator.click();
|
|
@@ -665,8 +752,37 @@ async function fillFields(page, aep, options = {}) {
|
|
|
665
752
|
continue;
|
|
666
753
|
}
|
|
667
754
|
|
|
668
|
-
// File uploads — handled separately by uploadResume() in
|
|
669
|
-
|
|
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
|
+
}
|
|
670
786
|
|
|
671
787
|
// EEO / consent — skip silently (vision handles EEO, user handles consent)
|
|
672
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
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
|
-
//
|
|
601
|
-
if (cat === '
|
|
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
|
|