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 +166 -31
- package/orchestrator.js +12 -0
- package/package.json +1 -1
- package/scanPage.js +51 -2
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
|
-
|
|
22
|
-
await
|
|
23
|
-
|
|
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,
|
|
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
|
|
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
|
|
53
|
-
|
|
54
|
-
if (
|
|
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.
|
|
59
|
-
}, value);
|
|
60
|
-
|
|
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
|
-
|
|
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
|
|
650
|
-
|
|
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
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
|
|