halo-agent 2.2.0 → 2.2.2
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/detectFormErrors.js +223 -1
- package/orchestrator.js +91 -20
- package/package.json +1 -1
- package/smartFill.js +61 -2
package/detectFormErrors.js
CHANGED
|
@@ -286,4 +286,226 @@ async function detectFormErrorsInRoot(page) {
|
|
|
286
286
|
});
|
|
287
287
|
}
|
|
288
288
|
|
|
289
|
-
|
|
289
|
+
/**
|
|
290
|
+
* Proactive required-field emptiness check.
|
|
291
|
+
*
|
|
292
|
+
* detectFormErrors is REACTIVE — it only reports a problem if the ATS has
|
|
293
|
+
* already rendered an error (aria-invalid, "is required" text, error class).
|
|
294
|
+
* On Ashby, a required combobox the agent failed to fill stays empty WITHOUT
|
|
295
|
+
* the form rendering a visible error until/unless submit is attempted — and
|
|
296
|
+
* sometimes not even then in the DOM snapshot we capture. So a reactive check
|
|
297
|
+
* sees a clean page and the agent auto-submits an incomplete form (the Plaid
|
|
298
|
+
* failure).
|
|
299
|
+
*
|
|
300
|
+
* This is the ground-truth, ATS-agnostic guard: walk every field the form
|
|
301
|
+
* marks REQUIRED (asterisked label, `required` attr, aria-required="true")
|
|
302
|
+
* and return the ones that are empty — using the same isEmptyField logic
|
|
303
|
+
* detectFormErrors uses, so react-select / Ashby comboboxes are judged by
|
|
304
|
+
* their rendered value chip, not the hidden input's .value.
|
|
305
|
+
*
|
|
306
|
+
* The orchestrator calls this BEFORE submitting: if any required field is
|
|
307
|
+
* empty, never auto-submit — route to REVIEWING with the list.
|
|
308
|
+
*
|
|
309
|
+
* Output: { hasEmptyRequired: boolean, emptyFields: [{label, selector?}] }
|
|
310
|
+
*/
|
|
311
|
+
async function detectRequiredEmpties(page, opts = {}) {
|
|
312
|
+
const roots = [page, ...page.frames().filter((f) => {
|
|
313
|
+
const u = f.url();
|
|
314
|
+
return f !== page.mainFrame() && u && DFE_EMBED_HOSTS.test(u) && !/google\.com|gstatic\.com|recaptcha/i.test(u);
|
|
315
|
+
})];
|
|
316
|
+
let merged = { hasEmptyRequired: false, emptyFields: [] };
|
|
317
|
+
for (const root of roots) {
|
|
318
|
+
const r = await detectRequiredEmptiesInRoot(root).catch(() => null);
|
|
319
|
+
if (r) {
|
|
320
|
+
merged.hasEmptyRequired = merged.hasEmptyRequired || r.hasEmptyRequired;
|
|
321
|
+
merged.emptyFields.push(...(r.emptyFields || []));
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
// Exclude labels the planner deliberately skipped (conditional-not-applicable,
|
|
325
|
+
// honeypots, optional). The planner has dependency context the DOM walk lacks,
|
|
326
|
+
// so a field it intentionally left blank is NOT an unfilled required field.
|
|
327
|
+
const excluded = new Set((opts.excludeLabels || []).map((l) => String(l || '').toLowerCase().trim()).filter(Boolean));
|
|
328
|
+
// Dedup by label, drop excluded.
|
|
329
|
+
const seen = new Set();
|
|
330
|
+
merged.emptyFields = merged.emptyFields.filter((f) => {
|
|
331
|
+
const k = (f.label || '').toLowerCase().trim();
|
|
332
|
+
if (!k || seen.has(k)) return false;
|
|
333
|
+
seen.add(k);
|
|
334
|
+
// If smartFill confirmed a resume upload, a file/resume field reading
|
|
335
|
+
// "empty" is a false positive: Ashby (and similar) keep the native
|
|
336
|
+
// <input type=file> at files.length===0 after upload because they stash
|
|
337
|
+
// the file in their own state and show a filename chip elsewhere. Don't
|
|
338
|
+
// block on it.
|
|
339
|
+
if (opts.resumeUploaded && f.isFile) return false;
|
|
340
|
+
// Match excluded loosely — DOM label vs planner label can differ by
|
|
341
|
+
// trailing punctuation/truncation.
|
|
342
|
+
for (const ex of excluded) {
|
|
343
|
+
if (k === ex || k.startsWith(ex) || ex.startsWith(k) || k.includes(ex) || ex.includes(k)) return false;
|
|
344
|
+
}
|
|
345
|
+
return true;
|
|
346
|
+
});
|
|
347
|
+
merged.hasEmptyRequired = merged.emptyFields.length > 0;
|
|
348
|
+
return merged;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function detectRequiredEmptiesInRoot(page) {
|
|
352
|
+
return await page.evaluate(() => {
|
|
353
|
+
function visibleText(el) {
|
|
354
|
+
if (!el) return '';
|
|
355
|
+
const rect = el.getBoundingClientRect();
|
|
356
|
+
if (rect.width === 0 && rect.height === 0) return '';
|
|
357
|
+
const t = (el.innerText || el.textContent || '').trim();
|
|
358
|
+
return t ? t.replace(/\s+/g, ' ').slice(0, 300) : '';
|
|
359
|
+
}
|
|
360
|
+
function nearestLabel(el) {
|
|
361
|
+
if (el.id) {
|
|
362
|
+
const lbl = document.querySelector(`label[for="${el.id}"]`);
|
|
363
|
+
if (lbl) return visibleText(lbl).replace(/\*$/, '').trim();
|
|
364
|
+
}
|
|
365
|
+
if (el.labels && el.labels[0]) return visibleText(el.labels[0]).replace(/\*$/, '').trim();
|
|
366
|
+
const al = el.getAttribute('aria-labelledby');
|
|
367
|
+
if (al) {
|
|
368
|
+
const t = al.split(/\s+/).map((id) => document.getElementById(id)).filter(Boolean).map(visibleText).join(' ').trim();
|
|
369
|
+
if (t) return t;
|
|
370
|
+
}
|
|
371
|
+
let p = el.parentElement; let hops = 0;
|
|
372
|
+
while (p && hops < 6) {
|
|
373
|
+
const lbl = p.querySelector('label, legend, h3, h4');
|
|
374
|
+
if (lbl && !lbl.contains(el)) {
|
|
375
|
+
const t = visibleText(lbl).replace(/\*$/, '').trim();
|
|
376
|
+
if (t && t.length < 200) return t;
|
|
377
|
+
}
|
|
378
|
+
p = p.parentElement; hops += 1;
|
|
379
|
+
}
|
|
380
|
+
return el.getAttribute('aria-label') || el.placeholder || el.name || el.id || '(unknown)';
|
|
381
|
+
}
|
|
382
|
+
// Same emptiness logic as detectFormErrors (kept in sync deliberately).
|
|
383
|
+
function isEmptyField(el) {
|
|
384
|
+
const tag = el.tagName.toLowerCase();
|
|
385
|
+
const type = (el.type || '').toLowerCase();
|
|
386
|
+
if (type === 'checkbox' || type === 'radio') return !el.checked;
|
|
387
|
+
if (tag === 'select') {
|
|
388
|
+
if (!el.value) return true;
|
|
389
|
+
const selOpt = el.options[el.selectedIndex];
|
|
390
|
+
if (selOpt && !selOpt.value) return true;
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
if (type === 'file') return !el.files || el.files.length === 0;
|
|
394
|
+
if (el.isContentEditable) return !(el.innerText || '').trim();
|
|
395
|
+
if (el.getAttribute('role') === 'combobox' || tag === 'select') {
|
|
396
|
+
let wrap = el.closest('[class*="select__control"]');
|
|
397
|
+
if (!wrap) { let p = el; for (let i = 0; i < 8 && p; i++, p = p.parentElement) { if (/select__control|select-control/i.test(p.className || '')) { wrap = p; break; } } }
|
|
398
|
+
if (!wrap) wrap = el.closest('[class*="select"], [class*="combobox"], [class*="Select"]') || el.parentElement;
|
|
399
|
+
if (wrap) {
|
|
400
|
+
const valueEl = wrap.querySelector('[class*="single-value"], [class*="multi-value"], [class*="multiValue"], [class*="singleValue"], [class*="chip"], [class*="tag"]');
|
|
401
|
+
if (valueEl && (valueEl.innerText || valueEl.textContent || '').trim()) return false;
|
|
402
|
+
const wrapText = (wrap.innerText || '').trim();
|
|
403
|
+
const placeholderEl = wrap.querySelector('[class*="placeholder"]');
|
|
404
|
+
const placeholderText = placeholderEl ? (placeholderEl.innerText || '').trim() : '';
|
|
405
|
+
if (wrapText && wrapText !== placeholderText && !/^select\b|^choose\b|^start typing|^\.\.\./i.test(wrapText)) return false;
|
|
406
|
+
}
|
|
407
|
+
const v = (el.value || el.innerText || '').trim();
|
|
408
|
+
return !v;
|
|
409
|
+
}
|
|
410
|
+
return !(el.value || '').trim();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// A radiogroup / checkbox-set counts as "filled" if ANY member is checked.
|
|
414
|
+
// We track required groups by name so we don't flag every individual radio.
|
|
415
|
+
function isRequired(el) {
|
|
416
|
+
// Programmatic markers are authoritative and field-specific.
|
|
417
|
+
if (el.required) return true;
|
|
418
|
+
if (el.getAttribute('aria-required') === 'true') return true;
|
|
419
|
+
|
|
420
|
+
// Asterisk/"required" in label text is the common ATS convention, BUT
|
|
421
|
+
// it must come from THIS field's OWN label — not a sibling's. The Plaid
|
|
422
|
+
// false-positive ("If yes, please provide further explanation below")
|
|
423
|
+
// was a conditional field flagged because a climb-5-ancestors walk
|
|
424
|
+
// scooped up a neighbouring required field's asterisk. Only trust the
|
|
425
|
+
// directly-associated label:
|
|
426
|
+
// 1. <label for=id>
|
|
427
|
+
// 2. el.labels[0]
|
|
428
|
+
// 3. the SINGLE closest wrapping label/legend that contains ONLY this
|
|
429
|
+
// control (not a shared group container with multiple inputs).
|
|
430
|
+
const labelTextOf = (lblEl) => (lblEl && (lblEl.innerText || lblEl.textContent || '')).trim();
|
|
431
|
+
const marks = (txt) => /\*\s*$/.test(txt) || /\(\s*required\s*\)/i.test(txt) || /\brequired\b\s*\*?$/i.test(txt);
|
|
432
|
+
|
|
433
|
+
if (el.id) {
|
|
434
|
+
const lbl = document.querySelector(`label[for="${el.id}"]`);
|
|
435
|
+
if (lbl) return marks(labelTextOf(lbl));
|
|
436
|
+
}
|
|
437
|
+
if (el.labels && el.labels[0]) return marks(labelTextOf(el.labels[0]));
|
|
438
|
+
|
|
439
|
+
// Closest wrapping label/legend, but ONLY if that container holds a
|
|
440
|
+
// single control (so we don't inherit a group asterisk meant for a
|
|
441
|
+
// different required field in the same fieldset).
|
|
442
|
+
let p = el.parentElement; let hops = 0;
|
|
443
|
+
while (p && hops < 4) {
|
|
444
|
+
const labelEl = p.querySelector(':scope > label, :scope > legend, :scope > [class*="label"]');
|
|
445
|
+
if (labelEl) {
|
|
446
|
+
const controlsInScope = p.querySelectorAll('input:not([type=hidden]), textarea, select, [role="combobox"], [contenteditable="true"]').length;
|
|
447
|
+
if (controlsInScope <= 1) return marks(labelTextOf(labelEl));
|
|
448
|
+
// Shared container with multiple controls — the asterisk isn't
|
|
449
|
+
// necessarily ours. Stop; treat as not-required unless a
|
|
450
|
+
// programmatic marker said otherwise (handled above).
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
p = p.parentElement; hops += 1;
|
|
454
|
+
}
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const fields = Array.from(document.querySelectorAll(
|
|
459
|
+
'input:not([type=hidden]):not([type=submit]):not([type=button]), textarea, select, [role="combobox"], [contenteditable="true"]'
|
|
460
|
+
));
|
|
461
|
+
const emptyFields = [];
|
|
462
|
+
const seen = new Set();
|
|
463
|
+
const radioGroupsChecked = new Map(); // name -> bool any-checked
|
|
464
|
+
const radioGroupRequired = new Map(); // name -> required
|
|
465
|
+
|
|
466
|
+
// First pass: tally radio/checkbox groups by name.
|
|
467
|
+
for (const el of fields) {
|
|
468
|
+
const type = (el.type || '').toLowerCase();
|
|
469
|
+
if ((type === 'radio' || type === 'checkbox') && el.name) {
|
|
470
|
+
if (el.checked) radioGroupsChecked.set(el.name, true);
|
|
471
|
+
else if (!radioGroupsChecked.has(el.name)) radioGroupsChecked.set(el.name, false);
|
|
472
|
+
if (isRequired(el)) radioGroupRequired.set(el.name, true);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
for (const el of fields) {
|
|
477
|
+
const type = (el.type || '').toLowerCase();
|
|
478
|
+
// Skip non-rendered fields entirely (display:none templates etc).
|
|
479
|
+
const rect = el.getBoundingClientRect();
|
|
480
|
+
const inDom = rect.width > 0 || rect.height > 0 || el.offsetParent !== null;
|
|
481
|
+
if (!inDom && type !== 'file') continue; // file inputs are often 0x0 by design
|
|
482
|
+
|
|
483
|
+
// Radio/checkbox handled at group level.
|
|
484
|
+
if ((type === 'radio' || type === 'checkbox') && el.name) {
|
|
485
|
+
if (!radioGroupRequired.get(el.name)) continue;
|
|
486
|
+
if (radioGroupsChecked.get(el.name)) continue; // satisfied
|
|
487
|
+
const key = 'group:' + el.name;
|
|
488
|
+
if (seen.has(key)) continue;
|
|
489
|
+
seen.add(key);
|
|
490
|
+
if (!radioGroupsChecked.get(el.name)) {
|
|
491
|
+
emptyFields.push({ label: nearestLabel(el).slice(0, 200), selector: `[name="${el.name}"]` });
|
|
492
|
+
}
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (!isRequired(el)) continue;
|
|
497
|
+
if (!isEmptyField(el)) continue;
|
|
498
|
+
const label = nearestLabel(el).slice(0, 200);
|
|
499
|
+
const key = label.toLowerCase();
|
|
500
|
+
if (seen.has(key)) continue;
|
|
501
|
+
seen.add(key);
|
|
502
|
+
const isFile = type === 'file'
|
|
503
|
+
|| /resume|cv\b|cover.?letter|attach|upload/i.test(label);
|
|
504
|
+
emptyFields.push({ label, isFile, selector: el.id ? `#${el.id}` : (el.name ? `[name="${el.name}"]` : null) });
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return { hasEmptyRequired: emptyFields.length > 0, emptyFields };
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
module.exports = { detectFormErrors, detectRequiredEmpties };
|
package/orchestrator.js
CHANGED
|
@@ -13,7 +13,7 @@ const path = require('path');
|
|
|
13
13
|
const fs = require('fs');
|
|
14
14
|
const { fillFields: legacyFillFields, uploadFile, findNextButton, findSubmitButton, waitForStableDOM, snapshotFieldLabels } = require('./filler');
|
|
15
15
|
const { smartFillPage } = require('./smartFill');
|
|
16
|
-
const { detectFormErrors } = require('./detectFormErrors');
|
|
16
|
+
const { detectFormErrors, detectRequiredEmpties } = require('./detectFormErrors');
|
|
17
17
|
|
|
18
18
|
// Switchable filler — smart by default, can be killed via config.useSmartFill=false.
|
|
19
19
|
// smartFill.js internally falls back to legacyFillFields if /smartfill/plan-fill
|
|
@@ -47,6 +47,7 @@ async function fillFields(page, aep, opts) {
|
|
|
47
47
|
askUserReasons: result.askUserReasons || [],
|
|
48
48
|
plannedActions: result.planned || 0,
|
|
49
49
|
fellBackToLegacy: !!result.fallback,
|
|
50
|
+
resumeUploaded: !!result.resumeUploaded,
|
|
50
51
|
};
|
|
51
52
|
}
|
|
52
53
|
const { detectCaptcha, solveCaptcha, injectCaptchaToken } = require('./captcha');
|
|
@@ -217,8 +218,14 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
|
|
|
217
218
|
}
|
|
218
219
|
}
|
|
219
220
|
|
|
220
|
-
// Upload resume if
|
|
221
|
-
|
|
221
|
+
// Upload resume ONLY if smartFill didn't already place it. This blanket
|
|
222
|
+
// fallback predates smartFill's per-field upload_file action; running it
|
|
223
|
+
// unconditionally dumped the resume into the FIRST file input it found —
|
|
224
|
+
// which on Greenhouse (resume + cover-letter inputs) put the resume into
|
|
225
|
+
// the cover-letter slot too. smartFill now reports resumeUploaded, so we
|
|
226
|
+
// only fall back when the planner genuinely missed the resume field.
|
|
227
|
+
if (tempResumeFile && !fillResult.resumeUploaded) {
|
|
228
|
+
console.log('[orchestrator] smartFill did not upload a resume — running fallback uploader.');
|
|
222
229
|
const uploaded = await uploadFile(page, 'input[type="file"], [data-testid*="resume"], button:has-text("Upload")', tempResumeFile);
|
|
223
230
|
if (uploaded) fillResult.filled = (fillResult.filled || 0) + 1;
|
|
224
231
|
}
|
|
@@ -432,23 +439,65 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
|
|
|
432
439
|
|
|
433
440
|
console.log(`[orchestrator] Reached review/submit page. Waiting for confirmation...`);
|
|
434
441
|
|
|
442
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
443
|
+
// HONESTY GUARD (the Plaid/Ashby fix). Before we even consider
|
|
444
|
+
// auto-submitting, check — proactively, not waiting for the ATS to
|
|
445
|
+
// render an error — whether any REQUIRED field is still empty. The
|
|
446
|
+
// Plaid run auto-submitted with Location + dates blank because:
|
|
447
|
+
// (a) the comboboxes never filled (element-not-visible, now fixed), and
|
|
448
|
+
// (b) detectFormErrors is reactive — a clean-looking DOM let
|
|
449
|
+
// auto-submit "trust the click."
|
|
450
|
+
// If required fields are empty, auto-submit is OFF for this job no matter
|
|
451
|
+
// what the config/agent_config says: we route to REVIEWING so the human
|
|
452
|
+
// sees exactly which fields are blank. Never silently submit incomplete.
|
|
453
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
454
|
+
const skippedLabels = (ctx.skippedFields || []).map((s) => s.label);
|
|
455
|
+
const requiredCheck = await detectRequiredEmpties(page, { excludeLabels: skippedLabels, resumeUploaded: !!fillResult.resumeUploaded }).catch(() => ({ hasEmptyRequired: false, emptyFields: [] }));
|
|
456
|
+
let forceReview = false;
|
|
457
|
+
if (requiredCheck.hasEmptyRequired) {
|
|
458
|
+
const list = requiredCheck.emptyFields.map((f) => f.label).slice(0, 8).join(', ');
|
|
459
|
+
console.warn(`[orchestrator] ${requiredCheck.emptyFields.length} required field(s) still EMPTY: ${list}`);
|
|
460
|
+
console.warn(`[orchestrator] Overriding auto-submit → REVIEWING. Will not submit an incomplete form.`);
|
|
461
|
+
forceReview = true;
|
|
462
|
+
}
|
|
463
|
+
|
|
435
464
|
// Take a screenshot of the review page
|
|
436
465
|
const reviewScreenshot = await page.screenshot({ type: 'jpeg', quality: 70 });
|
|
437
466
|
const reviewKey = await uploadScreenshot(config, reviewScreenshot, `review_${queueId}.jpg`);
|
|
438
467
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
468
|
+
if (forceReview) {
|
|
469
|
+
const list = requiredCheck.emptyFields.map((f) => f.label).slice(0, 8).join(', ');
|
|
470
|
+
// Report REVIEWING (not NEEDS_ATTENTION): the agent is going to WAIT on
|
|
471
|
+
// the submit-confirmed flag, and POST /apply-queue/submit/:id only
|
|
472
|
+
// accepts rows in REVIEWING state. Using NEEDS_ATTENTION here meant the
|
|
473
|
+
// dashboard's "Resolved" click 400'd and the agent waited forever. The
|
|
474
|
+
// reason text still tells the user exactly which fields to fill; the
|
|
475
|
+
// "Submit" button then wakes the agent once they've filled them in Chrome.
|
|
476
|
+
await reportStatus('REVIEWING', {
|
|
477
|
+
review_screenshot_r2_key: reviewKey || null,
|
|
478
|
+
step: 'REVIEWING',
|
|
479
|
+
step_detail: `${requiredCheck.emptyFields.length} required field(s) need you: ${list}. Fill them in Chrome, then click Submit.`.slice(0, 200),
|
|
480
|
+
needs_attention_reason: `${requiredCheck.emptyFields.length} required field(s) could not be auto-filled: ${list}. Fill them in your Chrome window, then click Submit.`,
|
|
481
|
+
fields_filled: cumulativeFilled,
|
|
482
|
+
});
|
|
483
|
+
// Wait for the human to fill + confirm. They resolve the blanks in
|
|
484
|
+
// Chrome, then click Submit on the dashboard.
|
|
451
485
|
await waitForSubmitConfirmation(config, queueId);
|
|
486
|
+
} else {
|
|
487
|
+
await reportStatus('REVIEWING', {
|
|
488
|
+
review_screenshot_r2_key: reviewKey || null,
|
|
489
|
+
step: 'REVIEWING',
|
|
490
|
+
step_detail: `${cumulativeFilled} fields filled · awaiting your confirm`,
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
// Wait for user to confirm submission from dashboard
|
|
494
|
+
// OR auto-submit if config.autoSubmit is true AND the form is complete.
|
|
495
|
+
if (config.autoSubmit || aep.agent_config?.auto_submit) {
|
|
496
|
+
const timeout = (aep.agent_config?.review_timeout_seconds || 30) * 1000;
|
|
497
|
+
await page.waitForTimeout(timeout);
|
|
498
|
+
} else {
|
|
499
|
+
await waitForSubmitConfirmation(config, queueId);
|
|
500
|
+
}
|
|
452
501
|
}
|
|
453
502
|
|
|
454
503
|
// STEP 6: SUBMITTING
|
|
@@ -601,10 +650,32 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
|
|
|
601
650
|
let finalState = 'DONE';
|
|
602
651
|
if (verdict.submitted === null) {
|
|
603
652
|
const autoSubmit = config.autoSubmit || aep.agent_config?.auto_submit;
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
653
|
+
|
|
654
|
+
// Even with auto-submit ON, don't "trust the click" blindly. Two cheap
|
|
655
|
+
// local signals tell us the submit probably did NOT go through:
|
|
656
|
+
// 1. The URL never changed to a confirmation pattern (still on the
|
|
657
|
+
// form). A successful ATS submit almost always redirects.
|
|
658
|
+
// 2. Required fields are STILL empty after the click — proof the form
|
|
659
|
+
// bounced us (this is the Plaid case: blank Location/dates, no
|
|
660
|
+
// redirect, verifier down → it had said "trusting click").
|
|
661
|
+
// If either holds, we refuse the silent DONE and route to REVIEWING.
|
|
662
|
+
const stillOnForm = !/thank|confirm|success|applied|submitted/i.test(verdictUrl);
|
|
663
|
+
const postSkipped = (ctx.skippedFields || []).map((s) => s.label);
|
|
664
|
+
const postCheck = await detectRequiredEmpties(page, { excludeLabels: postSkipped, resumeUploaded: !!fillResult.resumeUploaded }).catch(() => ({ hasEmptyRequired: false, emptyFields: [] }));
|
|
665
|
+
|
|
666
|
+
if (postCheck.hasEmptyRequired) {
|
|
667
|
+
const list = postCheck.emptyFields.map((f) => f.label).slice(0, 8).join(', ');
|
|
668
|
+
console.warn(`[orchestrator] After submit, required field(s) STILL empty: ${list}. Not a real submit — REVIEWING.`);
|
|
669
|
+
finalState = 'REVIEWING';
|
|
670
|
+
} else if (autoSubmit && !stillOnForm) {
|
|
671
|
+
// Form complete + URL redirected away + verifier just unavailable:
|
|
672
|
+
// this is the defensible "trust the click" case. Screenshot is the receipt.
|
|
673
|
+
console.log(`[orchestrator] Verifier unavailable (source: ${verdict.source}); form complete + redirected — trusting click, screenshot is the receipt.`);
|
|
674
|
+
} else if (autoSubmit && stillOnForm) {
|
|
675
|
+
// Auto-submit ON but no redirect and no obvious empties: ambiguous.
|
|
676
|
+
// Don't claim DONE on a page that still looks like the form.
|
|
677
|
+
console.warn(`[orchestrator] Auto-submit ON but still on form URL and verifier unavailable — REVIEWING rather than a blind DONE.`);
|
|
678
|
+
finalState = 'REVIEWING';
|
|
608
679
|
} else {
|
|
609
680
|
// No auto-submit → REVIEWING so the user eyeballs first.
|
|
610
681
|
console.warn(`[orchestrator] Could not verify submission (source: ${verdict.source}). REVIEWING — please eyeball the screenshot + click Submit.`);
|
|
@@ -1267,7 +1338,7 @@ async function runExtensionFill({
|
|
|
1267
1338
|
fillResult.filled += precision.filled || 0;
|
|
1268
1339
|
}
|
|
1269
1340
|
|
|
1270
|
-
if (tempResumeFile) {
|
|
1341
|
+
if (tempResumeFile && !fillResult.resumeUploaded) {
|
|
1271
1342
|
const uploaded = await uploadFile(page, 'input[type="file"], [data-testid*="resume"], button:has-text("Upload")', tempResumeFile);
|
|
1272
1343
|
if (uploaded) fillResult.filled = (fillResult.filled || 0) + 1;
|
|
1273
1344
|
}
|
package/package.json
CHANGED
package/smartFill.js
CHANGED
|
@@ -58,7 +58,50 @@ async function executePlanItem(page, item, fieldByMmid, ctx) {
|
|
|
58
58
|
visible = await locator.isVisible({ timeout: 800 }).catch(() => false);
|
|
59
59
|
}
|
|
60
60
|
if (!visible) {
|
|
61
|
-
|
|
61
|
+
// The scanned element being invisible doesn't always mean "give up." Many
|
|
62
|
+
// ATSes (Ashby, react-select) render the REAL combobox <input> at 0x0 and
|
|
63
|
+
// expose only a clickable WRAPPER (".select__control", a styled div, the
|
|
64
|
+
// value-display button). scanAccessibility tags the inner input, so the
|
|
65
|
+
// executor would FAIL "element not visible" and the required field stays
|
|
66
|
+
// empty — exactly the Ashby Location/School/date failure. Recover by
|
|
67
|
+
// re-pointing the locator at the nearest visible interactive ancestor and
|
|
68
|
+
// letting the dropdown handlers below open it.
|
|
69
|
+
const looksLikeDropdown = field.role === 'combobox' || field.role === 'listbox'
|
|
70
|
+
|| /select|combobox|dropdown|month|year|listbox/i.test(String(field.selectorHint || ''))
|
|
71
|
+
|| (Array.isArray(field.options) && field.options.length > 0);
|
|
72
|
+
const isOpenable = looksLikeDropdown
|
|
73
|
+
|| item.action === 'click_option' || item.action === 'select_option';
|
|
74
|
+
if (isOpenable) {
|
|
75
|
+
const wrapperSel = await locator.evaluate((el) => {
|
|
76
|
+
// Climb to the closest ancestor that's actually rendered (non-zero box)
|
|
77
|
+
// and looks interactive (select-control class, role, or just a sized
|
|
78
|
+
// container holding this input). Tag it so Playwright can target it.
|
|
79
|
+
const sized = (n) => { const r = n.getBoundingClientRect?.(); return r && (r.width > 0 || r.height > 0); };
|
|
80
|
+
let p = el;
|
|
81
|
+
for (let i = 0; i < 8 && p; i++, p = p.parentElement) {
|
|
82
|
+
const cls = String(p.className || '');
|
|
83
|
+
const looksControl = /select__control|select-control|control|combobox|dropdown|trigger|css-/i.test(cls)
|
|
84
|
+
|| p.getAttribute?.('role') === 'combobox'
|
|
85
|
+
|| p.getAttribute?.('aria-haspopup');
|
|
86
|
+
if (sized(p) && (looksControl || p !== el)) {
|
|
87
|
+
const cid = 'halo-wrap-' + Math.random().toString(36).slice(2, 8);
|
|
88
|
+
p.setAttribute('data-halo-wrap', cid);
|
|
89
|
+
return cid;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}).catch(() => null);
|
|
94
|
+
if (wrapperSel) {
|
|
95
|
+
const wrap = root.locator(`[data-halo-wrap="${wrapperSel}"]`).first();
|
|
96
|
+
if (await wrap.isVisible({ timeout: 600 }).catch(() => false)) {
|
|
97
|
+
locator = wrap;
|
|
98
|
+
visible = true;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (!visible) {
|
|
103
|
+
return { ok: false, reason: `element not visible (mmid=${item.mmid}${field.frameUrl ? ', in-frame' : ''})` };
|
|
104
|
+
}
|
|
62
105
|
}
|
|
63
106
|
|
|
64
107
|
switch (item.action) {
|
|
@@ -738,6 +781,7 @@ async function smartFillPage(page, aep, options) {
|
|
|
738
781
|
const fieldByMmid = new Map(fields.map((f) => [String(f.mmid), f]));
|
|
739
782
|
const planByMmid = new Map(plan.map((p) => [String(p.mmid), p]));
|
|
740
783
|
let filled = 0, skipped = 0, failed = 0;
|
|
784
|
+
let resumeUploaded = false; // so the orchestrator can skip its blanket fallback
|
|
741
785
|
const askUserReasons = [];
|
|
742
786
|
|
|
743
787
|
for (const f of fields) {
|
|
@@ -764,9 +808,24 @@ async function smartFillPage(page, aep, options) {
|
|
|
764
808
|
if (item.action === 'skip') {
|
|
765
809
|
console.log(`[smartFill] skip "${labelShort}" — ${result.reason}`);
|
|
766
810
|
skipped += 1;
|
|
811
|
+
// Record the planner's deliberate skips so the orchestrator's
|
|
812
|
+
// required-empty guard can EXCLUDE them. The planner has full
|
|
813
|
+
// context (it saw "If yes…" conditional dependencies); the guard is
|
|
814
|
+
// a blind DOM walk. A field the planner intentionally left blank
|
|
815
|
+
// (conditional-not-applicable, honeypot, optional) must not be
|
|
816
|
+
// mistaken for an unfilled required field.
|
|
817
|
+
if (ctx && Array.isArray(ctx.skippedFields) && f.label) {
|
|
818
|
+
ctx.skippedFields.push({ label: f.label, reason: (item.reasoning || result.reason || '').slice(0, 160) });
|
|
819
|
+
}
|
|
767
820
|
} else {
|
|
768
821
|
console.log(`[smartFill] ${item.action} "${labelShort}" — ${result.reason}${item.reasoning ? ` (why: ${item.reasoning.slice(0, 80)})` : ''}`);
|
|
769
822
|
filled += 1;
|
|
823
|
+
// Note a successful resume upload so the orchestrator doesn't run its
|
|
824
|
+
// legacy blanket "upload resume to the first file input" fallback,
|
|
825
|
+
// which was dumping the resume into the COVER-LETTER slot too.
|
|
826
|
+
if (item.action === 'upload_file' && String(item.value).toLowerCase().trim() !== 'cover_letter') {
|
|
827
|
+
resumeUploaded = true;
|
|
828
|
+
}
|
|
770
829
|
// Track in ctx for cross-page dedup
|
|
771
830
|
if (ctx && ctx.answeredFields) {
|
|
772
831
|
ctx.answeredFields.set(f.label || f.selectorHint, {
|
|
@@ -784,7 +843,7 @@ async function smartFillPage(page, aep, options) {
|
|
|
784
843
|
}
|
|
785
844
|
}
|
|
786
845
|
|
|
787
|
-
return { filled, skipped, failed, askUserReasons, planned: plan.length, fallback: false };
|
|
846
|
+
return { filled, skipped, failed, askUserReasons, planned: plan.length, fallback: false, resumeUploaded };
|
|
788
847
|
}
|
|
789
848
|
|
|
790
849
|
module.exports = { smartFillPage };
|