halo-agent 2.0.4 → 2.0.6
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 +66 -0
- package/package.json +1 -1
- package/scanAccessibility.js +14 -0
- package/smartFill.js +93 -26
package/detectFormErrors.js
CHANGED
|
@@ -118,8 +118,74 @@ 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 / custom-select: React-Select (which Greenhouse uses for
|
|
147
|
+
// ALL its dropdowns) renders the selected value as a
|
|
148
|
+
// .select__single-value or .select__multi-value chip INSIDE the
|
|
149
|
+
// control wrapper, NOT as the input's .value. We must look there or
|
|
150
|
+
// we falsely flag every filled dropdown as empty (the bug that made
|
|
151
|
+
// the retry re-pick Gender + Ethnicity).
|
|
152
|
+
if (el.getAttribute('role') === 'combobox' || tag === 'select') {
|
|
153
|
+
// Walk up to the React-Select control wrapper (a few class variants).
|
|
154
|
+
const wrap = el.closest(
|
|
155
|
+
'[class*="select__control"], [class*="select-control"], ' +
|
|
156
|
+
'[class*="select__value-container"], [class*="select"], ' +
|
|
157
|
+
'[class*="combobox"], [class*="Select"]'
|
|
158
|
+
) || el.parentElement;
|
|
159
|
+
if (wrap) {
|
|
160
|
+
// Any rendered value chip → NOT empty.
|
|
161
|
+
const valueEl = wrap.querySelector(
|
|
162
|
+
'[class*="single-value"], [class*="multi-value"], ' +
|
|
163
|
+
'[class*="multiValue"], [class*="singleValue"], ' +
|
|
164
|
+
'[class*="chip"], [class*="tag"], [class*="__value"]'
|
|
165
|
+
);
|
|
166
|
+
if (valueEl && (valueEl.innerText || valueEl.textContent || '').trim()) return false;
|
|
167
|
+
// Also: the control's own text, minus the placeholder. If the
|
|
168
|
+
// control shows "Male" it's filled; if it shows "Select..." it's empty.
|
|
169
|
+
const wrapText = (wrap.innerText || '').trim();
|
|
170
|
+
const placeholderEl = wrap.querySelector('[class*="placeholder"]');
|
|
171
|
+
const placeholderText = placeholderEl ? (placeholderEl.innerText || '').trim() : '';
|
|
172
|
+
if (wrapText && wrapText !== placeholderText && !/^select\b|^choose\b|^\.\.\./i.test(wrapText)) {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const v = (el.value || el.innerText || '').trim();
|
|
177
|
+
return !v;
|
|
178
|
+
}
|
|
179
|
+
// Plain inputs / textareas
|
|
180
|
+
return !(el.value || '').trim();
|
|
181
|
+
}
|
|
182
|
+
|
|
121
183
|
function addField(el, errorText) {
|
|
122
184
|
if (!el) return;
|
|
185
|
+
// Skip fields that are already filled — they're not the cause of
|
|
186
|
+
// the form rejection even if their label happens to be near
|
|
187
|
+
// required-text styling.
|
|
188
|
+
if (!isEmptyField(el)) return;
|
|
123
189
|
const sel = bestSelector(el);
|
|
124
190
|
const key = sel.mmid || sel.selector || el.outerHTML.slice(0, 80);
|
|
125
191
|
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:
|
|
@@ -310,6 +362,21 @@ async function openAndPickOption(page, triggerLocator, value, llmCtx) {
|
|
|
310
362
|
}
|
|
311
363
|
|
|
312
364
|
if (candidates.length === 0) {
|
|
365
|
+
// DIAGNOSTIC: dump the field's actual DOM so we can see what widget
|
|
366
|
+
// this really is (intl-tel? react-select? custom?). One-line summary
|
|
367
|
+
// of the element + its 2 nearest ancestors' classes + any sibling
|
|
368
|
+
// input/button. This is how we stop guessing selectors.
|
|
369
|
+
const diag = await triggerLocator.evaluate((el) => {
|
|
370
|
+
const summ = (n) => n ? `<${n.tagName.toLowerCase()} class="${(n.className || '').toString().slice(0, 120)}" role="${n.getAttribute?.('role') || ''}" aria-haspopup="${n.getAttribute?.('aria-haspopup') || ''}">` : 'null';
|
|
371
|
+
const p1 = el.parentElement;
|
|
372
|
+
const p2 = p1?.parentElement;
|
|
373
|
+
const siblingInputs = Array.from((p2 || p1 || el).querySelectorAll('input, button, [role="combobox"], [role="button"]'))
|
|
374
|
+
.slice(0, 5)
|
|
375
|
+
.map((s) => `${s.tagName.toLowerCase()}.${(s.className || '').toString().split(' ')[0]}`)
|
|
376
|
+
.join(', ');
|
|
377
|
+
return `self=${summ(el)} | p1=${summ(p1)} | p2=${summ(p2)} | nearby=[${siblingInputs}]`;
|
|
378
|
+
}).catch(() => 'diag failed');
|
|
379
|
+
console.warn(`[smartFill] DROPDOWN-DIAG: ${diag}`);
|
|
313
380
|
await page.keyboard.press('Escape').catch(() => {});
|
|
314
381
|
return { ok: false, reason: 'dropdown opened but no visible options found' };
|
|
315
382
|
}
|