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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "halo-agent",
3
- "version": "2.6.1",
3
+ "version": "2.6.2",
4
4
  "description": "HALO local apply agent — auto-fills job applications using your real Chrome session",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -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(