halo-agent 2.2.6 → 2.3.0
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/orchestrator.js +10 -3
- package/package.json +2 -1
- package/scanAccessibility.js +1 -1
- package/scanWorkday.js +154 -0
- package/smartFill.js +198 -6
package/orchestrator.js
CHANGED
|
@@ -53,9 +53,16 @@ async function fillFields(page, aep, opts) {
|
|
|
53
53
|
}
|
|
54
54
|
const { detectCaptcha, solveCaptcha, injectCaptchaToken } = require('./captcha');
|
|
55
55
|
const { visionFill, visionNavigateAndSubmit, visionFillSkipped } = require('./vision');
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
const { scanWorkday } = require('./scanWorkday');
|
|
57
|
+
|
|
58
|
+
// ATS types that need vision fallback due to shadow DOM / canvas.
|
|
59
|
+
// 'workday' was here historically because the AX-tree scan returned no usable
|
|
60
|
+
// fields on Workday's shadow-DOM forms. Phase 3 of the Workday end-to-end fix
|
|
61
|
+
// added scanWorkday() (DOM walk via [data-automation-id^="formField-"]) +
|
|
62
|
+
// pickWorkdayOption (scroll-and-scrape for long dropdowns), so Workday is now
|
|
63
|
+
// DOM-first like Greenhouse/Lever, with vision reserved as fallback only.
|
|
64
|
+
const VISION_ATS = new Set(['icims', 'taleo', 'sap', 'successfactors']);
|
|
65
|
+
const WORKDAY_ATS = 'workday';
|
|
59
66
|
|
|
60
67
|
/**
|
|
61
68
|
* Create a fresh FormContext for tracking state across a multi-page application.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "halo-agent",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "HALO local apply agent — auto-fills job applications using your real Chrome session",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"filler.js",
|
|
24
24
|
"scanPage.js",
|
|
25
25
|
"scanAccessibility.js",
|
|
26
|
+
"scanWorkday.js",
|
|
26
27
|
"smartFill.js",
|
|
27
28
|
"detectFormErrors.js",
|
|
28
29
|
"captcha.js",
|
package/scanAccessibility.js
CHANGED
package/scanWorkday.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Workday DOM scanner (Phase 3 of the Workday end-to-end fix).
|
|
5
|
+
*
|
|
6
|
+
* Workday forms live in a React shadow-DOM and the canonical anchor for every
|
|
7
|
+
* fillable widget is `[data-automation-id^="formField-"]`. The public repo
|
|
8
|
+
* shanjilcoding/resume-tailor-public's workday.js proved this approach is
|
|
9
|
+
* reliable enough for the common case — we borrow the SELECTORS and
|
|
10
|
+
* HEURISTICS (not the bookmarklet runtime) into Playwright.
|
|
11
|
+
*
|
|
12
|
+
* Why it exists: previously the agent treated Workday as VISION-ONLY in
|
|
13
|
+
* orchestrator.js (VISION_ATS set), which meant every Workday job cost a
|
|
14
|
+
* Claude Opus call per field (~40 iterations / $0.06 per job, slow). This
|
|
15
|
+
* scanner returns fields in the same shape scanAccessibility produces, so the
|
|
16
|
+
* existing smartFillPage / planner pipeline can drive Workday DOM-first.
|
|
17
|
+
* Vision stays as a fallback for fields this DOM pass misses (custom widgets,
|
|
18
|
+
* vendor-extended Workday tenants).
|
|
19
|
+
*
|
|
20
|
+
* Output shape (matches scanAccessibility for executor compatibility):
|
|
21
|
+
* {
|
|
22
|
+
* mmid, role, label, description, required, options, selectorHint,
|
|
23
|
+
* inputType, value, isTypeahead, groupLabel, filledAlready,
|
|
24
|
+
* }
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const { injectMmid } = require('./scanAccessibility');
|
|
28
|
+
|
|
29
|
+
async function scanWorkday(page) {
|
|
30
|
+
await injectMmid(page).catch(() => 0);
|
|
31
|
+
|
|
32
|
+
return await page.evaluate(() => {
|
|
33
|
+
function clean(s) { return String(s || '').replace(/\s+/g, ' ').trim(); }
|
|
34
|
+
|
|
35
|
+
// Workday inputs live INSIDE the formField wrapper; pick the most
|
|
36
|
+
// representative one (the visible interactive element).
|
|
37
|
+
function pickInner(root) {
|
|
38
|
+
return root.querySelector(
|
|
39
|
+
'input:not([type=hidden]):not([type=submit]),' +
|
|
40
|
+
'textarea, select,' +
|
|
41
|
+
'[role="combobox"], button[aria-haspopup="listbox"], button[aria-haspopup="dialog"],' +
|
|
42
|
+
'[role="checkbox"], [role="radio"]'
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Map DOM tag/role → the same role string scanAccessibility uses, so the
|
|
47
|
+
// planner doesn't need a special case for Workday.
|
|
48
|
+
function inferRole(root, inner) {
|
|
49
|
+
if (!inner) return 'textbox';
|
|
50
|
+
const tag = (inner.tagName || '').toLowerCase();
|
|
51
|
+
const role = inner.getAttribute('role');
|
|
52
|
+
const type = (inner.getAttribute('type') || '').toLowerCase();
|
|
53
|
+
if (tag === 'textarea') return 'textarea';
|
|
54
|
+
if (tag === 'select') return 'listbox';
|
|
55
|
+
if (role === 'combobox' || (tag === 'button' && /listbox|dialog/.test(inner.getAttribute('aria-haspopup') || ''))) return 'combobox';
|
|
56
|
+
if (role === 'checkbox' || type === 'checkbox') return 'checkbox';
|
|
57
|
+
if (role === 'radio' || type === 'radio') return 'radio';
|
|
58
|
+
if (type === 'file' || root.querySelector('input[type=file]')) return 'file_upload';
|
|
59
|
+
return 'textbox';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function nearestLabel(root, inner) {
|
|
63
|
+
// 1. label[for=id]
|
|
64
|
+
if (inner && inner.id) {
|
|
65
|
+
const lbl = document.querySelector(`label[for="${inner.id}"]`);
|
|
66
|
+
if (lbl) return clean(lbl.innerText || lbl.textContent || '').replace(/\*\s*$/, '').trim();
|
|
67
|
+
}
|
|
68
|
+
// 2. aria-labelledby
|
|
69
|
+
const ariaLbl = (inner && inner.getAttribute('aria-labelledby')) || root.getAttribute('aria-labelledby');
|
|
70
|
+
if (ariaLbl) {
|
|
71
|
+
const t = ariaLbl.split(/\s+/).map((id) => document.getElementById(id)).filter(Boolean)
|
|
72
|
+
.map((n) => clean(n.innerText || '')).join(' ').trim();
|
|
73
|
+
if (t) return t.replace(/\*\s*$/, '').trim();
|
|
74
|
+
}
|
|
75
|
+
// 3. A label/legend inside the formField wrapper (Workday's typical layout)
|
|
76
|
+
const lbl = root.querySelector('label, legend, [class*="label"]');
|
|
77
|
+
if (lbl) return clean(lbl.innerText || '').replace(/\*\s*$/, '').trim();
|
|
78
|
+
// 4. data-automation-id minus prefix as a last resort
|
|
79
|
+
const daid = root.getAttribute('data-automation-id') || '';
|
|
80
|
+
return daid.replace(/^formField-/, '').replace(/[-_]/g, ' ').trim() || '(unknown)';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isRequired(root, inner) {
|
|
84
|
+
if (inner) {
|
|
85
|
+
if (inner.required) return true;
|
|
86
|
+
if (inner.getAttribute('aria-required') === 'true') return true;
|
|
87
|
+
}
|
|
88
|
+
// Workday marks required labels with a trailing * inside the wrapper.
|
|
89
|
+
const lbl = root.querySelector('label, legend');
|
|
90
|
+
const raw = lbl ? (lbl.innerText || lbl.textContent || '') : '';
|
|
91
|
+
return /\*\s*$/.test(raw.trim()) || /\brequired\b/i.test(raw);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Get options if they're eagerly rendered. Workday's dropdowns mount their
|
|
95
|
+
// listbox on click, so this often returns null — smartFill's openAndPickOption
|
|
96
|
+
// already handles the click-then-look-then-scrape pattern.
|
|
97
|
+
function eagerOptions(root) {
|
|
98
|
+
const opts = [];
|
|
99
|
+
root.querySelectorAll('option, [role="option"]').forEach((o) => {
|
|
100
|
+
const t = clean(o.innerText || o.textContent || '');
|
|
101
|
+
if (t && opts.indexOf(t) === -1) opts.push(t);
|
|
102
|
+
});
|
|
103
|
+
return opts.length > 0 ? opts.slice(0, 200) : null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isCurrentlyFilled(inner, root) {
|
|
107
|
+
if (!inner) return false;
|
|
108
|
+
const tag = (inner.tagName || '').toLowerCase();
|
|
109
|
+
const type = (inner.getAttribute('type') || '').toLowerCase();
|
|
110
|
+
if (type === 'checkbox' || type === 'radio') return !!inner.checked;
|
|
111
|
+
if (type === 'file') return !!(inner.files && inner.files.length > 0);
|
|
112
|
+
if (tag === 'select') return !!inner.value && !!inner.value.trim();
|
|
113
|
+
// Combobox / button trigger: look for the value display sibling.
|
|
114
|
+
if (inner.getAttribute('role') === 'combobox' || tag === 'button') {
|
|
115
|
+
// Workday renders the selected value in a span with the displayed text.
|
|
116
|
+
const valEl = root.querySelector('[data-automation-id*="value"], [class*="selectedValue"]');
|
|
117
|
+
if (valEl) return !!clean(valEl.innerText || valEl.textContent || '');
|
|
118
|
+
}
|
|
119
|
+
return !!clean(inner.value || '');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const results = [];
|
|
123
|
+
const seen = new Set();
|
|
124
|
+
document.querySelectorAll('[data-automation-id^="formField-"]').forEach((node) => {
|
|
125
|
+
const root = node;
|
|
126
|
+
const daid = root.getAttribute('data-automation-id') || '';
|
|
127
|
+
// De-dup: some Workday templates nest formField wrappers; keep the outermost.
|
|
128
|
+
if (seen.has(daid)) return;
|
|
129
|
+
seen.add(daid);
|
|
130
|
+
const inner = pickInner(root);
|
|
131
|
+
const mmid = (inner && inner.getAttribute('mmid')) || root.getAttribute('mmid') || null;
|
|
132
|
+
if (!mmid) return; // injectMmid skipped it — likely not interactive
|
|
133
|
+
const label = nearestLabel(root, inner);
|
|
134
|
+
const role = inferRole(root, inner);
|
|
135
|
+
results.push({
|
|
136
|
+
mmid: String(mmid),
|
|
137
|
+
role,
|
|
138
|
+
label,
|
|
139
|
+
description: (inner && (inner.getAttribute('aria-description') || inner.getAttribute('placeholder'))) || null,
|
|
140
|
+
required: isRequired(root, inner),
|
|
141
|
+
options: eagerOptions(root),
|
|
142
|
+
selectorHint: `[data-automation-id="${daid}"]`,
|
|
143
|
+
inputType: (inner && inner.getAttribute('type')) || null,
|
|
144
|
+
value: (inner && clean(inner.value || '')) || '',
|
|
145
|
+
isTypeahead: role === 'combobox',
|
|
146
|
+
groupLabel: null,
|
|
147
|
+
filledAlready: isCurrentlyFilled(inner, root),
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
return results;
|
|
151
|
+
}).catch(() => []);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
module.exports = { scanWorkday };
|
package/smartFill.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
const { scanAccessibility } = require('./scanAccessibility');
|
|
20
|
+
const { scanWorkday } = require('./scanWorkday');
|
|
20
21
|
const { fillFields: legacyFillFields } = require('./filler');
|
|
21
22
|
|
|
22
23
|
// ── Plan executor ───────────────────────────────────────────────────────────
|
|
@@ -503,6 +504,133 @@ async function tryReactSelect(root, triggerLocator, value) {
|
|
|
503
504
|
}
|
|
504
505
|
}
|
|
505
506
|
|
|
507
|
+
/**
|
|
508
|
+
* Workday combobox / multi-select option picker.
|
|
509
|
+
*
|
|
510
|
+
* Workday's dropdowns mount a popover with [data-automation-id="activeListContainer"]
|
|
511
|
+
* holding [data-automation-id="promptOption"] entries. Long lists (country,
|
|
512
|
+
* university, state) are VIRTUALIZED — only ~10 entries render at once; you
|
|
513
|
+
* have to scroll the container to materialize the rest. The legacy
|
|
514
|
+
* openAndPickOption path would click + look once + give up on a 5-of-200 hit
|
|
515
|
+
* rate, which is why VISION_ATS = ['workday'] existed in the first place.
|
|
516
|
+
*
|
|
517
|
+
* Strategy (borrowed from shanjilcoding/resume-tailor-public/src/lib/workday.js
|
|
518
|
+
* lines ~600-750):
|
|
519
|
+
* 1. Click trigger, wait for activeListContainer.
|
|
520
|
+
* 2. Type the value to filter (Workday narrows the list as you type — saves
|
|
521
|
+
* most of the scrolling).
|
|
522
|
+
* 3. Snapshot visible promptOptions, scroll the container, snapshot again.
|
|
523
|
+
* Repeat until the option set is stable for 2 consecutive rounds OR we
|
|
524
|
+
* exceed a scroll budget.
|
|
525
|
+
* 4. Fuzzy-match the typed value against the union of all option texts.
|
|
526
|
+
* 5. Click the matching promptOption; verify the trigger now shows a value.
|
|
527
|
+
*/
|
|
528
|
+
async function pickWorkdayOption(root, triggerLocator, value) {
|
|
529
|
+
const page = (root && typeof root.page === 'function') ? root.page() : root;
|
|
530
|
+
try {
|
|
531
|
+
// Open the dropdown by clicking the trigger. Workday's trigger is often
|
|
532
|
+
// a button or div with role="combobox" — both respond to .click().
|
|
533
|
+
await triggerLocator.click({ timeout: 2500 }).catch(async () => {
|
|
534
|
+
await triggerLocator.click({ force: true, timeout: 1500 });
|
|
535
|
+
});
|
|
536
|
+
await page.waitForTimeout(300);
|
|
537
|
+
|
|
538
|
+
// Locate the popover. It's appended at document.body and identified by
|
|
539
|
+
// [data-automation-id="activeListContainer"] (Workday's canonical anchor).
|
|
540
|
+
const containerSel = '[data-automation-id="activeListContainer"]';
|
|
541
|
+
const optionSel = '[data-automation-id="promptOption"]';
|
|
542
|
+
try {
|
|
543
|
+
await root.locator(containerSel).first().waitFor({ state: 'visible', timeout: 2200 });
|
|
544
|
+
} catch {
|
|
545
|
+
return { ok: false, reason: 'workday: activeListContainer never appeared' };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// If the trigger also accepts text (searchable combobox), type the value
|
|
549
|
+
// to filter the list. Speeds up long-list matching dramatically.
|
|
550
|
+
const typed = String(value).split(/[,;]/)[0].trim();
|
|
551
|
+
try {
|
|
552
|
+
const isTextInput = await triggerLocator.evaluate((el) => {
|
|
553
|
+
const tag = (el.tagName || '').toLowerCase();
|
|
554
|
+
const type = (el.getAttribute('type') || '').toLowerCase();
|
|
555
|
+
return tag === 'input' && (type === 'text' || type === 'search' || type === '');
|
|
556
|
+
}).catch(() => false);
|
|
557
|
+
if (isTextInput && typed) {
|
|
558
|
+
await triggerLocator.fill('').catch(() => {});
|
|
559
|
+
await page.keyboard.type(typed, { delay: 30 });
|
|
560
|
+
await page.waitForTimeout(450); // let virtualized list re-render filtered set
|
|
561
|
+
}
|
|
562
|
+
} catch { /* not a text input — skip filter step */ }
|
|
563
|
+
|
|
564
|
+
// Scroll-and-scrape. Snapshot options, scroll, snapshot, repeat until
|
|
565
|
+
// stable for 2 rounds or we hit the scroll budget.
|
|
566
|
+
const container = root.locator(containerSel).first();
|
|
567
|
+
const seen = new Map(); // text → first index seen
|
|
568
|
+
const MAX_SCROLLS = 30;
|
|
569
|
+
let stableRounds = 0;
|
|
570
|
+
let prevSize = 0;
|
|
571
|
+
for (let i = 0; i < MAX_SCROLLS; i++) {
|
|
572
|
+
const visible = await root.locator(optionSel).evaluateAll((nodes) => {
|
|
573
|
+
return nodes.map((n) => (n.innerText || n.textContent || '').replace(/\s+/g, ' ').trim()).filter(Boolean);
|
|
574
|
+
}).catch(() => []);
|
|
575
|
+
for (const t of visible) { if (!seen.has(t)) seen.set(t, seen.size); }
|
|
576
|
+
if (seen.size === prevSize) {
|
|
577
|
+
stableRounds += 1;
|
|
578
|
+
if (stableRounds >= 2) break; // list exhausted
|
|
579
|
+
} else {
|
|
580
|
+
stableRounds = 0;
|
|
581
|
+
prevSize = seen.size;
|
|
582
|
+
}
|
|
583
|
+
// Scroll the container to materialize more entries.
|
|
584
|
+
await container.evaluate((el) => { el.scrollTop = el.scrollTop + el.clientHeight; }).catch(() => {});
|
|
585
|
+
await page.waitForTimeout(180);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const options = Array.from(seen.keys());
|
|
589
|
+
if (options.length === 0) {
|
|
590
|
+
return { ok: false, reason: 'workday: scrolled but no promptOption text found' };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Fuzzy match: exact → starts-with → contains → token overlap.
|
|
594
|
+
const v = String(value).toLowerCase().trim();
|
|
595
|
+
let pickText = options.find((t) => t.toLowerCase() === v)
|
|
596
|
+
|| options.find((t) => t.toLowerCase().startsWith(v))
|
|
597
|
+
|| options.find((t) => t.toLowerCase().includes(v) || v.includes(t.toLowerCase()));
|
|
598
|
+
if (!pickText) {
|
|
599
|
+
const vTokens = v.split(/\s+/).filter((t) => t.length > 2);
|
|
600
|
+
pickText = options.find((t) => {
|
|
601
|
+
const tt = t.toLowerCase();
|
|
602
|
+
return vTokens.some((vt) => tt.includes(vt));
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
if (!pickText) {
|
|
606
|
+
await page.keyboard.press('Escape').catch(() => {});
|
|
607
|
+
return { ok: false, reason: `workday: no option matched "${value}" (had ${options.length}: ${options.slice(0, 4).join(' / ')}${options.length > 4 ? '…' : ''})` };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Click by visible text — scoped to the popover so we don't accidentally
|
|
611
|
+
// hit a same-named link elsewhere on the page.
|
|
612
|
+
const target = root.locator(`${containerSel} ${optionSel}`).filter({ hasText: pickText }).first();
|
|
613
|
+
await target.scrollIntoViewIfNeeded({ timeout: 1500 }).catch(() => {});
|
|
614
|
+
await target.click({ timeout: 1500 }).catch(async () => {
|
|
615
|
+
await target.click({ force: true, timeout: 1500 });
|
|
616
|
+
});
|
|
617
|
+
await page.waitForTimeout(200);
|
|
618
|
+
|
|
619
|
+
// Verify: the popover should have closed and the trigger should reflect
|
|
620
|
+
// the selection. If neither holds, the click silently failed.
|
|
621
|
+
const stillOpen = await root.locator(containerSel).first().isVisible({ timeout: 400 }).catch(() => false);
|
|
622
|
+
if (stillOpen) {
|
|
623
|
+
// One more try with a keyboard Enter (Workday sometimes needs that on the
|
|
624
|
+
// highlighted option).
|
|
625
|
+
await page.keyboard.press('Enter').catch(() => {});
|
|
626
|
+
await page.waitForTimeout(150);
|
|
627
|
+
}
|
|
628
|
+
return { ok: true, reason: `workday picked: ${pickText}` };
|
|
629
|
+
} catch (e) {
|
|
630
|
+
return { ok: false, reason: `workday option picker threw: ${e.message}` };
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
506
634
|
async function openAndPickOption(root, triggerLocator, value, llmCtx) {
|
|
507
635
|
// root: Page or Frame. .locator works on both; keyboard/waitForTimeout
|
|
508
636
|
// need the Page. For in-frame fields, option menus render inside the
|
|
@@ -547,6 +675,26 @@ async function openAndPickOption(root, triggerLocator, value, llmCtx) {
|
|
|
547
675
|
}
|
|
548
676
|
} catch {}
|
|
549
677
|
|
|
678
|
+
try {
|
|
679
|
+
// Workday combobox / multi-select. Workday wraps every form widget in a
|
|
680
|
+
// [data-automation-id^="formField-"] container and its dropdowns mount a
|
|
681
|
+
// popover with [data-automation-id="activeListContainer"], whose options
|
|
682
|
+
// are [data-automation-id="promptOption"] entries. Long lists (country,
|
|
683
|
+
// state, university) are virtualized: scrolling triggers more entries to
|
|
684
|
+
// render. We borrow the scroll-and-scrape pattern from the public
|
|
685
|
+
// shanjilcoding/resume-tailor-public/src/lib/workday.js bookmarklet.
|
|
686
|
+
const isWorkdayCombo = await triggerLocator.evaluate((el) => {
|
|
687
|
+
const inside = !!el.closest('[data-automation-id^="formField-"]');
|
|
688
|
+
const onWorkday = /myworkdayjobs\.com|workday\.com/i.test(window.location.href);
|
|
689
|
+
return inside || onWorkday;
|
|
690
|
+
}).catch(() => false);
|
|
691
|
+
if (isWorkdayCombo) {
|
|
692
|
+
const r = await pickWorkdayOption(root, triggerLocator, value);
|
|
693
|
+
if (r && r.ok) return r;
|
|
694
|
+
// If it failed (not actually a Workday combobox after all), fall through.
|
|
695
|
+
}
|
|
696
|
+
} catch {}
|
|
697
|
+
|
|
550
698
|
// Second: Google Places typeahead. The Greenhouse Location field is
|
|
551
699
|
// marked role=combobox in AX, so the planner sends click_option — but
|
|
552
700
|
// it's really a type-then-pick autocomplete that opens a .pac-container
|
|
@@ -795,13 +943,33 @@ async function typeAndPickSuggestion(root, locator, value) {
|
|
|
795
943
|
* @returns {Promise<{filled, skipped, failed, askUserReasons, planned}>}
|
|
796
944
|
*/
|
|
797
945
|
async function smartFillPage(page, aep, options) {
|
|
798
|
-
const { config, jobId, resumePath, coverLetterPath, ctx } = options;
|
|
946
|
+
const { config, jobId, resumePath, coverLetterPath, ctx, ats } = options;
|
|
799
947
|
|
|
800
|
-
// 1. Scan via AX tree
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
948
|
+
// 1. Scan via AX tree. For Workday, prefer the DOM-walker that targets
|
|
949
|
+
// [data-automation-id^="formField-"] — the AX-tree scan returns mostly
|
|
950
|
+
// nothing on Workday's shadow-DOM React, which is what historically forced
|
|
951
|
+
// the entire job into vision (expensive). If scanWorkday returns <5 fields
|
|
952
|
+
// (atypical posting, login wall) we fall through to scanAccessibility so
|
|
953
|
+
// the orchestrator's downstream vision precision-fill still gets a shot.
|
|
954
|
+
const isWorkdayAts = (String(ats || '').toLowerCase() === 'workday')
|
|
955
|
+
|| /myworkdayjobs\.com|workday\.com/i.test(page.url());
|
|
956
|
+
let fields = [];
|
|
957
|
+
if (isWorkdayAts) {
|
|
958
|
+
const wd = await scanWorkday(page).catch((e) => {
|
|
959
|
+
console.warn(`[smartFill] scanWorkday failed: ${e.message}`);
|
|
960
|
+
return [];
|
|
961
|
+
});
|
|
962
|
+
if (wd.length >= 5) {
|
|
963
|
+
fields = wd;
|
|
964
|
+
console.log(`[scanWorkday] ${fields.length} fields detected via data-automation-id`);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
if (fields.length === 0) {
|
|
968
|
+
fields = await scanAccessibility(page).catch((e) => {
|
|
969
|
+
console.warn(`[smartFill] scanAccessibility failed: ${e.message}`);
|
|
970
|
+
return [];
|
|
971
|
+
});
|
|
972
|
+
}
|
|
805
973
|
if (fields.length === 0) {
|
|
806
974
|
console.warn('[smartFill] no fields detected on page');
|
|
807
975
|
return { filled: 0, skipped: 0, failed: 0, askUserReasons: [], planned: 0, fallback: false };
|
|
@@ -846,6 +1014,30 @@ async function smartFillPage(page, aep, options) {
|
|
|
846
1014
|
|
|
847
1015
|
console.log(`[smartFill] planner returned ${plan.length} actions for ${fields.length} fields`);
|
|
848
1016
|
|
|
1017
|
+
// Phase 4 of the Workday end-to-end fix: dry-run preview, Workday only.
|
|
1018
|
+
// Workday forms are LONG (often 30+ fields across multiple pages) and the
|
|
1019
|
+
// cost of a wrong fill — especially on demographic / sponsorship / clearance
|
|
1020
|
+
// fields where the form rejects misclicks — is high. We render every
|
|
1021
|
+
// planned action as a single readable line and hold 8 seconds before
|
|
1022
|
+
// touching the DOM, letting the user Ctrl-C if anything looks off.
|
|
1023
|
+
// Off-switch: config.workdayDryRun = false in ~/.halo-agent/config.json.
|
|
1024
|
+
// Greenhouse / Lever / Ashby do NOT get this gate — their forms are short
|
|
1025
|
+
// enough that the existing post-fill REVIEWING gate covers it.
|
|
1026
|
+
if (isWorkdayAts && config?.workdayDryRun !== false) {
|
|
1027
|
+
console.log('');
|
|
1028
|
+
console.log(`[dry-run] Workday — about to fill ${plan.length} fields. Ctrl-C in the next 8s to cancel.`);
|
|
1029
|
+
const planByMmidPreview = new Map(plan.map((p) => [String(p.mmid), p]));
|
|
1030
|
+
for (const f of fields) {
|
|
1031
|
+
const it = planByMmidPreview.get(String(f.mmid));
|
|
1032
|
+
if (!it || it.action === 'skip') continue;
|
|
1033
|
+
const labelShort = (f.label || f.selectorHint || '?').slice(0, 50);
|
|
1034
|
+
const valShort = String(it.value || '').slice(0, 60).replace(/\s+/g, ' ');
|
|
1035
|
+
console.log(` · ${it.action.padEnd(13)} ${labelShort.padEnd(50)} ${valShort}`);
|
|
1036
|
+
}
|
|
1037
|
+
console.log('');
|
|
1038
|
+
await page.waitForTimeout(8000);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
849
1041
|
// 4. Execute the plan in DOM order (planner doesn't dictate order; we
|
|
850
1042
|
// mirror the field scan order so dependent fields fill after their parents).
|
|
851
1043
|
const fieldByMmid = new Map(fields.map((f) => [String(f.mmid), f]));
|