halo-agent 2.0.4 → 2.0.5
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 +43 -0
- package/package.json +1 -1
- package/scanAccessibility.js +14 -0
- package/smartFill.js +78 -26
package/detectFormErrors.js
CHANGED
|
@@ -118,8 +118,51 @@ async function detectFormErrors(page) {
|
|
|
118
118
|
const invalidFields = [];
|
|
119
119
|
const seenMmids = new Set();
|
|
120
120
|
|
|
121
|
+
// CRITICAL: only flag fields that are ACTUALLY empty. Otherwise we
|
|
122
|
+
// get a cascade of false positives — Greenhouse marks every required
|
|
123
|
+
// field's LABEL red after a failed submit (visual highlight), and the
|
|
124
|
+
// text-pattern walk matches "First Name is required" near a perfectly-
|
|
125
|
+
// filled First Name field. That triggered the retry to re-touch fields
|
|
126
|
+
// that didn't need fixing, which is how Gender went from Male → Female
|
|
127
|
+
// and Ethnicity East Asian → South Asian.
|
|
128
|
+
function isEmptyField(el) {
|
|
129
|
+
const tag = el.tagName.toLowerCase();
|
|
130
|
+
const type = (el.type || '').toLowerCase();
|
|
131
|
+
// Checkboxes / radios: empty means !checked.
|
|
132
|
+
if (type === 'checkbox' || type === 'radio') return !el.checked;
|
|
133
|
+
// Native select: empty value or selected option is the placeholder.
|
|
134
|
+
if (tag === 'select') {
|
|
135
|
+
if (!el.value) return true;
|
|
136
|
+
const selOpt = el.options[el.selectedIndex];
|
|
137
|
+
// A select whose first option is "Select..." with empty value AND
|
|
138
|
+
// that option is selected → empty.
|
|
139
|
+
if (selOpt && !selOpt.value) return true;
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
// File inputs: empty means files.length === 0.
|
|
143
|
+
if (type === 'file') return !el.files || el.files.length === 0;
|
|
144
|
+
// contenteditable: empty when innerText is whitespace.
|
|
145
|
+
if (el.isContentEditable) return !(el.innerText || '').trim();
|
|
146
|
+
// Combobox role with no inner input (custom dropdown trigger): look
|
|
147
|
+
// for the displayed value via aria-activedescendant or innerText.
|
|
148
|
+
if (el.getAttribute('role') === 'combobox') {
|
|
149
|
+
const v = (el.value || el.innerText || '').trim();
|
|
150
|
+
// Empty AND no chip/pill rendered as a sibling means truly empty.
|
|
151
|
+
const wrap = el.closest('[class*="select"], [class*="combobox"]');
|
|
152
|
+
const chip = wrap?.querySelector('[class*="chip"], [class*="multi-value"], [class*="tag"]');
|
|
153
|
+
if (chip && (chip.innerText || '').trim()) return false;
|
|
154
|
+
return !v;
|
|
155
|
+
}
|
|
156
|
+
// Plain inputs / textareas
|
|
157
|
+
return !(el.value || '').trim();
|
|
158
|
+
}
|
|
159
|
+
|
|
121
160
|
function addField(el, errorText) {
|
|
122
161
|
if (!el) return;
|
|
162
|
+
// Skip fields that are already filled — they're not the cause of
|
|
163
|
+
// the form rejection even if their label happens to be near
|
|
164
|
+
// required-text styling.
|
|
165
|
+
if (!isEmptyField(el)) return;
|
|
123
166
|
const sel = bestSelector(el);
|
|
124
167
|
const key = sel.mmid || sel.selector || el.outerHTML.slice(0, 80);
|
|
125
168
|
if (seenMmids.has(key)) return;
|
package/package.json
CHANGED
package/scanAccessibility.js
CHANGED
|
@@ -429,6 +429,20 @@ async function scanAccessibility(page) {
|
|
|
429
429
|
const label = pickLabel(f);
|
|
430
430
|
// Filter noise: no label AND no visible role → skip
|
|
431
431
|
if (!label && !['button', 'link'].includes(normalizeRole(f))) continue;
|
|
432
|
+
// Filter out the "Remove X" chip-clear buttons that React-Select
|
|
433
|
+
// renders next to selected items in multi-select dropdowns. The
|
|
434
|
+
// planner was treating these as separate fields and "filling" them
|
|
435
|
+
// (clicking them, which DELETED the correct answer) on the retry
|
|
436
|
+
// pass — that's how Gender flipped Male → Female on retry. Same for
|
|
437
|
+
// the X button that clears a single-select's value.
|
|
438
|
+
if (normalizeRole(f) === 'button' && (
|
|
439
|
+
/^remove\s+\S/i.test(label) ||
|
|
440
|
+
/^clear\s+selection/i.test(label) ||
|
|
441
|
+
/^remove\s+item/i.test(label) ||
|
|
442
|
+
label === '×' || label === '✕' || label === 'x'
|
|
443
|
+
)) {
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
432
446
|
out.push({
|
|
433
447
|
mmid: f.mmid,
|
|
434
448
|
role: normalizeRole(f),
|
package/smartFill.js
CHANGED
|
@@ -208,53 +208,81 @@ async function executePlanItem(page, item, fieldByMmid, ctx) {
|
|
|
208
208
|
*/
|
|
209
209
|
async function tryIntlTelCountry(page, triggerLocator, value) {
|
|
210
210
|
try {
|
|
211
|
-
//
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
211
|
+
// Detect intl-tel-input by looking for ANY characteristic class within
|
|
212
|
+
// a reasonable ancestor radius. Newer versions (v18+) shifted from
|
|
213
|
+
// .iti to .iti--allow-dropdown / .iti-mobile, plus they may wrap
|
|
214
|
+
// in .country-select instead. Scan up to 6 ancestors.
|
|
215
|
+
const probe = await triggerLocator.evaluate((el) => {
|
|
216
|
+
let p = el; let hops = 0;
|
|
217
|
+
while (p && hops < 6) {
|
|
218
|
+
const cls = String(p.className || '');
|
|
219
|
+
if (/\biti\b|\biti--|country-select|country-picker/i.test(cls)) {
|
|
220
|
+
// Found the wrapper. Find the flag/trigger inside.
|
|
221
|
+
const flag = p.querySelector(
|
|
222
|
+
'.iti__selected-flag, .iti__country-container, ' +
|
|
223
|
+
'[class*="selected-flag"], [class*="country-button"], ' +
|
|
224
|
+
'[aria-haspopup="listbox"][class*="iti"], ' +
|
|
225
|
+
'button[class*="country"]'
|
|
226
|
+
);
|
|
227
|
+
return { found: true, wrapperOuterClass: cls.slice(0, 200), hasFlag: !!flag };
|
|
228
|
+
}
|
|
229
|
+
p = p.parentElement; hops += 1;
|
|
230
|
+
}
|
|
231
|
+
return { found: false };
|
|
232
|
+
}).catch(() => ({ found: false }));
|
|
233
|
+
if (!probe.found) return null;
|
|
234
|
+
|
|
235
|
+
// Click the flag/trigger element to open the country list.
|
|
220
236
|
const flag = await triggerLocator.evaluateHandle((el) => {
|
|
221
|
-
|
|
222
|
-
|
|
237
|
+
let p = el; let hops = 0;
|
|
238
|
+
while (p && hops < 6) {
|
|
239
|
+
const cls = String(p.className || '');
|
|
240
|
+
if (/\biti\b|\biti--|country-select|country-picker/i.test(cls)) {
|
|
241
|
+
return p.querySelector(
|
|
242
|
+
'.iti__selected-flag, .iti__country-container, ' +
|
|
243
|
+
'[class*="selected-flag"], [class*="country-button"], ' +
|
|
244
|
+
'[aria-haspopup="listbox"][class*="iti"], ' +
|
|
245
|
+
'button[class*="country"]'
|
|
246
|
+
) || null;
|
|
247
|
+
}
|
|
248
|
+
p = p.parentElement; hops += 1;
|
|
249
|
+
}
|
|
250
|
+
return null;
|
|
223
251
|
});
|
|
224
|
-
if (!flag || !(await flag.evaluate((n) => !!n).catch(() => false)))
|
|
252
|
+
if (!flag || !(await flag.evaluate((n) => !!n).catch(() => false))) {
|
|
253
|
+
return { ok: false, reason: `intl-tel wrapper found (${probe.wrapperOuterClass}) but no flag button inside` };
|
|
254
|
+
}
|
|
225
255
|
await flag.click({ timeout: 1500 }).catch(async () => {
|
|
226
|
-
// Some intl-tel versions need force-click because the flag is
|
|
227
|
-
// positioned absolute under the input
|
|
228
256
|
await flag.click({ force: true, timeout: 1500 }).catch(() => {});
|
|
229
257
|
});
|
|
230
|
-
await page.waitForTimeout(
|
|
258
|
+
await page.waitForTimeout(500);
|
|
231
259
|
|
|
232
|
-
//
|
|
233
|
-
|
|
234
|
-
const
|
|
235
|
-
const items = page.locator('.iti__country-list:visible .iti__country, .iti__dropdown-content .iti__country');
|
|
260
|
+
// Country items — try several known class patterns
|
|
261
|
+
const itemSel = '.iti__country-list:visible .iti__country, .iti__dropdown-content .iti__country, [class*="country-list"] [class*="country-item"], [role="listbox"] [role="option"][class*="iti"]';
|
|
262
|
+
const items = page.locator(itemSel);
|
|
236
263
|
const count = await items.count().catch(() => 0);
|
|
237
264
|
if (count === 0) {
|
|
238
|
-
// Country list never opened
|
|
239
265
|
await page.keyboard.press('Escape').catch(() => {});
|
|
240
|
-
return { ok: false, reason:
|
|
266
|
+
return { ok: false, reason: `intl-tel country list did not open (wrapper=${probe.wrapperOuterClass})` };
|
|
241
267
|
}
|
|
242
|
-
|
|
268
|
+
const v = String(value).toLowerCase().replace(/\s*\+\d+\s*$/, '').trim();
|
|
243
269
|
const names = await items.evaluateAll((nodes) => nodes.map((n) => {
|
|
244
|
-
const nm = n.querySelector('.iti__country-name')?.textContent?.trim()
|
|
245
|
-
|
|
270
|
+
const nm = n.querySelector('.iti__country-name, [class*="country-name"]')?.textContent?.trim()
|
|
271
|
+
|| (n.getAttribute('data-country-name') || '').trim()
|
|
272
|
+
|| (n.textContent || '').replace(/\+\d+/, '').trim();
|
|
273
|
+
const dial = n.querySelector('.iti__dial-code, [class*="dial-code"]')?.textContent?.trim()
|
|
274
|
+
|| (n.getAttribute('data-dial-code') ? `+${n.getAttribute('data-dial-code')}` : '');
|
|
246
275
|
return { name: nm, dial };
|
|
247
276
|
})).catch(() => []);
|
|
248
277
|
let idx = names.findIndex((n) => n.name.toLowerCase() === v);
|
|
249
278
|
if (idx === -1) idx = names.findIndex((n) => n.name.toLowerCase().includes(v) || v.includes(n.name.toLowerCase()));
|
|
250
279
|
if (idx === -1) {
|
|
251
|
-
// Try matching by dial code (e.g. "+1")
|
|
252
280
|
const dialMatch = String(value).match(/\+(\d+)/);
|
|
253
281
|
if (dialMatch) idx = names.findIndex((n) => n.dial === `+${dialMatch[1]}`);
|
|
254
282
|
}
|
|
255
283
|
if (idx === -1) {
|
|
256
284
|
await page.keyboard.press('Escape').catch(() => {});
|
|
257
|
-
return { ok: false, reason: `no intl-tel country matched "${value}"` };
|
|
285
|
+
return { ok: false, reason: `no intl-tel country matched "${value}" (${count} items)` };
|
|
258
286
|
}
|
|
259
287
|
await items.nth(idx).click({ timeout: 1500 }).catch(async () => {
|
|
260
288
|
await items.nth(idx).click({ force: true, timeout: 1500 });
|
|
@@ -275,6 +303,30 @@ async function openAndPickOption(page, triggerLocator, value, llmCtx) {
|
|
|
275
303
|
if (itlResult !== null) return itlResult;
|
|
276
304
|
} catch {}
|
|
277
305
|
|
|
306
|
+
// Second: Google Places typeahead. The Greenhouse Location field is
|
|
307
|
+
// marked role=combobox in AX, so the planner sends click_option — but
|
|
308
|
+
// it's really a type-then-pick autocomplete that opens a .pac-container
|
|
309
|
+
// mounted at document.body (NOT inside the field's wrapper). Detect
|
|
310
|
+
// by checking for adjacent "Locate me" UI or for an existing pac-container.
|
|
311
|
+
try {
|
|
312
|
+
const isPac = await triggerLocator.evaluate((el) => {
|
|
313
|
+
// Heuristic 1: a "Locate me" link/button nearby (Greenhouse-specific)
|
|
314
|
+
const ancestor = el.closest('div, section, fieldset') || el.parentElement;
|
|
315
|
+
const hasLocateMe = ancestor && Array.from(ancestor.querySelectorAll('a, button')).some((b) => /locate\s*me/i.test(b.textContent || ''));
|
|
316
|
+
// Heuristic 2: a pac-container is mounted somewhere on the page
|
|
317
|
+
const hasPacContainer = !!document.querySelector('.pac-container, .pac-target-input');
|
|
318
|
+
// Heuristic 3: the input itself is a pac-target-input (Google sets this when initialized)
|
|
319
|
+
const isPacInput = el.classList?.contains('pac-target-input');
|
|
320
|
+
return hasLocateMe || hasPacContainer || isPacInput;
|
|
321
|
+
}).catch(() => false);
|
|
322
|
+
if (isPac) {
|
|
323
|
+
const r = await typeAndPickSuggestion(page, triggerLocator, value);
|
|
324
|
+
// If typeahead failed, fall through to the normal dropdown path
|
|
325
|
+
// (some fields are both, weirdly)
|
|
326
|
+
if (r.ok) return r;
|
|
327
|
+
}
|
|
328
|
+
} catch {}
|
|
329
|
+
|
|
278
330
|
try {
|
|
279
331
|
// Snapshot option-list state BEFORE opening so we can identify
|
|
280
332
|
// the new options. Includes:
|