halo-agent 2.0.9 → 2.2.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.
- package/detectFormErrors.js +25 -0
- package/filler.js +19 -10
- package/index.js +73 -47
- package/package.json +2 -1
- package/poller.js +11 -7
- package/scanAccessibility.js +178 -36
- package/smartFill.js +56 -30
- package/ui.js +85 -0
package/detectFormErrors.js
CHANGED
|
@@ -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
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
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/index.js
CHANGED
|
@@ -17,6 +17,7 @@ const { loadConfig, saveConfig } = require('./config');
|
|
|
17
17
|
const { connectToChrome } = require('./browser');
|
|
18
18
|
const { startPolling } = require('./poller');
|
|
19
19
|
const { startLocalServer } = require('./localServer');
|
|
20
|
+
const ui = require('./ui');
|
|
20
21
|
|
|
21
22
|
const [,, command, ...cmdArgs] = process.argv;
|
|
22
23
|
|
|
@@ -34,13 +35,18 @@ async function main() {
|
|
|
34
35
|
} else if (command === 'uninstall-autostart') {
|
|
35
36
|
await runUninstallAutostart();
|
|
36
37
|
} else {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
console.log('
|
|
41
|
-
console.log(
|
|
42
|
-
console.log('
|
|
43
|
-
console.log('
|
|
38
|
+
let v = '';
|
|
39
|
+
try { v = require('./package.json').version; } catch {}
|
|
40
|
+
ui.banner(v);
|
|
41
|
+
console.log('');
|
|
42
|
+
console.log(` ${ui.bold('Usage')}`);
|
|
43
|
+
console.log(` ${ui.cyan('halo-agent pair')} ${ui.gray('<code>')} One-click pair with the dashboard`);
|
|
44
|
+
console.log(` ${ui.cyan('halo-agent start')} Start the agent`);
|
|
45
|
+
console.log(` ${ui.cyan('halo-agent install-autostart')} Auto-start at login`);
|
|
46
|
+
console.log(` ${ui.cyan('halo-agent uninstall-autostart')} Remove login auto-start`);
|
|
47
|
+
console.log(` ${ui.cyan('halo-agent init')} First-time setup (legacy: token paste)`);
|
|
48
|
+
console.log(` ${ui.cyan('halo-agent token')} ${ui.gray('<value>')} Update auth token`);
|
|
49
|
+
console.log('');
|
|
44
50
|
process.exit(1);
|
|
45
51
|
}
|
|
46
52
|
}
|
|
@@ -59,7 +65,7 @@ async function runUpdateToken(newToken) {
|
|
|
59
65
|
const config = loadConfig() || {};
|
|
60
66
|
config.token = resolved.trim();
|
|
61
67
|
saveConfig(config);
|
|
62
|
-
|
|
68
|
+
ui.success('Token updated');
|
|
63
69
|
}
|
|
64
70
|
|
|
65
71
|
// One-click pairing. The user generates a 6-char code in the dashboard
|
|
@@ -68,8 +74,11 @@ async function runUpdateToken(newToken) {
|
|
|
68
74
|
// the user to copy-paste an API token.
|
|
69
75
|
async function runPair(code) {
|
|
70
76
|
if (!code) {
|
|
71
|
-
|
|
72
|
-
console.
|
|
77
|
+
ui.fail('Missing pairing code.');
|
|
78
|
+
console.log('');
|
|
79
|
+
console.log(` ${ui.bold('Usage')} ${ui.cyan('halo-agent pair')} ${ui.gray('<code>')}`);
|
|
80
|
+
console.log(` ${ui.gray('Get a code from the HALO dashboard: Profile')} ${ui.gray(ui.sym.arrow)} ${ui.gray('Connect agent.')}`);
|
|
81
|
+
console.log('');
|
|
73
82
|
process.exit(1);
|
|
74
83
|
}
|
|
75
84
|
const trimmed = String(code).trim().toUpperCase();
|
|
@@ -77,7 +86,8 @@ async function runPair(code) {
|
|
|
77
86
|
// Default API URL — overridable via env for self-hosted Workers.
|
|
78
87
|
const apiUrl = process.env.HALO_API_URL || 'https://halo-apply-os.amoghi2tb.workers.dev';
|
|
79
88
|
|
|
80
|
-
console.log(
|
|
89
|
+
console.log('');
|
|
90
|
+
const spin = ui.spinner(`Pairing with ${ui.gray(apiUrl)}`);
|
|
81
91
|
let res;
|
|
82
92
|
try {
|
|
83
93
|
res = await fetch(`${apiUrl}/agent-pair/claim`, {
|
|
@@ -86,19 +96,19 @@ async function runPair(code) {
|
|
|
86
96
|
body: JSON.stringify({ code: trimmed }),
|
|
87
97
|
});
|
|
88
98
|
} catch (e) {
|
|
89
|
-
|
|
99
|
+
spin.stop(`Network error reaching HALO: ${e.message}`, false);
|
|
90
100
|
process.exit(1);
|
|
91
101
|
}
|
|
92
102
|
|
|
93
103
|
if (!res.ok) {
|
|
94
104
|
let msg = `HTTP ${res.status}`;
|
|
95
105
|
try { const j = await res.json(); msg = j.error || msg; } catch {}
|
|
96
|
-
|
|
97
|
-
|
|
106
|
+
spin.stop(`Pair failed: ${msg}`, false);
|
|
107
|
+
ui.muted('Codes expire 5 minutes after they are generated. Get a fresh one if needed.');
|
|
98
108
|
process.exit(1);
|
|
99
109
|
}
|
|
100
110
|
const data = await res.json();
|
|
101
|
-
if (!data.token) {
|
|
111
|
+
if (!data.token) { spin.stop('Pair response missing token.', false); process.exit(1); }
|
|
102
112
|
|
|
103
113
|
const { loadConfig, saveConfig } = require('./config');
|
|
104
114
|
const existing = loadConfig() || {};
|
|
@@ -110,8 +120,11 @@ async function runPair(code) {
|
|
|
110
120
|
};
|
|
111
121
|
saveConfig(config);
|
|
112
122
|
|
|
113
|
-
|
|
114
|
-
|
|
123
|
+
spin.stop(`Paired with HALO`);
|
|
124
|
+
ui.muted(` Token saved to ~/.halo-agent/config.json`);
|
|
125
|
+
console.log('');
|
|
126
|
+
console.log(` ${ui.gray('Next:')} ${ui.cyan('halo-agent start')}`);
|
|
127
|
+
console.log('');
|
|
115
128
|
}
|
|
116
129
|
|
|
117
130
|
// Auto-start at login. The user picked "always on" — once installed, the
|
|
@@ -166,10 +179,12 @@ async function runInstallAutostart() {
|
|
|
166
179
|
spawnSync('launchctl', ['unload', plistPath], { stdio: 'ignore' });
|
|
167
180
|
spawnSync('launchctl', ['load', plistPath], { stdio: 'ignore' });
|
|
168
181
|
} catch { /* user can `launchctl load` manually */ }
|
|
169
|
-
console.log(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
182
|
+
console.log('');
|
|
183
|
+
ui.success('Login auto-start installed (macOS LaunchAgent)');
|
|
184
|
+
ui.muted(` plist: ${plistPath}`);
|
|
185
|
+
ui.muted(` logs: ${logPath}`);
|
|
186
|
+
ui.muted(' The agent will start automatically at login. It is running now.');
|
|
187
|
+
console.log('');
|
|
173
188
|
return;
|
|
174
189
|
}
|
|
175
190
|
|
|
@@ -183,10 +198,12 @@ async function runInstallAutostart() {
|
|
|
183
198
|
WshShell.Run """${nodePath.replace(/"/g, '""')}"" ""${agentPath.replace(/"/g, '""')}"" start", 0, False
|
|
184
199
|
Set WshShell = Nothing`;
|
|
185
200
|
fs.writeFileSync(vbsPath, vbs);
|
|
186
|
-
console.log(
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
201
|
+
console.log('');
|
|
202
|
+
ui.success('Login auto-start installed (Windows Startup folder)');
|
|
203
|
+
ui.muted(` vbs: ${vbsPath}`);
|
|
204
|
+
ui.muted(` logs: ${logPath} (agent appends its own output)`);
|
|
205
|
+
ui.muted(` Starts at next login. Run ${ui.cyan('halo-agent start')} now to begin this session.`);
|
|
206
|
+
console.log('');
|
|
190
207
|
return;
|
|
191
208
|
}
|
|
192
209
|
|
|
@@ -217,10 +234,12 @@ WantedBy=default.target
|
|
|
217
234
|
// Linger so the unit survives logout — best effort.
|
|
218
235
|
spawnSync('loginctl', ['enable-linger', os.userInfo().username], { stdio: 'ignore' });
|
|
219
236
|
} catch { /* user can enable manually */ }
|
|
220
|
-
console.log(
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
237
|
+
console.log('');
|
|
238
|
+
ui.success('Login auto-start installed (systemd --user)');
|
|
239
|
+
ui.muted(` unit: ${unitPath}`);
|
|
240
|
+
ui.muted(` logs: ${logPath}`);
|
|
241
|
+
ui.muted(` Check status: ${ui.cyan('systemctl --user status halo-agent')}`);
|
|
242
|
+
console.log('');
|
|
224
243
|
}
|
|
225
244
|
|
|
226
245
|
async function runUninstallAutostart() {
|
|
@@ -235,13 +254,13 @@ async function runUninstallAutostart() {
|
|
|
235
254
|
spawnSync('launchctl', ['unload', plistPath], { stdio: 'ignore' });
|
|
236
255
|
} catch {}
|
|
237
256
|
if (fs.existsSync(plistPath)) fs.unlinkSync(plistPath);
|
|
238
|
-
|
|
257
|
+
ui.success('Removed macOS LaunchAgent');
|
|
239
258
|
return;
|
|
240
259
|
}
|
|
241
260
|
if (process.platform === 'win32') {
|
|
242
261
|
const vbsPath = path.join(process.env.APPDATA || os.homedir(), 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup', 'halo-agent.vbs');
|
|
243
262
|
if (fs.existsSync(vbsPath)) fs.unlinkSync(vbsPath);
|
|
244
|
-
|
|
263
|
+
ui.success('Removed Windows Startup entry');
|
|
245
264
|
return;
|
|
246
265
|
}
|
|
247
266
|
try {
|
|
@@ -250,7 +269,7 @@ async function runUninstallAutostart() {
|
|
|
250
269
|
} catch {}
|
|
251
270
|
const unitPath = path.join(os.homedir(), '.config', 'systemd', 'user', 'halo-agent.service');
|
|
252
271
|
if (fs.existsSync(unitPath)) fs.unlinkSync(unitPath);
|
|
253
|
-
|
|
272
|
+
ui.success('Removed systemd --user unit');
|
|
254
273
|
}
|
|
255
274
|
|
|
256
275
|
async function runInit() {
|
|
@@ -287,8 +306,10 @@ async function runInit() {
|
|
|
287
306
|
};
|
|
288
307
|
|
|
289
308
|
saveConfig(config);
|
|
290
|
-
console.log('
|
|
291
|
-
|
|
309
|
+
console.log('');
|
|
310
|
+
ui.success('Config saved to ~/.halo-agent/config.json');
|
|
311
|
+
console.log(` ${ui.gray('Next:')} ${ui.cyan('halo-agent start')}`);
|
|
312
|
+
console.log('');
|
|
292
313
|
}
|
|
293
314
|
|
|
294
315
|
// Is Chrome running without the --remote-debugging-port=9222 flag?
|
|
@@ -325,13 +346,13 @@ async function detectChromeRunningWithoutDebug() {
|
|
|
325
346
|
function offerChromeRestart() {
|
|
326
347
|
return new Promise((resolve) => {
|
|
327
348
|
console.log('');
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
349
|
+
ui.warn('Chrome is running, but without the debug flag the agent needs.');
|
|
350
|
+
ui.muted(' I can restart it for you. Your tabs reopen automatically');
|
|
351
|
+
ui.muted(' (if your "On startup" setting is "Continue where you left off").');
|
|
331
352
|
console.log('');
|
|
332
353
|
const readline = require('readline');
|
|
333
354
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
334
|
-
rl.question(
|
|
355
|
+
rl.question(`${ui.cyan(ui.sym.arrow)} Restart Chrome now? ${ui.gray('[Y/n]')} `, (ans) => {
|
|
335
356
|
rl.close();
|
|
336
357
|
const a = (ans || '').trim().toLowerCase();
|
|
337
358
|
resolve(a === '' || a === 'y' || a === 'yes');
|
|
@@ -446,12 +467,14 @@ async function waitForChromeGone(timeoutMs) {
|
|
|
446
467
|
async function runStart() {
|
|
447
468
|
const config = loadConfig();
|
|
448
469
|
if (!config || !config.token) {
|
|
449
|
-
|
|
470
|
+
ui.fail('No config found.');
|
|
471
|
+
console.log(` ${ui.gray('Run')} ${ui.cyan('halo-agent pair <code>')} ${ui.gray('first. Get a code from the HALO dashboard.')}`);
|
|
450
472
|
process.exit(1);
|
|
451
473
|
}
|
|
452
474
|
|
|
453
|
-
|
|
454
|
-
|
|
475
|
+
let version = '';
|
|
476
|
+
try { version = require('./package.json').version; } catch {}
|
|
477
|
+
ui.banner(version);
|
|
455
478
|
|
|
456
479
|
// The agent runs its own isolated Chrome instance (separate --user-data-dir
|
|
457
480
|
// at ~/.halo-agent/chrome-profile) so it never collides with the user's
|
|
@@ -459,22 +482,25 @@ async function runStart() {
|
|
|
459
482
|
// into Workday / LinkedIn ONCE in the agent's Chrome and those sessions
|
|
460
483
|
// persist across runs. No need to detect or restart the user's Chrome.
|
|
461
484
|
let chromeConn;
|
|
485
|
+
const spin = ui.spinner('Launching Chrome');
|
|
462
486
|
try {
|
|
463
487
|
chromeConn = await connectToChrome(10);
|
|
464
|
-
|
|
465
|
-
console.log('First time? Log into Workday/LinkedIn in this Chrome window — sessions persist.');
|
|
466
|
-
console.log('Go to your HALO dashboard and click "Auto-Apply" on any job.\n');
|
|
488
|
+
spin.stop('Connected to Chrome');
|
|
467
489
|
} catch (err) {
|
|
468
|
-
|
|
490
|
+
spin.stop(`Could not connect to Chrome: ${err.message}`, false);
|
|
469
491
|
process.exit(1);
|
|
470
492
|
}
|
|
493
|
+
ui.muted(` First time? Sign into Workday / LinkedIn once in this Chrome window — sessions persist.`);
|
|
494
|
+
ui.muted(` Then queue a job from your HALO dashboard.`);
|
|
495
|
+
console.log('');
|
|
471
496
|
|
|
472
497
|
// Start the local HTTP server so the HALO dashboard can trigger Manus automation
|
|
473
498
|
startLocalServer();
|
|
474
499
|
|
|
475
500
|
// Graceful shutdown
|
|
476
501
|
process.on('SIGINT', async () => {
|
|
477
|
-
console.log('
|
|
502
|
+
console.log('');
|
|
503
|
+
ui.muted('Shutting down…');
|
|
478
504
|
try { await chromeConn.browser.close(); } catch {}
|
|
479
505
|
process.exit(0);
|
|
480
506
|
});
|
|
@@ -483,6 +509,6 @@ async function runStart() {
|
|
|
483
509
|
}
|
|
484
510
|
|
|
485
511
|
main().catch(err => {
|
|
486
|
-
|
|
512
|
+
ui.fail(`Fatal: ${err.message}`);
|
|
487
513
|
process.exit(1);
|
|
488
514
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "halo-agent",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.2.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": {
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"captcha.js",
|
|
29
29
|
"vision.js",
|
|
30
30
|
"manusAutomate.js",
|
|
31
|
+
"ui.js",
|
|
31
32
|
"README.md"
|
|
32
33
|
],
|
|
33
34
|
"keywords": [
|
package/poller.js
CHANGED
|
@@ -8,12 +8,15 @@
|
|
|
8
8
|
|
|
9
9
|
const { runJob } = require('./orchestrator');
|
|
10
10
|
const { cfAccessHeaders } = require('./config');
|
|
11
|
+
const ui = require('./ui');
|
|
11
12
|
|
|
12
13
|
let active = false;
|
|
13
14
|
let sessionId = null;
|
|
14
15
|
|
|
15
16
|
async function startPolling(chromeConn, config) {
|
|
16
|
-
|
|
17
|
+
ui.success('Listening for jobs');
|
|
18
|
+
ui.muted(` Polling every 5s. ${ui.cyan('Ctrl-C')} to stop.`);
|
|
19
|
+
console.log('');
|
|
17
20
|
|
|
18
21
|
// Register agent session with backend
|
|
19
22
|
sessionId = await registerSession(config);
|
|
@@ -28,7 +31,8 @@ async function startPolling(chromeConn, config) {
|
|
|
28
31
|
if (!item) continue;
|
|
29
32
|
|
|
30
33
|
active = true;
|
|
31
|
-
console.log(
|
|
34
|
+
console.log('');
|
|
35
|
+
ui.info(`${ui.bold(item.company)} ${ui.gray(ui.sym.dot)} ${item.title}`);
|
|
32
36
|
|
|
33
37
|
// Update session status
|
|
34
38
|
await heartbeat(config, sessionId, 'filling', item.job_id);
|
|
@@ -37,7 +41,7 @@ async function startPolling(chromeConn, config) {
|
|
|
37
41
|
try {
|
|
38
42
|
await runJob(item, chromeConn, config, reportStatus);
|
|
39
43
|
} catch (err) {
|
|
40
|
-
|
|
44
|
+
ui.fail(`Job error: ${err.message}`);
|
|
41
45
|
// Last-resort safety net — orchestrator usually classifies, but if a
|
|
42
46
|
// throw escaped without classification, fall back to 'generic'.
|
|
43
47
|
await reportStatus('NEEDS_ATTENTION', {
|
|
@@ -80,7 +84,7 @@ function makeReporter(config, queueId, sessionId) {
|
|
|
80
84
|
body: JSON.stringify({ status, ...extra }),
|
|
81
85
|
});
|
|
82
86
|
} catch (e) {
|
|
83
|
-
|
|
87
|
+
ui.warn(`Could not report status: ${e.message}`);
|
|
84
88
|
}
|
|
85
89
|
|
|
86
90
|
// Granular step PATCH — drives the live SSE feed. Fire-and-forget; if the
|
|
@@ -142,14 +146,14 @@ async function registerSession(config) {
|
|
|
142
146
|
});
|
|
143
147
|
if (!res.ok) {
|
|
144
148
|
const text = await res.text().catch(() => res.status);
|
|
145
|
-
|
|
149
|
+
ui.warn(`Session registration failed (${res.status}): ${text}`);
|
|
146
150
|
return null;
|
|
147
151
|
}
|
|
148
152
|
const data = await res.json();
|
|
149
|
-
|
|
153
|
+
ui.muted(` Session ${ui.gray(data.session_id)}`);
|
|
150
154
|
return data.session_id;
|
|
151
155
|
} catch (e) {
|
|
152
|
-
|
|
156
|
+
ui.warn(`Session registration error: ${e.message}`);
|
|
153
157
|
return null;
|
|
154
158
|
}
|
|
155
159
|
}
|
package/scanAccessibility.js
CHANGED
|
@@ -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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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 =
|
|
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 =
|
|
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(
|
|
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(
|
|
90
|
+
return await typeAndPickSuggestion(root, locator, item.value);
|
|
76
91
|
}
|
|
77
|
-
return await reactSafeType(
|
|
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(
|
|
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 =
|
|
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
|
|
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 =
|
|
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(
|
|
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 =
|
|
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(
|
|
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 =
|
|
337
|
-
const input =
|
|
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
|
|
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 =
|
|
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(
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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 =
|
|
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(
|
|
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(
|
|
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
|
|
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 =
|
|
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);
|
package/ui.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tiny zero-dep terminal styling helpers. ANSI escapes only — no chalk/ora,
|
|
5
|
+
* because the agent is CommonJS and those packages are ESM-only in v5/v8.
|
|
6
|
+
*
|
|
7
|
+
* Auto-disables color when stdout isn't a TTY (e.g. piped to a file, or
|
|
8
|
+
* the LaunchAgent/systemd log) or when NO_COLOR is set (https://no-color.org).
|
|
9
|
+
*
|
|
10
|
+
* Used everywhere we want the agent to look like a modern CLI on camera
|
|
11
|
+
* (Vercel, Bun, pnpm) instead of plain console.log output.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
15
|
+
|
|
16
|
+
const wrap = (open, close) => (s) => useColor ? `\x1b[${open}m${s}\x1b[${close}m` : String(s);
|
|
17
|
+
|
|
18
|
+
const bold = wrap(1, 22);
|
|
19
|
+
const dim = wrap(2, 22);
|
|
20
|
+
const red = wrap(31, 39);
|
|
21
|
+
const green = wrap(32, 39);
|
|
22
|
+
const yellow = wrap(33, 39);
|
|
23
|
+
const blue = wrap(34, 39);
|
|
24
|
+
const magenta = wrap(35, 39);
|
|
25
|
+
const cyan = wrap(36, 39);
|
|
26
|
+
const gray = wrap(90, 39);
|
|
27
|
+
|
|
28
|
+
// HALO's brand color is ember; render as bright orange when supported.
|
|
29
|
+
const ember = (s) => useColor ? `\x1b[38;5;208m${s}\x1b[39m` : String(s);
|
|
30
|
+
|
|
31
|
+
// Status glyphs — match what modern CLIs do. ASCII fallbacks for terminals
|
|
32
|
+
// that can't render the unicode (rare in 2026, but cheap insurance).
|
|
33
|
+
const sym = process.platform === 'win32' && !process.env.WT_SESSION
|
|
34
|
+
? { tick: '√', cross: '×', bullet: '*', arrow: '→', dot: '·' }
|
|
35
|
+
: { tick: '✓', cross: '✗', bullet: '•', arrow: '→', dot: '·' };
|
|
36
|
+
|
|
37
|
+
const success = (msg) => console.log(`${green(sym.tick)} ${msg}`);
|
|
38
|
+
const fail = (msg) => console.log(`${red(sym.cross)} ${msg}`);
|
|
39
|
+
const info = (msg) => console.log(`${cyan(sym.arrow)} ${msg}`);
|
|
40
|
+
const warn = (msg) => console.log(`${yellow('!')} ${msg}`);
|
|
41
|
+
const muted = (msg) => console.log(gray(msg));
|
|
42
|
+
const step = (msg) => console.log(`${gray(sym.dot)} ${msg}`);
|
|
43
|
+
|
|
44
|
+
// Banner shown on `start`. Three lines so it has presence on camera without
|
|
45
|
+
// being a giant ASCII-art block that ages badly.
|
|
46
|
+
function banner(version) {
|
|
47
|
+
const v = version ? gray(`v${version}`) : '';
|
|
48
|
+
const line = gray('─'.repeat(40));
|
|
49
|
+
console.log('');
|
|
50
|
+
console.log(` ${ember(bold('HALO'))} ${bold('Agent')} ${v}`);
|
|
51
|
+
console.log(` ${gray('Auto-apply, in your own Chrome.')}`);
|
|
52
|
+
console.log(` ${line}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Lightweight spinner for waiting states ("Connecting to Chrome..."). Frames
|
|
56
|
+
// match the standard braille spinner most users have seen. Returns a stop()
|
|
57
|
+
// fn — call it with a final message + status to render a success/fail line
|
|
58
|
+
// in place of the spinner.
|
|
59
|
+
function spinner(message) {
|
|
60
|
+
if (!useColor) {
|
|
61
|
+
process.stdout.write(`${gray(sym.dot)} ${message}\n`);
|
|
62
|
+
return { stop: (finalMsg, ok = true) => (ok ? success(finalMsg) : fail(finalMsg)) };
|
|
63
|
+
}
|
|
64
|
+
const frames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
|
|
65
|
+
let i = 0;
|
|
66
|
+
const render = () => {
|
|
67
|
+
process.stdout.write(`\r${cyan(frames[i = (i + 1) % frames.length])} ${message} `);
|
|
68
|
+
};
|
|
69
|
+
const handle = setInterval(render, 80);
|
|
70
|
+
render();
|
|
71
|
+
return {
|
|
72
|
+
update: (newMessage) => { message = newMessage; },
|
|
73
|
+
stop: (finalMsg, ok = true) => {
|
|
74
|
+
clearInterval(handle);
|
|
75
|
+
process.stdout.write('\r\x1b[K'); // clear the spinner line
|
|
76
|
+
if (ok) success(finalMsg);
|
|
77
|
+
else fail(finalMsg);
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = {
|
|
83
|
+
bold, dim, red, green, yellow, blue, magenta, cyan, gray, ember,
|
|
84
|
+
sym, success, fail, info, warn, muted, step, banner, spinner,
|
|
85
|
+
};
|