halo-agent 2.2.4 → 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 +51 -5
- package/package.json +2 -1
- package/scanAccessibility.js +1 -1
- package/scanWorkday.js +154 -0
- package/smartFill.js +285 -19
package/orchestrator.js
CHANGED
|
@@ -48,13 +48,21 @@ async function fillFields(page, aep, opts) {
|
|
|
48
48
|
plannedActions: result.planned || 0,
|
|
49
49
|
fellBackToLegacy: !!result.fallback,
|
|
50
50
|
resumeUploaded: !!result.resumeUploaded,
|
|
51
|
+
coverLetterUploaded: !!result.coverLetterUploaded,
|
|
51
52
|
};
|
|
52
53
|
}
|
|
53
54
|
const { detectCaptcha, solveCaptcha, injectCaptchaToken } = require('./captcha');
|
|
54
55
|
const { visionFill, visionNavigateAndSubmit, visionFillSkipped } = require('./vision');
|
|
56
|
+
const { scanWorkday } = require('./scanWorkday');
|
|
55
57
|
|
|
56
|
-
// ATS types that need vision fallback due to shadow DOM / canvas
|
|
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';
|
|
58
66
|
|
|
59
67
|
/**
|
|
60
68
|
* Create a fresh FormContext for tracking state across a multi-page application.
|
|
@@ -684,12 +692,45 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
|
|
|
684
692
|
}
|
|
685
693
|
|
|
686
694
|
if (finalState === 'REVIEWING') {
|
|
695
|
+
// CRITICAL: keep the Chrome tab OPEN and the agent ALIVE so the user can
|
|
696
|
+
// actually take over. Previously this branch reported REVIEWING then
|
|
697
|
+
// fell through → the `finally` closed the tab and the agent moved on,
|
|
698
|
+
// leaving a REVIEWING row with no open tab and nothing listening — a
|
|
699
|
+
// dead-end the user couldn't resolve. Now: leave the tab open, tell the
|
|
700
|
+
// user what's blank, and wait for them to finish in Chrome + click
|
|
701
|
+
// Submit (which the agent's waitForSubmitConfirmation is parked on).
|
|
702
|
+
leaveTabOpen = true;
|
|
703
|
+
// Re-check exactly which required fields are still empty so the message
|
|
704
|
+
// names them (the user is staring at the form; tell them what to fix).
|
|
705
|
+
const stillEmpty = await detectRequiredEmpties(page, {
|
|
706
|
+
excludeLabels: (ctx.skippedFields || []).map((s) => s.label),
|
|
707
|
+
resumeUploaded: !!fillResult.resumeUploaded,
|
|
708
|
+
}).catch(() => ({ emptyFields: [] }));
|
|
709
|
+
const emptyList = stillEmpty.emptyFields.map((f) => f.label).slice(0, 8).join(', ');
|
|
710
|
+
const reason = emptyList
|
|
711
|
+
? `${stillEmpty.emptyFields.length} field(s) need you: ${emptyList}. Fill them in the open Chrome tab, then click Submit.`
|
|
712
|
+
: `Couldn't auto-confirm this submit. Eyeball the open Chrome tab, then click Submit.`;
|
|
687
713
|
await reportStatus('REVIEWING', {
|
|
688
714
|
review_screenshot_r2_key: confirmKey || null,
|
|
689
715
|
step: 'REVIEWING',
|
|
690
|
-
step_detail:
|
|
716
|
+
step_detail: reason.slice(0, 200),
|
|
717
|
+
needs_attention_reason: reason,
|
|
691
718
|
fields_filled: cumulativeFilled,
|
|
692
719
|
});
|
|
720
|
+
console.log(`[orchestrator] REVIEWING — tab left open. ${reason}`);
|
|
721
|
+
// Park here until the user clicks Submit on the dashboard. The tab stays
|
|
722
|
+
// open the whole time so they can fill the blanks in their own Chrome.
|
|
723
|
+
await waitForSubmitConfirmation(config, queueId);
|
|
724
|
+
// They filled the blanks and clicked Submit — click the form's submit.
|
|
725
|
+
const finishBtn = await findSubmitButton(page).catch(() => null);
|
|
726
|
+
if (finishBtn) {
|
|
727
|
+
console.log('[orchestrator] User confirmed — clicking submit on the finished form.');
|
|
728
|
+
await finishBtn.click().catch(() => {});
|
|
729
|
+
await page.waitForTimeout(2500);
|
|
730
|
+
}
|
|
731
|
+
await reportStatus('DONE', { confirmation_screenshot_r2_key: confirmKey || null, fields_filled: cumulativeFilled });
|
|
732
|
+
await clearCheckpoint(config, queueId);
|
|
733
|
+
console.log(`[orchestrator] Done (user-completed): ${queueItem.company} - ${queueItem.title}`);
|
|
693
734
|
} else {
|
|
694
735
|
leaveTabOpen = true;
|
|
695
736
|
await reportStatus('DONE', {
|
|
@@ -724,8 +765,13 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
|
|
|
724
765
|
filled_actions: filledActions,
|
|
725
766
|
// Pass through which actual files were uploaded — backend writes to
|
|
726
767
|
// submissions.resume_pdf_r2_key / cover_letter_pdf_r2_key for receipt.
|
|
727
|
-
|
|
728
|
-
|
|
768
|
+
// ONLY report a file key if the agent ACTUALLY uploaded it. Previously
|
|
769
|
+
// the cover-letter key was sent whenever the packet HAD one, even when
|
|
770
|
+
// the upload FAILED ("no cover_letter file available") — so the receipt
|
|
771
|
+
// claimed a cover letter that was never submitted. The receipt must
|
|
772
|
+
// reflect what landed on the form, not what the packet contained.
|
|
773
|
+
resume_pdf_r2_key: (fillResult.resumeUploaded || tempResumeFile) ? (aep?.recommended_resume?.pdf_r2_key || null) : null,
|
|
774
|
+
cover_letter_pdf_r2_key: fillResult.coverLetterUploaded ? (aep?.cover_letter_pdf?.pdf_r2_key || null) : null,
|
|
729
775
|
}).catch(() => {}); // non-critical
|
|
730
776
|
|
|
731
777
|
} catch (err) {
|
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 ───────────────────────────────────────────────────────────
|
|
@@ -188,14 +189,61 @@ async function executePlanItem(page, item, fieldByMmid, ctx) {
|
|
|
188
189
|
}
|
|
189
190
|
}
|
|
190
191
|
}
|
|
191
|
-
// Strategy 2:
|
|
192
|
+
// Strategy 2: custom radios (Ashby etc) — click the labeled option,
|
|
193
|
+
// but SCOPED to THIS field's own radiogroup. The old code matched
|
|
194
|
+
// `[role="radio"]:has-text("Yes")` across the WHOLE page via .first(),
|
|
195
|
+
// so on a form with several Yes/No questions it clicked the first
|
|
196
|
+
// "Yes" on the page (or nothing) instead of the one for this field —
|
|
197
|
+
// the Gen Digital bug where sponsorship + office-commitment radios
|
|
198
|
+
// ended up blank. Anchor on the scanned element's group container.
|
|
192
199
|
const v = String(item.value);
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
200
|
+
// Tag the field's enclosing radiogroup so we can scope option lookup.
|
|
201
|
+
const groupId = await locator.evaluate((el) => {
|
|
202
|
+
// Walk up to the nearest radiogroup / fieldset / Ashby field entry.
|
|
203
|
+
let p = el;
|
|
204
|
+
for (let i = 0; i < 8 && p; i++, p = p.parentElement) {
|
|
205
|
+
const role = p.getAttribute && p.getAttribute('role');
|
|
206
|
+
const cls = String(p.className || '');
|
|
207
|
+
if (role === 'radiogroup' || p.tagName === 'FIELDSET'
|
|
208
|
+
|| /_fieldEntry_|ashby-application-form-field-entry|radio.?group/i.test(cls)) {
|
|
209
|
+
const gid = 'halo-rg-' + Math.random().toString(36).slice(2, 8);
|
|
210
|
+
p.setAttribute('data-halo-rg', gid);
|
|
211
|
+
return gid;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
}).catch(() => null);
|
|
216
|
+
|
|
217
|
+
const groupRoot = groupId ? root.locator(`[data-halo-rg="${groupId}"]`) : root;
|
|
218
|
+
// Within the group, find the option whose visible label matches value.
|
|
219
|
+
// Try role=radio, label, and a generic clickable row with the text.
|
|
220
|
+
const optInGroup = groupRoot.locator(
|
|
221
|
+
`[role="radio"]:has-text("${v}"), label:has-text("${v}"), ` +
|
|
222
|
+
`[class*="option"]:has-text("${v}"), [class*="radio"]:has-text("${v}")`
|
|
223
|
+
).first();
|
|
224
|
+
if (await optInGroup.isVisible({ timeout: 1000 }).catch(() => false)) {
|
|
225
|
+
await optInGroup.click({ timeout: 1500 }).catch(async () => {
|
|
226
|
+
await optInGroup.click({ force: true, timeout: 1500 });
|
|
227
|
+
});
|
|
228
|
+
// Verify it registered: the matched radio should now be checked /
|
|
229
|
+
// aria-checked. Ashby toggles aria-checked on the role=radio element.
|
|
230
|
+
await page.waitForTimeout(150);
|
|
231
|
+
const stuck = await optInGroup.evaluate((el) => {
|
|
232
|
+
const r = el.querySelector('[role="radio"]') || el.closest('[role="radio"]') || el;
|
|
233
|
+
return r.getAttribute('aria-checked') === 'true'
|
|
234
|
+
|| (r.querySelector && r.querySelector('input:checked') != null)
|
|
235
|
+
|| (r.tagName === 'INPUT' && r.checked);
|
|
236
|
+
}).catch(() => true); // if we can't tell, assume click worked
|
|
237
|
+
if (stuck) return { ok: true, reason: `radio (scoped label): ${v}` };
|
|
238
|
+
// Didn't register — click the input inside the option as a fallback.
|
|
239
|
+
const innerInput = optInGroup.locator('input[type="radio"]').first();
|
|
240
|
+
if (await innerInput.count().catch(() => 0)) {
|
|
241
|
+
await innerInput.check({ force: true, timeout: 1500 }).catch(() => {});
|
|
242
|
+
return { ok: true, reason: `radio (scoped input): ${v}` };
|
|
243
|
+
}
|
|
244
|
+
return { ok: true, reason: `radio (clicked, unverified): ${v}` };
|
|
197
245
|
}
|
|
198
|
-
return { ok: false, reason: `radio option "${item.value}" not found` };
|
|
246
|
+
return { ok: false, reason: `radio option "${item.value}" not found in group` };
|
|
199
247
|
} catch (e) {
|
|
200
248
|
return { ok: false, reason: `radio failed: ${e.message}` };
|
|
201
249
|
}
|
|
@@ -456,6 +504,133 @@ async function tryReactSelect(root, triggerLocator, value) {
|
|
|
456
504
|
}
|
|
457
505
|
}
|
|
458
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
|
+
|
|
459
634
|
async function openAndPickOption(root, triggerLocator, value, llmCtx) {
|
|
460
635
|
// root: Page or Frame. .locator works on both; keyboard/waitForTimeout
|
|
461
636
|
// need the Page. For in-frame fields, option menus render inside the
|
|
@@ -477,6 +652,49 @@ async function openAndPickOption(root, triggerLocator, value, llmCtx) {
|
|
|
477
652
|
if (itlResult !== null && itlResult.ok) return itlResult;
|
|
478
653
|
} catch {}
|
|
479
654
|
|
|
655
|
+
try {
|
|
656
|
+
// Ashby async typeahead. Ashby's Location field is role="combobox" with
|
|
657
|
+
// aria-haspopup="listbox" and an input class like `_input_v5ami_28`. It
|
|
658
|
+
// renders NO options until you type, then fetches matches over the
|
|
659
|
+
// network (~700ms) and shows them as [role="option"]. The generic
|
|
660
|
+
// open-and-look path opened it, saw an empty menu, and bailed — that's
|
|
661
|
+
// the Gen Digital "dropdown opened but no visible options found" failure.
|
|
662
|
+
// Detect Ashby's widget and route to type-then-wait-for-async-options.
|
|
663
|
+
const isAshbyCombo = await triggerLocator.evaluate((el) => {
|
|
664
|
+
const role = el.getAttribute('role');
|
|
665
|
+
const hasPopup = el.getAttribute('aria-haspopup');
|
|
666
|
+
const cls = String(el.className || '');
|
|
667
|
+
// Ashby's emotion-hashed input class, or the generic combobox+listbox shape.
|
|
668
|
+
const ashbyClass = /_input_/.test(cls) && !!el.closest('[class*="_fieldEntry_"], .ashby-application-form-field-entry');
|
|
669
|
+
return (role === 'combobox' && (hasPopup === 'listbox' || hasPopup === 'true')) || ashbyClass;
|
|
670
|
+
}).catch(() => false);
|
|
671
|
+
if (isAshbyCombo) {
|
|
672
|
+
const r = await typeAndPickSuggestion(root, triggerLocator, value);
|
|
673
|
+
if (r.ok) return r;
|
|
674
|
+
// If typeahead didn't land, fall through to the generic path below.
|
|
675
|
+
}
|
|
676
|
+
} catch {}
|
|
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
|
+
|
|
480
698
|
// Second: Google Places typeahead. The Greenhouse Location field is
|
|
481
699
|
// marked role=combobox in AX, so the planner sends click_option — but
|
|
482
700
|
// it's really a type-then-pick autocomplete that opens a .pac-container
|
|
@@ -725,13 +943,33 @@ async function typeAndPickSuggestion(root, locator, value) {
|
|
|
725
943
|
* @returns {Promise<{filled, skipped, failed, askUserReasons, planned}>}
|
|
726
944
|
*/
|
|
727
945
|
async function smartFillPage(page, aep, options) {
|
|
728
|
-
const { config, jobId, resumePath, coverLetterPath, ctx } = options;
|
|
946
|
+
const { config, jobId, resumePath, coverLetterPath, ctx, ats } = options;
|
|
729
947
|
|
|
730
|
-
// 1. Scan via AX tree
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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
|
+
}
|
|
735
973
|
if (fields.length === 0) {
|
|
736
974
|
console.warn('[smartFill] no fields detected on page');
|
|
737
975
|
return { filled: 0, skipped: 0, failed: 0, askUserReasons: [], planned: 0, fallback: false };
|
|
@@ -776,12 +1014,37 @@ async function smartFillPage(page, aep, options) {
|
|
|
776
1014
|
|
|
777
1015
|
console.log(`[smartFill] planner returned ${plan.length} actions for ${fields.length} fields`);
|
|
778
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
|
+
|
|
779
1041
|
// 4. Execute the plan in DOM order (planner doesn't dictate order; we
|
|
780
1042
|
// mirror the field scan order so dependent fields fill after their parents).
|
|
781
1043
|
const fieldByMmid = new Map(fields.map((f) => [String(f.mmid), f]));
|
|
782
1044
|
const planByMmid = new Map(plan.map((p) => [String(p.mmid), p]));
|
|
783
1045
|
let filled = 0, skipped = 0, failed = 0;
|
|
784
|
-
let resumeUploaded = false;
|
|
1046
|
+
let resumeUploaded = false; // so the orchestrator can skip its blanket fallback
|
|
1047
|
+
let coverLetterUploaded = false; // so the receipt only claims a cover letter if one actually went in
|
|
785
1048
|
const askUserReasons = [];
|
|
786
1049
|
|
|
787
1050
|
for (const f of fields) {
|
|
@@ -820,11 +1083,14 @@ async function smartFillPage(page, aep, options) {
|
|
|
820
1083
|
} else {
|
|
821
1084
|
console.log(`[smartFill] ${item.action} "${labelShort}" — ${result.reason}${item.reasoning ? ` (why: ${item.reasoning.slice(0, 80)})` : ''}`);
|
|
822
1085
|
filled += 1;
|
|
823
|
-
//
|
|
824
|
-
//
|
|
825
|
-
//
|
|
826
|
-
|
|
827
|
-
|
|
1086
|
+
// Track which files actually went in. resumeUploaded gates the
|
|
1087
|
+
// orchestrator's blanket fallback; coverLetterUploaded gates whether
|
|
1088
|
+
// the RECEIPT is allowed to claim a cover letter was submitted (it was
|
|
1089
|
+
// lying before — packet had a cover-letter key, upload FAILED, receipt
|
|
1090
|
+
// still showed it).
|
|
1091
|
+
if (item.action === 'upload_file') {
|
|
1092
|
+
if (String(item.value).toLowerCase().trim() === 'cover_letter') coverLetterUploaded = true;
|
|
1093
|
+
else resumeUploaded = true;
|
|
828
1094
|
}
|
|
829
1095
|
// Track in ctx for cross-page dedup
|
|
830
1096
|
if (ctx && ctx.answeredFields) {
|
|
@@ -843,7 +1109,7 @@ async function smartFillPage(page, aep, options) {
|
|
|
843
1109
|
}
|
|
844
1110
|
}
|
|
845
1111
|
|
|
846
|
-
return { filled, skipped, failed, askUserReasons, planned: plan.length, fallback: false, resumeUploaded };
|
|
1112
|
+
return { filled, skipped, failed, askUserReasons, planned: plan.length, fallback: false, resumeUploaded, coverLetterUploaded };
|
|
847
1113
|
}
|
|
848
1114
|
|
|
849
1115
|
module.exports = { smartFillPage };
|