halo-agent 2.0.9 → 2.1.0

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.
@@ -46,7 +46,32 @@ const BANNER_PATTERNS = [
46
46
  /please\s*review/i,
47
47
  ];
48
48
 
49
+ // Embedded ATS hosts whose forms (and their validation errors) live inside
50
+ // an iframe. Errors must be scanned in that frame, not just the top page.
51
+ const DFE_EMBED_HOSTS = /(greenhouse\.io|lever\.co|ashbyhq\.com|myworkdayjobs\.com|icims\.com|smartrecruiters\.com|jobvite\.com|workable\.com|bamboohr\.com)/i;
52
+
49
53
  async function detectFormErrors(page) {
54
+ // Scan the top frame + any embedded ATS frame, merge results. Embedded
55
+ // forms (Orion → greenhouse.io iframe) render their "X is required"
56
+ // errors inside the frame, so a top-only scan would see a clean page and
57
+ // wrongly conclude the submit succeeded.
58
+ const roots = [page, ...page.frames().filter((f) => {
59
+ const u = f.url();
60
+ return f !== page.mainFrame() && u && DFE_EMBED_HOSTS.test(u) && !/google\.com|gstatic\.com|recaptcha/i.test(u);
61
+ })];
62
+ let merged = { hasErrors: false, errorBanner: null, invalidFields: [] };
63
+ for (const root of roots) {
64
+ const r = await detectFormErrorsInRoot(root).catch(() => null);
65
+ if (r) {
66
+ merged.hasErrors = merged.hasErrors || r.hasErrors;
67
+ merged.errorBanner = merged.errorBanner || r.errorBanner;
68
+ merged.invalidFields.push(...(r.invalidFields || []));
69
+ }
70
+ }
71
+ return merged;
72
+ }
73
+
74
+ async function detectFormErrorsInRoot(page) {
50
75
  return await page.evaluate(({ fieldPatterns, bannerPatterns }) => {
51
76
  const fp = fieldPatterns.map((s) => new RegExp(s.source, s.flags));
52
77
  const bp = bannerPatterns.map((s) => new RegExp(s.source, s.flags));
package/filler.js CHANGED
@@ -1157,16 +1157,25 @@ async function findSubmitButton(page) {
1157
1157
  'button[type="submit"]',
1158
1158
  'form button:last-of-type',
1159
1159
  ];
1160
- for (const sel of selectors) {
1161
- try {
1162
- const el = page.locator(sel).first();
1163
- if (await el.isVisible({ timeout: 500 })) {
1164
- // Skip if it looks like a Next/Continue button
1165
- const text = (await el.textContent().catch(() => '')).toLowerCase().trim();
1166
- if (text.includes('next') || text.includes('continue') || text.includes('save and') || text.includes('save &')) continue;
1167
- return el;
1168
- }
1169
- } catch {}
1160
+ // Search the top frame first, then any embedded ATS frame. Embedded
1161
+ // Greenhouse/Lever forms put the Submit button INSIDE the iframe, so a
1162
+ // top-frame-only search returns null and the orchestrator can't submit.
1163
+ const EMBED_HOSTS = /(greenhouse\.io|lever\.co|ashbyhq\.com|myworkdayjobs\.com|icims\.com|smartrecruiters\.com|jobvite\.com|workable\.com|bamboohr\.com)/i;
1164
+ const roots = [page, ...page.frames().filter((f) => {
1165
+ const u = f.url();
1166
+ return f !== page.mainFrame() && u && EMBED_HOSTS.test(u) && !/google\.com|gstatic\.com|recaptcha/i.test(u);
1167
+ })];
1168
+ for (const root of roots) {
1169
+ for (const sel of selectors) {
1170
+ try {
1171
+ const el = root.locator(sel).first();
1172
+ if (await el.isVisible({ timeout: 500 })) {
1173
+ const text = (await el.textContent().catch(() => '')).toLowerCase().trim();
1174
+ if (text.includes('next') || text.includes('continue') || text.includes('save and') || text.includes('save &')) continue;
1175
+ return el;
1176
+ }
1177
+ } catch {}
1178
+ }
1170
1179
  }
1171
1180
  return null;
1172
1181
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "halo-agent",
3
- "version": "2.0.9",
3
+ "version": "2.1.0",
4
4
  "description": "HALO local apply agent — auto-fills job applications using your real Chrome session",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -357,8 +357,148 @@ function pickDescription(f) {
357
357
  return (f.description || (f.rawName && f.placeholder ? f.placeholder : '')).trim();
358
358
  }
359
359
 
360
+ // ── Frame DOM scanner (for embedded/cross-origin ATS iframes) ─────────────────
361
+
362
+ /**
363
+ * The AX-tree path (Accessibility.getFullAXTree on the main page) does NOT
364
+ * cross into cross-origin iframes. Many companies embed Greenhouse/Lever
365
+ * forms via job-boards.greenhouse.io/embed/job_app on their own careers
366
+ * domain — the fields live entirely inside that cross-origin frame, so the
367
+ * main scan returns 0 real fields (the Orion case: 41 elements, all chrome).
368
+ *
369
+ * This scanner runs a self-contained DOM walk INSIDE a given frame and
370
+ * produces the same field shape the AX path does (mmid, role, label,
371
+ * options, etc). Each field is tagged with frameUrl so the executor can
372
+ * locate it via page.frameLocator(...) instead of page.locator(...).
373
+ *
374
+ * We inject mmid here too (the frame's own elements), so the executor's
375
+ * `[mmid="N"]` selector works within the frame.
376
+ */
377
+ async function scanFrameDom(frame, frameUrl) {
378
+ const raw = await frame.evaluate(({ mmidAttr }) => {
379
+ function safeText(t) { return (t || '').replace(/\s+/g, ' ').trim(); }
380
+ function cssEscape(s) {
381
+ try { return CSS.escape(s); } catch { return String(s).replace(/([!"#$%&'()*+,.\/:;<=>?@\[\\\]^`{|}~])/g, '\\$1'); }
382
+ }
383
+ // Label resolver — same ladder the old scanPage used: label[for], aria,
384
+ // parent heading/legend walk, placeholder/name fallback.
385
+ function getLabel(el) {
386
+ if (el.id) {
387
+ const lbl = document.querySelector(`label[for="${cssEscape(el.id)}"]`);
388
+ if (lbl) return safeText(lbl.textContent).replace(/\*$/, '').trim();
389
+ }
390
+ if (el.labels && el.labels[0]) return safeText(el.labels[0].textContent).replace(/\*$/, '').trim();
391
+ const al = el.getAttribute('aria-label'); if (al) return al.trim();
392
+ const alb = el.getAttribute('aria-labelledby');
393
+ if (alb) {
394
+ const t = alb.split(/\s+/).map((id) => document.getElementById(id)).filter(Boolean).map((e) => safeText(e.textContent)).join(' ').trim();
395
+ if (t) return t;
396
+ }
397
+ let p = el.parentElement, hops = 0;
398
+ while (p && hops < 8) {
399
+ const lbl = p.querySelector('label, legend, h2, h3, h4');
400
+ if (lbl && !lbl.contains(el)) { const t = safeText(lbl.textContent).replace(/\*$/, '').trim(); if (t && t.length < 200) return t; }
401
+ p = p.parentElement; hops++;
402
+ }
403
+ return el.placeholder || el.getAttribute('aria-label') || el.name || el.id || '';
404
+ }
405
+
406
+ const out = [];
407
+ const seen = new Set();
408
+ let n = Math.floor(Math.random() * 800000) + 200000; // high offset, no main-frame collision
409
+
410
+ const sel = 'input,textarea,select,[contenteditable="true"],[role="textbox"],[role="combobox"],[role="listbox"],[role="radio"],[role="checkbox"],[role="switch"],[role="button"],button[type="submit"]';
411
+ document.querySelectorAll(sel).forEach((el) => {
412
+ const tag = el.tagName.toLowerCase();
413
+ const type = (el.type || '').toLowerCase();
414
+ if (['hidden', 'image', 'reset'].includes(type)) return;
415
+ // Visibility check (allow styled-hidden radio/checkbox/file)
416
+ if (!['radio', 'checkbox', 'file'].includes(type)) {
417
+ const r = el.getBoundingClientRect();
418
+ if (r.width === 0 && r.height === 0) return;
419
+ if (el.offsetParent === null && !el.closest('[role="dialog"]')) return;
420
+ }
421
+ n += 1; const id = String(n);
422
+ el.setAttribute(mmidAttr, id);
423
+
424
+ const label = getLabel(el);
425
+ const role = (el.getAttribute('role') || '').toLowerCase();
426
+ const isCE = el.isContentEditable || false;
427
+ // dedup
428
+ const key = el.id || el.name || (label + ':' + type) || el.outerHTML.slice(0, 60);
429
+ if (seen.has(key)) return;
430
+ seen.add(key);
431
+
432
+ let selectorHint = `[${mmidAttr}="${id}"]`;
433
+ if (el.id) selectorHint = `#${cssEscape(el.id)}`;
434
+ else if (el.name) selectorHint = `${tag}[name="${el.name.replace(/"/g, '\\"')}"]`;
435
+
436
+ // value (checkbox/radio use checked)
437
+ let currentValue = '';
438
+ if (type === 'checkbox' || type === 'radio') currentValue = el.checked ? 'checked' : '';
439
+ else currentValue = (el.value || (isCE ? safeText(el.innerText) : '') || '').trim();
440
+
441
+ // native select options
442
+ let options = null;
443
+ if (tag === 'select' && el.options) {
444
+ options = Array.from(el.options).map((o) => safeText(o.text)).filter(Boolean);
445
+ }
446
+
447
+ // typeahead / react-select heuristics
448
+ const isTypeahead = !!(
449
+ el.getAttribute('aria-autocomplete') === 'list' ||
450
+ el.getAttribute('aria-haspopup') === 'listbox' ||
451
+ el.closest('[role="combobox"]') ||
452
+ el.closest('[class*="select__"]') ||
453
+ Array.from((el.closest('div') || el.parentElement || document).querySelectorAll('button,a')).some((b) => /locate\s*me/i.test(b.textContent || ''))
454
+ );
455
+
456
+ out.push({
457
+ mmid: id,
458
+ tag, inputType: type, role,
459
+ label,
460
+ selectorHint,
461
+ currentValue,
462
+ options,
463
+ isTypeahead,
464
+ isContentEditable: isCE,
465
+ textContent: safeText(el.innerText || el.textContent || '').slice(0, 120),
466
+ // react-select inputs report role=combobox; mark them combobox so
467
+ // the executor uses the dropdown path.
468
+ forceRole: el.closest('[class*="select__control"]') ? 'combobox' : null,
469
+ });
470
+ });
471
+ return out;
472
+ }, { mmidAttr: MMID_ATTR }).catch((e) => {
473
+ console.warn(`[scanAx] frame DOM scan failed for ${frameUrl}: ${e.message}`);
474
+ return [];
475
+ });
476
+
477
+ // Normalize to the public field shape, tagging frameUrl for the executor.
478
+ return raw.map((f) => ({
479
+ mmid: f.mmid,
480
+ role: f.forceRole || normalizeRole(f),
481
+ label: f.label || f.textContent || '',
482
+ description: '',
483
+ required: false,
484
+ disabled: false,
485
+ options: f.options || null,
486
+ selectorHint: f.selectorHint,
487
+ inputType: f.inputType,
488
+ currentValue: f.currentValue,
489
+ isTypeahead: f.isTypeahead,
490
+ groupLabel: null,
491
+ textContent: f.textContent,
492
+ filledAlready: !!(f.currentValue && f.currentValue.length > 0),
493
+ frameUrl, // ← executor uses this to scope via frameLocator
494
+ }));
495
+ }
496
+
360
497
  // ── Public entry point ───────────────────────────────────────────────────────
361
498
 
499
+ // Frames whose forms we should DOM-scan independently (cross-origin embeds).
500
+ const EMBED_FRAME_HOSTS = /(greenhouse\.io|lever\.co|ashbyhq\.com|myworkdayjobs\.com|icims\.com|smartrecruiters\.com|jobvite\.com|workable\.com|bamboohr\.com)/i;
501
+
362
502
  /**
363
503
  * Main scanner. Returns a flat list of fields the LLM planner can plan over.
364
504
  * Iterates same-origin frames so iCIMS/Workday iframes don't get missed.
@@ -378,43 +518,30 @@ async function scanAccessibility(page) {
378
518
  // once (on the main page) and reconcile against all frames' DOMs.
379
519
  const isMain = ctx === page || ctx === page.mainFrame?.();
380
520
 
381
- const injected = await (isMain
382
- ? injectMmid(page)
383
- : ctx.evaluate(({ mmidAttr, handleAttr }) => {
384
- const sel = 'input,textarea,select,[contenteditable="true"],[role="textbox"],[role="combobox"],[role="listbox"],[role="radiogroup"],[role="radio"],[role="checkbox"],[role="switch"],[role="button"],[role="option"],button[type="submit"],a[href]';
385
- // Continue numbering from a high offset so frames don't collide
386
- // with main-frame ids. 100000 * frame index is plenty.
387
- let n = Math.floor(Math.random() * 90000) + 100000;
388
- const all = document.querySelectorAll(sel);
389
- all.forEach((el) => {
390
- n += 1; const id = String(n);
391
- el.setAttribute(mmidAttr, id);
392
- const prev = el.getAttribute(handleAttr);
393
- if (prev && !el.hasAttribute('data-orig-aria-keyshortcuts')) {
394
- el.setAttribute('data-orig-aria-keyshortcuts', prev);
395
- }
396
- el.setAttribute(handleAttr, id);
397
- });
398
- return all.length;
399
- }, { mmidAttr: MMID_ATTR, handleAttr: HANDLE_ATTR }));
400
-
401
- if (!injected || injected === 0) continue;
402
-
403
- // Only the main page can drive CDP; for frames, AX is reachable from
404
- // the same root tree fetched on the main page (it includes all frame
405
- // subtrees). Skip the per-frame fetch.
406
- if (!isMain) {
407
- // For frames, just enrich DOM — we'll re-run reconcile after the
408
- // main-page AX fetch. Stash the frame's DOM data only.
409
- // (Simpler: skip frames entirely in v1 — the main-frame AX tree
410
- // doesn't include cross-realm frame nodes anyway.)
411
- continue;
521
+ if (isMain) {
522
+ // Main frame: AX-tree path (best label quality).
523
+ const injected = await injectMmid(page);
524
+ if (!injected || injected === 0) continue;
525
+ const axNodes = await fetchAxTree(page);
526
+ const axFields = reconcile(axNodes);
527
+ const enriched = await enrichFromDom(page, axFields);
528
+ allFields.push(...enriched);
529
+ } else {
530
+ // Non-main frame: only DOM-scan it if it's a known ATS embed frame
531
+ // (greenhouse/lever/ashby/etc). Skipping frames was the v1
532
+ // simplification that broke embedded forms — the Orion case had
533
+ // ALL fields inside the greenhouse.io embed iframe, so the main
534
+ // scan found 0 real fields. Now we scan that frame independently
535
+ // and tag each field with frameUrl so the executor scopes to it.
536
+ const fUrl = ctx.url();
537
+ if (fUrl && EMBED_FRAME_HOSTS.test(fUrl) && !/google\.com|gstatic\.com|recaptcha/i.test(fUrl)) {
538
+ const frameFields = await scanFrameDom(ctx, fUrl);
539
+ if (frameFields.length > 0) {
540
+ console.log(`[scanAx] embedded ATS frame: ${frameFields.length} fields from ${fUrl.slice(0, 60)}`);
541
+ allFields.push(...frameFields);
542
+ }
543
+ }
412
544
  }
413
-
414
- const axNodes = await fetchAxTree(page);
415
- const axFields = reconcile(axNodes);
416
- const enriched = await enrichFromDom(page, axFields);
417
- allFields.push(...enriched);
418
545
  } catch (e) {
419
546
  console.warn(`[scanAx] Frame scan failed: ${e.message}`);
420
547
  }
@@ -426,6 +553,20 @@ async function scanAccessibility(page) {
426
553
  for (const f of allFields) {
427
554
  if (seen.has(f.mmid)) continue;
428
555
  seen.add(f.mmid);
556
+
557
+ // Frame-DOM fields are ALREADY normalized (scanFrameDom produced the
558
+ // public shape: role/label/options as strings). Re-running pickLabel /
559
+ // normalizeRole / options.map(o=>o.label) on them would break (those
560
+ // expect the raw AX-field shape). Pass them through, applying only the
561
+ // Remove-chip filter.
562
+ if (f.frameUrl) {
563
+ const fl = (f.label || '').trim();
564
+ if (f.role === 'button' && (/^remove\s+\S/i.test(fl) || /^clear\s+selection/i.test(fl) || fl === '×' || fl === '✕' || fl === 'x')) continue;
565
+ if (!fl && !['button', 'link'].includes(f.role)) continue;
566
+ out.push(f); // already in public shape, carries frameUrl
567
+ continue;
568
+ }
569
+
429
570
  const label = pickLabel(f);
430
571
  // Filter noise: no label AND no visible role → skip
431
572
  if (!label && !['button', 'link'].includes(normalizeRole(f))) continue;
@@ -460,6 +601,7 @@ async function scanAccessibility(page) {
460
601
  textContent: f.textContent,
461
602
  // Already-filled hint so the planner can skip
462
603
  filledAlready: !!(f.currentValue && f.currentValue.length > 0),
604
+ frameUrl: null, // main-frame field
463
605
  });
464
606
  }
465
607
 
package/smartFill.js CHANGED
@@ -33,17 +33,32 @@ async function executePlanItem(page, item, fieldByMmid, ctx) {
33
33
 
34
34
  const labelShort = (field.label || field.selectorHint || '?').slice(0, 50);
35
35
 
36
+ // Resolve the LOCATOR ROOT. For fields inside an embedded ATS iframe
37
+ // (field.frameUrl set), the element lives in that frame, not the top
38
+ // page — so locating via page.locator() would never find it. Find the
39
+ // matching Playwright Frame and use IT as the root. Frame exposes
40
+ // .locator() exactly like Page, so all downstream helpers work; only
41
+ // page.keyboard stays on the top-level page (keyboard events target the
42
+ // focused element regardless of frame, so that's fine).
43
+ let root = page;
44
+ if (field.frameUrl) {
45
+ const frame = page.frames().find((fr) => fr.url() === field.frameUrl)
46
+ || page.frames().find((fr) => fr.url() && field.frameUrl && fr.url().split('?')[0] === field.frameUrl.split('?')[0]);
47
+ if (frame) root = frame;
48
+ else return { ok: false, reason: `embed frame gone (${(field.frameUrl || '').slice(0, 50)})` };
49
+ }
50
+
36
51
  // Re-locate the element via the mmid attribute (the executor's anchor).
37
52
  // Falls back to selectorHint if mmid was wiped (rare; happens on full
38
53
  // re-renders between scan and execute).
39
- let locator = page.locator(`[mmid="${item.mmid}"]`).first();
54
+ let locator = root.locator(`[mmid="${item.mmid}"]`).first();
40
55
  let visible = await locator.isVisible({ timeout: 800 }).catch(() => false);
41
56
  if (!visible && field.selectorHint) {
42
- locator = page.locator(field.selectorHint).first();
57
+ locator = root.locator(field.selectorHint).first();
43
58
  visible = await locator.isVisible({ timeout: 800 }).catch(() => false);
44
59
  }
45
60
  if (!visible) {
46
- return { ok: false, reason: `element not visible (mmid=${item.mmid})` };
61
+ return { ok: false, reason: `element not visible (mmid=${item.mmid}${field.frameUrl ? ', in-frame' : ''})` };
47
62
  }
48
63
 
49
64
  switch (item.action) {
@@ -63,7 +78,7 @@ async function executePlanItem(page, item, fieldByMmid, ctx) {
63
78
  // hidden until clicked) and fell back to type. Recover by treating
64
79
  // it as click_option.
65
80
  if (field.role === 'combobox' || field.role === 'listbox') {
66
- const result = await openAndPickOption(page, locator, item.value, {
81
+ const result = await openAndPickOption(root, locator, item.value, {
67
82
  config: ctx.config, jobId: ctx.jobId, label: field.label,
68
83
  });
69
84
  if (result.ok) return result;
@@ -72,9 +87,9 @@ async function executePlanItem(page, item, fieldByMmid, ctx) {
72
87
  }
73
88
  // Typeahead path: open suggestion list, pick first match.
74
89
  if (field.isTypeahead) {
75
- return await typeAndPickSuggestion(page, locator, item.value);
90
+ return await typeAndPickSuggestion(root, locator, item.value);
76
91
  }
77
- return await reactSafeType(page, locator, item.value);
92
+ return await reactSafeType(root, locator, item.value);
78
93
  }
79
94
 
80
95
  case 'select_option': {
@@ -91,7 +106,7 @@ async function executePlanItem(page, item, fieldByMmid, ctx) {
91
106
  }
92
107
 
93
108
  case 'click_option': {
94
- return await openAndPickOption(page, locator, item.value, {
109
+ return await openAndPickOption(root, locator, item.value, {
95
110
  config: ctx.config, jobId: ctx.jobId, label: field.label,
96
111
  });
97
112
  }
@@ -114,14 +129,14 @@ async function executePlanItem(page, item, fieldByMmid, ctx) {
114
129
  // Strategy 1: native radio with name= — find by name + label match.
115
130
  const name = field.name;
116
131
  if (name) {
117
- const group = page.locator(`input[type="radio"][name="${name}"]`);
132
+ const group = root.locator(`input[type="radio"][name="${name}"]`);
118
133
  const gc = await group.count();
119
134
  for (let i = 0; i < gc; i++) {
120
135
  const rad = group.nth(i);
121
136
  const id = await rad.getAttribute('id').catch(() => null);
122
137
  let lbl = '';
123
138
  if (id) {
124
- lbl = (await page.locator(`label[for="${id}"]`).first().textContent().catch(() => '') || '').trim();
139
+ lbl = (await root.locator(`label[for="${id}"]`).first().textContent().catch(() => '') || '').trim();
125
140
  }
126
141
  if (!lbl) lbl = (await rad.evaluate(el => el.value || '').catch(() => '') || '');
127
142
  if (lbl.toLowerCase().includes(String(item.value).toLowerCase())) {
@@ -132,7 +147,7 @@ async function executePlanItem(page, item, fieldByMmid, ctx) {
132
147
  }
133
148
  // Strategy 2: just click the labeled option directly (custom radios).
134
149
  const v = String(item.value);
135
- const opt = page.locator(`[role="radio"]:has-text("${v}"), label:has-text("${v}")`).first();
150
+ const opt = root.locator(`[role="radio"]:has-text("${v}"), label:has-text("${v}")`).first();
136
151
  if (await opt.isVisible({ timeout: 800 }).catch(() => false)) {
137
152
  await opt.click({ timeout: 1500 });
138
153
  return { ok: true, reason: `radio (label): ${v}` };
@@ -206,7 +221,8 @@ async function executePlanItem(page, item, fieldByMmid, ctx) {
206
221
  * Returns null if this isn't an intl-tel-input field; otherwise the
207
222
  * fill result.
208
223
  */
209
- async function tryIntlTelCountry(page, triggerLocator, value) {
224
+ async function tryIntlTelCountry(root, triggerLocator, value) {
225
+ const page = (root && typeof root.page === 'function') ? root.page() : root;
210
226
  try {
211
227
  // Detect intl-tel-input by looking for ANY characteristic class within
212
228
  // a reasonable ancestor radius. Newer versions (v18+) shifted from
@@ -259,7 +275,7 @@ async function tryIntlTelCountry(page, triggerLocator, value) {
259
275
 
260
276
  // Country items — try several known class patterns
261
277
  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);
278
+ const items = root.locator(itemSel);
263
279
  const count = await items.count().catch(() => 0);
264
280
  if (count === 0) {
265
281
  await page.keyboard.press('Escape').catch(() => {});
@@ -313,7 +329,10 @@ async function tryIntlTelCountry(page, triggerLocator, value) {
313
329
  *
314
330
  * Returns null if this isn't a react-select field (so other handlers run).
315
331
  */
316
- async function tryReactSelect(page, triggerLocator, value) {
332
+ async function tryReactSelect(root, triggerLocator, value) {
333
+ // root: Page or Frame. control/input/option locators must be scoped to
334
+ // root (in-frame for embedded forms); keyboard/waitForTimeout use Page.
335
+ const page = (root && typeof root.page === 'function') ? root.page() : root;
317
336
  try {
318
337
  // Detect: the trigger or an ancestor carries a .select__ class.
319
338
  const info = await triggerLocator.evaluate((el) => {
@@ -333,8 +352,8 @@ async function tryReactSelect(page, triggerLocator, value) {
333
352
  }).catch(() => null);
334
353
  if (!info) return null;
335
354
 
336
- const control = page.locator(`[data-halo-rs="${info.cid}"]`).first();
337
- const input = page.locator(`[data-halo-rs-input="${info.cid}"]`).first();
355
+ const control = root.locator(`[data-halo-rs="${info.cid}"]`).first();
356
+ const input = root.locator(`[data-halo-rs-input="${info.cid}"]`).first();
338
357
 
339
358
  // 1. Open: click the control. react-select opens the menu on control click.
340
359
  await control.click({ timeout: 2500 }).catch(() => {});
@@ -353,7 +372,7 @@ async function tryReactSelect(page, triggerLocator, value) {
353
372
  // .select__option. Async (Places) options take longer — wait up to 2s.
354
373
  const optSel = '.select__option, [class*="select__option"], [id*="react-select"][id*="option"]';
355
374
  try {
356
- await page.locator(optSel).first().waitFor({ state: 'visible', timeout: 2200 });
375
+ await root.locator(optSel).first().waitFor({ state: 'visible', timeout: 2200 });
357
376
  } catch {
358
377
  // No options appeared. For react-select, pressing Enter on a typed
359
378
  // value sometimes commits a free-text/create option. Try it, then
@@ -370,7 +389,7 @@ async function tryReactSelect(page, triggerLocator, value) {
370
389
  }
371
390
 
372
391
  // 4. Pick best option.
373
- const opts = page.locator(optSel);
392
+ const opts = root.locator(optSel);
374
393
  const texts = await opts.allTextContents().catch(() => []);
375
394
  const tl = typed.toLowerCase();
376
395
  let idx = texts.findIndex((t) => t.toLowerCase().trim() === tl);
@@ -394,12 +413,13 @@ async function tryReactSelect(page, triggerLocator, value) {
394
413
  }
395
414
  }
396
415
 
397
- async function openAndPickOption(page, triggerLocator, value, llmCtx) {
398
- // React-Select FIRST Greenhouse uses it for every dropdown. The
399
- // diagnostic confirmed Country + Location are both .select__ widgets.
400
- // Returns null if not react-select, so other handlers still run.
416
+ async function openAndPickOption(root, triggerLocator, value, llmCtx) {
417
+ // root: Page or Frame. .locator works on both; keyboard/waitForTimeout
418
+ // need the Page. For in-frame fields, option menus render inside the
419
+ // frame so root.locator finds them.
420
+ const page = (root && typeof root.page === 'function') ? root.page() : root;
401
421
  try {
402
- const rs = await tryReactSelect(page, triggerLocator, value);
422
+ const rs = await tryReactSelect(root, triggerLocator, value);
403
423
  if (rs !== null) {
404
424
  if (rs.ok) return rs;
405
425
  // react-select detected but pick failed — log + fall through to the
@@ -410,7 +430,7 @@ async function openAndPickOption(page, triggerLocator, value, llmCtx) {
410
430
 
411
431
  try {
412
432
  // intl-tel-input special case (kept for forms that genuinely use it).
413
- const itlResult = await tryIntlTelCountry(page, triggerLocator, value);
433
+ const itlResult = await tryIntlTelCountry(root, triggerLocator, value);
414
434
  if (itlResult !== null && itlResult.ok) return itlResult;
415
435
  } catch {}
416
436
 
@@ -431,7 +451,7 @@ async function openAndPickOption(page, triggerLocator, value, llmCtx) {
431
451
  return hasLocateMe || hasPacContainer || isPacInput;
432
452
  }).catch(() => false);
433
453
  if (isPac) {
434
- const r = await typeAndPickSuggestion(page, triggerLocator, value);
454
+ const r = await typeAndPickSuggestion(root, triggerLocator, value);
435
455
  // If typeahead failed, fall through to the normal dropdown path
436
456
  // (some fields are both, weirdly)
437
457
  if (r.ok) return r;
@@ -446,7 +466,7 @@ async function openAndPickOption(page, triggerLocator, value, llmCtx) {
446
466
  // .select__option — React-Select
447
467
  // role=option — ARIA-correct dropdowns
448
468
  const optionSel = '[role="option"], [role="menuitem"], .select__option, li[class*="option"], .pac-item, .iti__country';
449
- const beforeCount = await page.locator(optionSel).count().catch(() => 0);
469
+ const beforeCount = await root.locator(optionSel).count().catch(() => 0);
450
470
 
451
471
  await triggerLocator.click({ timeout: 2500 });
452
472
  await page.waitForTimeout(350);
@@ -454,7 +474,7 @@ async function openAndPickOption(page, triggerLocator, value, llmCtx) {
454
474
  // Newly mounted options live at indexes >= beforeCount. If no new
455
475
  // ones appeared, the dropdown may have rendered options earlier
456
476
  // (already-open select). Fall through to scanning all visible.
457
- const allOpts = page.locator(optionSel);
477
+ const allOpts = root.locator(optionSel);
458
478
  const totalCount = await allOpts.count().catch(() => 0);
459
479
  const newCount = totalCount - beforeCount;
460
480
 
@@ -553,7 +573,10 @@ async function openAndPickOption(page, triggerLocator, value, llmCtx) {
553
573
  }
554
574
  }
555
575
 
556
- async function reactSafeType(page, locator, value) {
576
+ async function reactSafeType(root, locator, value) {
577
+ // root may be a Page or a Frame. keyboard/waitForTimeout live on Page;
578
+ // Frame exposes .page() to reach it. locator() works on both.
579
+ const page = (root && typeof root.page === 'function') ? root.page() : root;
557
580
  const v = String(value);
558
581
  try {
559
582
  await locator.fill(v, { timeout: 4000 });
@@ -586,7 +609,10 @@ async function reactSafeType(page, locator, value) {
586
609
  /**
587
610
  * Type-and-pick for typeahead inputs (Greenhouse Location etc).
588
611
  */
589
- async function typeAndPickSuggestion(page, locator, value) {
612
+ async function typeAndPickSuggestion(root, locator, value) {
613
+ // root: Page or Frame. Option lists for an in-frame field render inside
614
+ // that frame, so they must be located via root, not the top page.
615
+ const page = (root && typeof root.page === 'function') ? root.page() : root;
590
616
  try {
591
617
  await locator.click({ timeout: 2000 });
592
618
  await locator.press('Meta+A').catch(() => locator.press('Control+A')).catch(() => {});
@@ -613,13 +639,13 @@ async function typeAndPickSuggestion(page, locator, value) {
613
639
  // to 1.5s additional. waitFor errors if nothing appears — that's the
614
640
  // signal that the field accepted free text instead.
615
641
  try {
616
- await page.locator(optionSel).first().waitFor({ state: 'visible', timeout: 1500 });
642
+ await root.locator(optionSel).first().waitFor({ state: 'visible', timeout: 1500 });
617
643
  } catch {
618
644
  const got = await locator.inputValue({ timeout: 800 }).catch(() => null);
619
645
  if (got && got.trim()) return { ok: true, reason: `typeahead (no suggestion, accepted): "${value.slice(0, 30)}"` };
620
646
  return { ok: false, reason: 'typeahead opened no suggestions' };
621
647
  }
622
- const opts = page.locator(optionSel);
648
+ const opts = root.locator(optionSel);
623
649
  const count = await opts.count().catch(() => 0);
624
650
  if (count === 0) {
625
651
  const got = await locator.inputValue({ timeout: 800 }).catch(() => null);