halo-agent 2.6.1 → 2.6.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/package.json +1 -1
- package/scanAccessibility.js +134 -0
- package/smartFill.js +30 -0
package/package.json
CHANGED
package/scanAccessibility.js
CHANGED
|
@@ -643,6 +643,107 @@ async function scanAccessibility(page) {
|
|
|
643
643
|
}
|
|
644
644
|
}
|
|
645
645
|
|
|
646
|
+
// ── Toggle-group coalescing (Ashby, similar custom designs) ─────────────
|
|
647
|
+
// Some ATSes render Yes/No (and similar pick-one) questions as a pair of
|
|
648
|
+
// <button> elements above a hidden <input type="checkbox">. The buttons
|
|
649
|
+
// have no role attribute, no name connecting them, and they're scanned
|
|
650
|
+
// as independent fields with label "Yes" or "No" — the planner can't tell
|
|
651
|
+
// them apart from a true Yes/No without knowing the QUESTION.
|
|
652
|
+
//
|
|
653
|
+
// We detect these groups ONLY via two well-defined hooks:
|
|
654
|
+
// 1. Ashby's `_yesno_` container class (the explicit yes/no widget)
|
|
655
|
+
// 2. The generic shape: a `_fieldEntry_` wrapper containing a question
|
|
656
|
+
// label + a sibling div whose ONLY children are 2-3 <button> elements
|
|
657
|
+
// with short text labels (Yes/No or short option text).
|
|
658
|
+
//
|
|
659
|
+
// For each detected group we:
|
|
660
|
+
// - Pick a "primary" mmid (the first button) and rewrite its label to
|
|
661
|
+
// the QUESTION text, role to 'radio', options to ['Yes','No'], and
|
|
662
|
+
// groupLabel to the question. The planner now sees this as a single
|
|
663
|
+
// radio question with two options.
|
|
664
|
+
// - Mark the OTHER button(s) in the group as droppable so the dedupe
|
|
665
|
+
// loop below skips them — the planner only sees one synthetic radio
|
|
666
|
+
// per group.
|
|
667
|
+
//
|
|
668
|
+
// Scoped tightly to avoid touching Greenhouse / Lever / Workday, all of
|
|
669
|
+
// which use native <input type="radio"> with `name` attributes for their
|
|
670
|
+
// pick-one questions.
|
|
671
|
+
let toggleGroupInfo = { primaries: {}, drop: new Set() };
|
|
672
|
+
try {
|
|
673
|
+
toggleGroupInfo = await page.evaluate(({ mmidAttr }) => {
|
|
674
|
+
function safeText(t) { return (t || '').replace(/\s+/g, ' ').trim(); }
|
|
675
|
+
const out = { primaries: {}, drop: [] };
|
|
676
|
+
|
|
677
|
+
// Pass 1: Ashby's explicit _yesno_ widget. CSS module hashing means
|
|
678
|
+
// the class name is like "_yesno_1e3gg_148" — match by substring.
|
|
679
|
+
const ashbyYesno = document.querySelectorAll('[class*="_yesno_"]');
|
|
680
|
+
ashbyYesno.forEach((container) => {
|
|
681
|
+
const buttons = Array.from(container.querySelectorAll('button'));
|
|
682
|
+
if (buttons.length < 2 || buttons.length > 4) return;
|
|
683
|
+
// The question label lives on a sibling <label> inside the same
|
|
684
|
+
// _fieldEntry_ wrapper.
|
|
685
|
+
const wrapper = container.closest('[class*="_fieldEntry_"], [data-field-path], .ashby-application-form-field-entry');
|
|
686
|
+
let question = '';
|
|
687
|
+
if (wrapper) {
|
|
688
|
+
const lbl = wrapper.querySelector('label');
|
|
689
|
+
if (lbl) question = safeText(lbl.textContent || '').replace(/\s*\*\s*$/, '').trim();
|
|
690
|
+
}
|
|
691
|
+
if (!question) return;
|
|
692
|
+
const mmids = buttons.map((b) => b.getAttribute(mmidAttr)).filter(Boolean);
|
|
693
|
+
if (mmids.length < 2) return;
|
|
694
|
+
const options = buttons.map((b) => safeText(b.textContent || '').trim()).filter(Boolean);
|
|
695
|
+
// First button is the primary — it'll be rewritten into the radio
|
|
696
|
+
// question. The rest get dropped from the field list.
|
|
697
|
+
out.primaries[mmids[0]] = { question, options };
|
|
698
|
+
for (let i = 1; i < mmids.length; i++) out.drop.push(mmids[i]);
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
// Pass 2: generic shape — _fieldEntry_ wrapper with a label + an
|
|
702
|
+
// option container whose direct children are only buttons (no inputs,
|
|
703
|
+
// no other interactive elements). Catches less-tagged variants of the
|
|
704
|
+
// same pattern. Conservative: only if EVERY direct interactive child
|
|
705
|
+
// is a button with short text.
|
|
706
|
+
const wrappers = document.querySelectorAll('[class*="_fieldEntry_"], [data-field-path], .ashby-application-form-field-entry');
|
|
707
|
+
wrappers.forEach((wrapper) => {
|
|
708
|
+
// Skip wrappers we've already coalesced via Pass 1.
|
|
709
|
+
if (wrapper.querySelector('[class*="_yesno_"]')) return;
|
|
710
|
+
const lbl = wrapper.querySelector('label');
|
|
711
|
+
if (!lbl) return;
|
|
712
|
+
const question = safeText(lbl.textContent || '').replace(/\s*\*\s*$/, '').trim();
|
|
713
|
+
if (!question || question.length > 200) return;
|
|
714
|
+
// Look for a button-only group sibling/descendant.
|
|
715
|
+
const candidates = Array.from(wrapper.querySelectorAll('div'))
|
|
716
|
+
.filter((d) => {
|
|
717
|
+
const kids = Array.from(d.children);
|
|
718
|
+
if (kids.length < 2 || kids.length > 4) return false;
|
|
719
|
+
return kids.every((k) => {
|
|
720
|
+
if (k.tagName !== 'BUTTON') return false;
|
|
721
|
+
const t = safeText(k.textContent || '').trim();
|
|
722
|
+
return t.length > 0 && t.length < 80;
|
|
723
|
+
});
|
|
724
|
+
});
|
|
725
|
+
if (!candidates.length) return;
|
|
726
|
+
const buttons = Array.from(candidates[0].children);
|
|
727
|
+
const mmids = buttons.map((b) => b.getAttribute(mmidAttr)).filter(Boolean);
|
|
728
|
+
if (mmids.length < 2) return;
|
|
729
|
+
// Skip if we already have a primary registered for these (Pass 1 ran).
|
|
730
|
+
if (out.primaries[mmids[0]]) return;
|
|
731
|
+
const options = buttons.map((b) => safeText(b.textContent || '').trim());
|
|
732
|
+
out.primaries[mmids[0]] = { question, options };
|
|
733
|
+
for (let i = 1; i < mmids.length; i++) out.drop.push(mmids[i]);
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
return out;
|
|
737
|
+
}, { mmidAttr: MMID_ATTR });
|
|
738
|
+
} catch (e) {
|
|
739
|
+
console.warn(`[scanAx] toggle-group detection failed: ${e.message}`);
|
|
740
|
+
}
|
|
741
|
+
const dropSet = new Set(toggleGroupInfo.drop || []);
|
|
742
|
+
const primaries = toggleGroupInfo.primaries || {};
|
|
743
|
+
if (Object.keys(primaries).length > 0) {
|
|
744
|
+
console.log(`[scanAx] coalesced ${Object.keys(primaries).length} toggle-button group(s) into radio questions`);
|
|
745
|
+
}
|
|
746
|
+
|
|
646
747
|
// Normalize, dedupe by mmid (frames could collide, though we offset above)
|
|
647
748
|
const seen = new Set();
|
|
648
749
|
const out = [];
|
|
@@ -650,6 +751,10 @@ async function scanAccessibility(page) {
|
|
|
650
751
|
if (seen.has(f.mmid)) continue;
|
|
651
752
|
seen.add(f.mmid);
|
|
652
753
|
|
|
754
|
+
// Drop secondary buttons in coalesced toggle groups — the primary will
|
|
755
|
+
// be emitted as a synthetic radio question covering all options.
|
|
756
|
+
if (dropSet.has(f.mmid)) continue;
|
|
757
|
+
|
|
653
758
|
// Frame-DOM fields are ALREADY normalized (scanFrameDom produced the
|
|
654
759
|
// public shape: role/label/options as strings). Re-running pickLabel /
|
|
655
760
|
// normalizeRole / options.map(o=>o.label) on them would break (those
|
|
@@ -718,6 +823,35 @@ async function scanAccessibility(page) {
|
|
|
718
823
|
// descendant content.
|
|
719
824
|
if (label.length > 80) continue;
|
|
720
825
|
}
|
|
826
|
+
// Coalesce toggle-button group: rewrite this primary mmid into a single
|
|
827
|
+
// synthetic radio question so the planner sees ONE field with the real
|
|
828
|
+
// question as its label and ['Yes','No'] (or similar) as options.
|
|
829
|
+
const primary = primaries[f.mmid];
|
|
830
|
+
if (primary) {
|
|
831
|
+
out.push({
|
|
832
|
+
mmid: f.mmid,
|
|
833
|
+
role: 'radio',
|
|
834
|
+
label: primary.question,
|
|
835
|
+
description: '',
|
|
836
|
+
required: f.required,
|
|
837
|
+
disabled: f.disabled,
|
|
838
|
+
options: primary.options,
|
|
839
|
+
selectorHint: f.selectorHint,
|
|
840
|
+
inputType: 'radio',
|
|
841
|
+
currentValue: '',
|
|
842
|
+
isTypeahead: false,
|
|
843
|
+
groupLabel: primary.question,
|
|
844
|
+
textContent: primary.options.join(' / '),
|
|
845
|
+
filledAlready: false,
|
|
846
|
+
frameUrl: null,
|
|
847
|
+
// Signal to the executor: this is a toggle-button group, so
|
|
848
|
+
// set_radio should locate buttons by their visible text within
|
|
849
|
+
// the same _fieldEntry_ wrapper.
|
|
850
|
+
isToggleButtonGroup: true,
|
|
851
|
+
});
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
854
|
+
|
|
721
855
|
out.push({
|
|
722
856
|
mmid: f.mmid,
|
|
723
857
|
role: normalizeRole(f),
|
package/smartFill.js
CHANGED
|
@@ -224,6 +224,36 @@ async function executePlanItem(page, item, fieldByMmid, ctx) {
|
|
|
224
224
|
}).catch(() => null);
|
|
225
225
|
|
|
226
226
|
const groupRoot = groupId ? root.locator(`[data-halo-rg="${groupId}"]`) : root;
|
|
227
|
+
// If the scanner coalesced this from a toggle-button group (Ashby
|
|
228
|
+
// _yesno_), prefer a direct button-by-text lookup — these are plain
|
|
229
|
+
// <button> elements with no role=radio attribute.
|
|
230
|
+
if (field.isToggleButtonGroup) {
|
|
231
|
+
// Match buttons whose entire trimmed text equals the value (avoid
|
|
232
|
+
// matching descendants by accident).
|
|
233
|
+
const btn = groupRoot.locator('button').filter({ hasText: new RegExp(`^\\s*${v.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\\s*$`, 'i') }).first();
|
|
234
|
+
if (await btn.isVisible({ timeout: 1000 }).catch(() => false)) {
|
|
235
|
+
await btn.click({ timeout: 1500 }).catch(async () => {
|
|
236
|
+
await btn.click({ force: true, timeout: 1500 });
|
|
237
|
+
});
|
|
238
|
+
await page.waitForTimeout(150);
|
|
239
|
+
// Verify via aria-pressed / aria-checked / explicit selected class.
|
|
240
|
+
const stuck = await btn.evaluate((el) => {
|
|
241
|
+
if (el.getAttribute('aria-pressed') === 'true') return true;
|
|
242
|
+
if (el.getAttribute('aria-checked') === 'true') return true;
|
|
243
|
+
const cls = String(el.className || '');
|
|
244
|
+
if (/selected|active|checked/i.test(cls)) return true;
|
|
245
|
+
// Also check the sibling hidden checkbox the widget controls.
|
|
246
|
+
const grp = el.closest('[class*="_yesno_"], [class*="_fieldEntry_"]');
|
|
247
|
+
if (grp) {
|
|
248
|
+
const hidden = grp.querySelector('input[type="checkbox"], input[type="hidden"]');
|
|
249
|
+
if (hidden && hidden.value && String(hidden.value).length > 0) return true;
|
|
250
|
+
}
|
|
251
|
+
return false;
|
|
252
|
+
}).catch(() => true);
|
|
253
|
+
return { ok: true, reason: `toggle button: ${v}${stuck ? '' : ' (unverified)'}` };
|
|
254
|
+
}
|
|
255
|
+
// Fall through to the generic lookup below if direct button lookup missed.
|
|
256
|
+
}
|
|
227
257
|
// Within the group, find the option whose visible label matches value.
|
|
228
258
|
// Try role=radio, label, and a generic clickable row with the text.
|
|
229
259
|
const optInGroup = groupRoot.locator(
|