@proagentstore/cli 0.1.17 → 0.1.19

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.
@@ -1,30 +1,58 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { existsSync, mkdirSync } from "node:fs";
2
+ import { existsSync, mkdirSync, rmSync } from "node:fs";
3
3
  import { createRequire } from "node:module";
4
4
  import { basename, dirname, join, resolve } from "node:path";
5
5
  import { pathToFileURL } from "node:url";
6
+ import { captureScreenshotDataUrl, challengeSolved, detectHumanChallenge } from "./challenge.js";
7
+ import { resolveRealChromeProfileDir, seedProfileCopy } from "./browser-profile.js";
8
+ import { performBrowserAction } from "./browser-actions.js";
9
+ import { HumanHandoffError, RunnerInputError } from "./errors.js";
6
10
  import { RunnerStore } from "./store.js";
11
+ /** True for a plain object. */
12
+ function isRecord(value) {
13
+ return typeof value === "object" && value !== null && !Array.isArray(value);
14
+ }
15
+ /** Coerce a value to a trimmed string ("" for non-strings). */
16
+ function stringValue(value) {
17
+ return typeof value === "string" ? value.trim() : "";
18
+ }
19
+ export { HumanHandoffError, RunnerInputError } from "./errors.js";
7
20
  const CAPABILITIES = [
8
21
  "browser.playwright",
9
22
  "browser.screenshot",
10
23
  "downloads",
11
24
  "file.upload",
12
25
  "human.approval",
26
+ "human.takeover",
13
27
  ];
14
- const APPROVAL_REQUIRED_TASKS = new Set(["browser.open", "job.apply_basic", "job.apply_authenticated"]);
28
+ /** How many times a task may attempt an action before handing off to a human. */
29
+ export const MAX_AUTONOMOUS_ATTEMPTS = 3;
30
+ const APPROVAL_REQUIRED_TASKS = new Set(["browser.open"]);
15
31
  const require = createRequire(import.meta.url);
16
- export class RunnerInputError extends Error {
17
- status = 400;
18
- }
19
32
  export class LocalRunner {
20
33
  config;
21
34
  browserContext = null;
22
35
  chromiumInstallChecked = false;
36
+ /**
37
+ * The single page the brain and the human takeover both act on. Tracked so
38
+ * that when an ATS opens a new tab/popup, the active page follows it — the
39
+ * remote brain and the human never diverge onto different pages.
40
+ */
41
+ activePage = null;
42
+ fileAttachArmed = new WeakSet();
43
+ applyResumePath = null;
23
44
  store;
45
+ /** Live human-takeover sessions, keyed by task id (the page is kept alive). */
46
+ takeovers = new Map();
24
47
  constructor(config) {
25
48
  this.config = config;
26
49
  mkdirSync(config.dataDir, { recursive: true });
27
50
  this.store = new RunnerStore(config.dataDir);
51
+ // Tasks paused/running on a previous process are orphaned now — their
52
+ // pages and takeover sessions are gone. Fail them so the board is clean.
53
+ const expired = this.store.expireInFlightTasks();
54
+ if (expired > 0)
55
+ console.log(`[runner] expired ${expired} orphaned in-flight task(s) from a previous session`);
28
56
  }
29
57
  capabilities() {
30
58
  return {
@@ -34,13 +62,31 @@ export class LocalRunner {
34
62
  runtimePlane: "fags",
35
63
  runnerRole: "tool-executor",
36
64
  capabilities: CAPABILITIES,
37
- taskTypes: ["echo", "browser.open", "job.apply_basic", "job.apply_authenticated"],
65
+ taskTypes: ["echo", "browser.open", "job.apply_agent"],
38
66
  approvalRequiredFor: [...APPROVAL_REQUIRED_TASKS],
39
67
  };
40
68
  }
41
69
  createTask(request) {
42
70
  const normalized = normalizeCreateTaskRequest(request);
71
+ validateTaskInput(normalized.type, normalized.input);
43
72
  const now = new Date().toISOString();
73
+ // Agent-driven applications are steered by the remote Workflow brain via the
74
+ // /browser/* endpoints — the runner never auto-executes them. The task exists
75
+ // for the console board, the activity trace, and takeover keying.
76
+ if (normalized.type === "job.apply_agent") {
77
+ const task = {
78
+ id: `task_${crypto.randomUUID()}`,
79
+ type: normalized.type,
80
+ status: "running",
81
+ input: normalized.input,
82
+ requiresApproval: false,
83
+ createdAt: now,
84
+ updatedAt: now,
85
+ };
86
+ this.store.putTask(task);
87
+ this.addTaskEvent(task, "task.created", "Job application started (agent-driven)", { status: "running", url: normalized.input.url });
88
+ return task;
89
+ }
44
90
  const requiresApproval = normalized.requiresApproval || APPROVAL_REQUIRED_TASKS.has(normalized.type);
45
91
  const task = {
46
92
  id: `task_${crypto.randomUUID()}`,
@@ -89,6 +135,8 @@ export class LocalRunner {
89
135
  task.completedAt = task.updatedAt;
90
136
  this.store.putTask(task);
91
137
  this.addTaskEvent(task, "task.cancelled", `Task cancelled: ${task.type}`);
138
+ // Tear down any live takeover session for this task (free the page + CDP).
139
+ void this.endTakeover(id).catch(() => undefined);
92
140
  return task;
93
141
  }
94
142
  async close() {
@@ -113,6 +161,16 @@ export class LocalRunner {
113
161
  this.addTaskEvent(task, "task.completed", `Task completed: ${task.type}`, output);
114
162
  }
115
163
  catch (error) {
164
+ if (error instanceof HumanHandoffError) {
165
+ // Not a failure — the autonomous attempt is paused for a human to
166
+ // take over (solve a challenge, finish a step the model can't).
167
+ task.status = "needs_human";
168
+ task.error = error.message;
169
+ task.updatedAt = new Date().toISOString();
170
+ this.store.putTask(task);
171
+ this.addTaskEvent(task, "job.human_handoff_required", error.message, error.handoff);
172
+ return;
173
+ }
116
174
  task.status = "failed";
117
175
  task.error = error instanceof Error ? error.message : String(error);
118
176
  task.updatedAt = new Date().toISOString();
@@ -129,6 +187,222 @@ export class LocalRunner {
129
187
  data,
130
188
  });
131
189
  }
190
+ /**
191
+ * Before submitting, bail to a human if the page shows an anti-bot challenge.
192
+ * The model can't solve CAPTCHAs, so retrying is pointless — hand off instead.
193
+ */
194
+ async guardHumanChallenge(task, page) {
195
+ const challenge = await detectHumanChallenge(page);
196
+ if (!challenge)
197
+ return;
198
+ this.addTaskEvent(task, "job.human_challenge_detected", `Human challenge detected: ${challenge}`, {
199
+ challengeType: challenge,
200
+ url: page.url(),
201
+ });
202
+ const screenshotBase64 = await captureScreenshotDataUrl(page);
203
+ // Keep this page alive and register a remote-control session so a human
204
+ // can view the screen and solve the challenge from the PAGS console.
205
+ this.takeovers.set(task.id, { page });
206
+ throw new HumanHandoffError(`Human verification required (${challenge}). A person must take over to solve it.`, { reason: "challenge", challengeType: challenge, url: page.url(), attempts: 1, screenshotBase64 });
207
+ }
208
+ /** Active human-takeover task ids (for status/discovery). */
209
+ listTakeovers() {
210
+ return [...this.takeovers.keys()];
211
+ }
212
+ requireTakeover(taskId) {
213
+ const session = this.takeovers.get(taskId);
214
+ if (!session) {
215
+ throw new RunnerInputError("No active human-takeover session for this task");
216
+ }
217
+ return session;
218
+ }
219
+ /** Begin a continuous CDP screencast for a takeover (no viewport disruption). */
220
+ async ensureScreencast(taskId) {
221
+ const session = this.requireTakeover(taskId);
222
+ if (!session.cdp) {
223
+ session.cdp = await session.page.context().newCDPSession(session.page);
224
+ }
225
+ if (session.screencasting)
226
+ return;
227
+ session.screencasting = true;
228
+ const cdp = session.cdp;
229
+ cdp.on("Page.screencastFrame", (params) => {
230
+ session.latestFrame = params.data;
231
+ if (params.metadata?.deviceWidth)
232
+ session.width = Math.round(params.metadata.deviceWidth);
233
+ if (params.metadata?.deviceHeight)
234
+ session.height = Math.round(params.metadata.deviceHeight);
235
+ cdp.send("Page.screencastFrameAck", { sessionId: params.sessionId }).catch(() => undefined);
236
+ });
237
+ await cdp.send("Page.startScreencast", { format: "jpeg", quality: 50, everyNthFrame: 1 });
238
+ }
239
+ /**
240
+ * The latest screencast frame of the taken-over page plus its CSS viewport
241
+ * size. Uses a continuous CDP screencast (not page.screenshot, which forces
242
+ * a viewport metrics override and makes the real window flicker/zoom).
243
+ */
244
+ async takeoverFrame(taskId) {
245
+ const session = this.requireTakeover(taskId);
246
+ await this.ensureScreencast(taskId);
247
+ // Until the first screencast frame arrives, fall back to a single shot.
248
+ if (!session.latestFrame) {
249
+ const buf = await session.page.screenshot({ type: "jpeg", quality: 55 }).catch(() => null);
250
+ if (buf)
251
+ session.latestFrame = buf.toString("base64");
252
+ const viewport = await session.page
253
+ .evaluate(() => ({ width: window.innerWidth, height: window.innerHeight }))
254
+ .catch(() => ({ width: 0, height: 0 }));
255
+ session.width = session.width || viewport.width;
256
+ session.height = session.height || viewport.height;
257
+ }
258
+ // Report the page's real CSS viewport — that is exactly the coordinate
259
+ // space CDP Input expects, so a relayed click lands precisely on target on
260
+ // any display (scaled / retina included). Screencast metadata can diverge
261
+ // from innerWidth on fractional-scale displays; innerWidth never lies.
262
+ // Re-read the CSS viewport EVERY frame — the real-profile Chrome window can be
263
+ // resized/zoomed/maximized after takeover starts, and a cached value offsets
264
+ // every relayed click (the captcha box is small, so even a few px misses).
265
+ const vp = await session.page
266
+ .evaluate(() => ({ w: window.innerWidth, h: window.innerHeight }))
267
+ .catch(() => null);
268
+ if (vp?.w) {
269
+ session.cssW = vp.w;
270
+ session.cssH = vp.h;
271
+ }
272
+ return {
273
+ frame: `data:image/jpeg;base64,${session.latestFrame ?? ""}`,
274
+ width: session.cssW ?? session.width ?? 1280,
275
+ height: session.cssH ?? session.height ?? 720,
276
+ };
277
+ }
278
+ /** Relay a human's mouse/keyboard input into the real page via CDP. */
279
+ async takeoverInput(taskId, input) {
280
+ const session = this.requireTakeover(taskId);
281
+ if (!session.cdp) {
282
+ session.cdp = await session.page.context().newCDPSession(session.page);
283
+ }
284
+ const cdp = session.cdp;
285
+ const x = input.x ?? 0;
286
+ const y = input.y ?? 0;
287
+ switch (input.type) {
288
+ case "move":
289
+ await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
290
+ return;
291
+ case "down":
292
+ await cdp.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button: "left", buttons: 1, clickCount: 1 });
293
+ return;
294
+ case "up":
295
+ await cdp.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button: "left", buttons: 0, clickCount: 1 });
296
+ return;
297
+ case "click":
298
+ await cdp.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button: "left", buttons: 1, clickCount: 1 });
299
+ await cdp.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button: "left", buttons: 0, clickCount: 1 });
300
+ return;
301
+ case "scroll":
302
+ await cdp.send("Input.dispatchMouseEvent", { type: "mouseWheel", x, y, deltaX: input.deltaX ?? 0, deltaY: input.deltaY ?? 0 });
303
+ return;
304
+ case "text":
305
+ await cdp.send("Input.insertText", { text: input.text ?? "" });
306
+ return;
307
+ case "key": {
308
+ // Special keys (Enter, Backspace, Delete, Escape, Tab, arrows…)
309
+ // need the virtual key code + code to register, not just the name.
310
+ const key = input.key ?? "";
311
+ const base = { key };
312
+ if (input.code)
313
+ base.code = input.code;
314
+ if (typeof input.keyCode === "number") {
315
+ base.windowsVirtualKeyCode = input.keyCode;
316
+ base.nativeVirtualKeyCode = input.keyCode;
317
+ }
318
+ const text = key === "Enter" ? "\r" : undefined;
319
+ await cdp.send("Input.dispatchKeyEvent", {
320
+ type: text ? "keyDown" : "rawKeyDown",
321
+ ...base,
322
+ ...(text ? { text } : {}),
323
+ });
324
+ await cdp.send("Input.dispatchKeyEvent", { type: "keyUp", ...base });
325
+ return;
326
+ }
327
+ }
328
+ }
329
+ /**
330
+ * Resume after a human takeover: verify the challenge is actually gone, then
331
+ * finish the application (submit). If a challenge or an unfilled required
332
+ * field remains, it does NOT submit — it reports what's left so the human can
333
+ * fix it in the live view and try again. Completes the task on success.
334
+ */
335
+ async resumeTakeover(taskId) {
336
+ const session = this.requireTakeover(taskId);
337
+ const task = this.store.getTask(taskId);
338
+ if (!task)
339
+ throw new RunnerInputError("Task not found");
340
+ const page = session.page;
341
+ // Agent-driven applications are steered by the remote workflow. "Resume"
342
+ // here just signals the human finished the stuck step (or solved a captcha);
343
+ // the workflow polls humanDone and continues driving. Do NOT complete/submit.
344
+ if (task.type === "job.apply_agent") {
345
+ session.humanDone = true;
346
+ this.addTaskEvent(task, "job.resumed", "Human finished the step — handing back to the agent");
347
+ return { submitted: false, resumed: true, reason: "handed back to the agent — the agent is continuing" };
348
+ }
349
+ const challenge = await detectHumanChallenge(page);
350
+ if (challenge && !(await challengeSolved(page))) {
351
+ this.addTaskEvent(task, "job.human_challenge_present", `Challenge not yet solved: ${challenge}`);
352
+ return { submitted: false, reason: `The ${challenge} challenge isn't solved yet — complete it in the live view, then submit again.` };
353
+ }
354
+ // Only browser.open reaches here now (job.apply_agent returns above) — it just
355
+ // needs the challenge cleared, no form submit.
356
+ this.addTaskEvent(task, "job.resumed", "Human cleared the challenge; resuming");
357
+ const output = {
358
+ taskType: task.type,
359
+ submitted: false,
360
+ challengeCleared: true,
361
+ resumedAfterHuman: true,
362
+ finalUrl: page.url(),
363
+ title: await page.title().catch(() => ""),
364
+ visibleText: (await page.locator("body").innerText({ timeout: 5_000 }).catch(() => "")).slice(0, 1_500),
365
+ };
366
+ this.addTaskEvent(task, "task.resumed.completed", "Challenge cleared after human takeover", output);
367
+ task.status = "completed";
368
+ task.output = output;
369
+ task.error = undefined;
370
+ task.updatedAt = new Date().toISOString();
371
+ task.completedAt = task.updatedAt;
372
+ this.store.putTask(task);
373
+ this.addTaskEvent(task, "task.completed", `Task completed: ${task.type}`, output);
374
+ await this.endTakeover(taskId);
375
+ return { submitted: true, output };
376
+ }
377
+ /**
378
+ * Always attach files via Playwright, never a native OS dialog: intercept any
379
+ * file chooser this page opens (e.g. a résumé upload) and set the file
380
+ * programmatically. Works even when a REMOTE human clicks the upload button
381
+ * during a takeover — no local file picker is ever needed.
382
+ */
383
+ // Auto-attach the résumé to ANY file chooser on the page — whether the brain uses
384
+ // the `upload` tool or just CLICKS a styled upload widget (SuccessFactors, Workday…
385
+ // render the real <input type=file> hidden behind a button/drop-zone). Playwright's
386
+ // filechooser event fires BEFORE the native OS dialog, so the file attaches without
387
+ // a blocking dialog, regardless of the upload DOM. Idempotent per page.
388
+ armFileAutoAttach(page, filePath) {
389
+ if (this.fileAttachArmed.has(page))
390
+ return;
391
+ this.fileAttachArmed.add(page);
392
+ page.on("filechooser", (chooser) => {
393
+ chooser.setFiles(filePath).catch(() => undefined);
394
+ });
395
+ }
396
+ /** End a takeover session (human finished or gave up). */
397
+ async endTakeover(taskId) {
398
+ const session = this.takeovers.get(taskId);
399
+ if (session?.cdp) {
400
+ if (session.screencasting)
401
+ await session.cdp.send("Page.stopScreencast").catch(() => undefined);
402
+ await session.cdp.detach().catch(() => undefined);
403
+ }
404
+ this.takeovers.delete(taskId);
405
+ }
132
406
  async execute(task) {
133
407
  if (task.type === "echo") {
134
408
  return task.input;
@@ -140,8 +414,34 @@ export class LocalRunner {
140
414
  }
141
415
  const context = await this.getBrowserContext();
142
416
  const page = context.pages()[0] || (await context.newPage());
417
+ // Auto-attach a file (e.g. résumé) via Playwright for any upload the
418
+ // human triggers in the takeover — never a native picker.
419
+ const attachPath = stringValue(task.input.resumePath ?? task.input.attachPath);
420
+ if (attachPath && existsSync(resolve(attachPath))) {
421
+ this.armFileAutoAttach(page, resolve(attachPath));
422
+ this.addTaskEvent(task, "browser.file.armed", "File will auto-attach via Playwright", {
423
+ file: basename(resolve(attachPath)),
424
+ });
425
+ }
143
426
  this.addTaskEvent(task, "browser.goto.started", "Opening browser page", { url });
144
427
  await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 });
428
+ // If the page presents an anti-bot challenge, hand off to a human to
429
+ // solve it via takeover (the same path job applications use).
430
+ await this.guardHumanChallenge(task, page);
431
+ // holdForTakeover: hand the page straight to a human to drive — for
432
+ // complex multi-step sites (e.g. a Dayforce/Workday ATS) the agent
433
+ // can't auto-fill, the human completes it in the live view.
434
+ if (task.input.holdForTakeover) {
435
+ const screenshotBase64 = await captureScreenshotDataUrl(page);
436
+ this.takeovers.set(task.id, { page });
437
+ this.addTaskEvent(task, "browser.takeover.ready", "Page ready for human takeover", { url: page.url() });
438
+ throw new HumanHandoffError("Ready for you to take over and complete this page.", {
439
+ reason: "assist",
440
+ url: page.url(),
441
+ attempts: 0,
442
+ screenshotBase64,
443
+ });
444
+ }
145
445
  const output = {
146
446
  url: page.url(),
147
447
  title: await page.title(),
@@ -149,168 +449,8 @@ export class LocalRunner {
149
449
  this.addTaskEvent(task, "browser.goto.completed", "Browser page loaded", output);
150
450
  return output;
151
451
  }
152
- if (task.type === "job.apply_basic") {
153
- return this.applyToBasicJob(task);
154
- }
155
- if (task.type === "job.apply_authenticated") {
156
- return this.applyToAuthenticatedJob(task);
157
- }
158
452
  throw new Error(`Unknown task type: ${task.type}`);
159
453
  }
160
- async applyToBasicJob(task) {
161
- const job = normalizeJobApplicationInput(task.input);
162
- const context = await this.getBrowserContext();
163
- const page = context.pages()[0] || (await context.newPage());
164
- this.addTaskEvent(task, "browser.goto.started", "Opening job application page", {
165
- url: job.url,
166
- });
167
- await page.goto(job.url, { waitUntil: "domcontentloaded", timeout: 30_000 });
168
- this.addTaskEvent(task, "browser.goto.completed", "Job application page loaded", {
169
- url: page.url(),
170
- title: await page.title(),
171
- });
172
- this.addTaskEvent(task, "job.form.fill.started", "Filling application form", {
173
- candidate: {
174
- fullName: job.candidate.fullName,
175
- email: job.candidate.email,
176
- },
177
- resumeFile: basename(job.resumePath),
178
- });
179
- const filledFields = await fillBasicApplicationForm(page, job);
180
- this.addTaskEvent(task, "job.form.filled", "Application form fields completed", {
181
- fieldsFilled: filledFields,
182
- resumeFile: basename(job.resumePath),
183
- });
184
- const beforeSubmitUrl = page.url();
185
- const navigation = page.waitForNavigation({
186
- waitUntil: "domcontentloaded",
187
- timeout: 15_000,
188
- }).catch(() => null);
189
- this.addTaskEvent(task, "job.form.submit.started", "Submitting application form", {
190
- url: beforeSubmitUrl,
191
- });
192
- await submitApplicationForm(page);
193
- await navigation;
194
- await page.waitForLoadState("domcontentloaded", { timeout: 5_000 }).catch(() => undefined);
195
- const output = {
196
- taskType: "job.apply_basic",
197
- submitted: true,
198
- beforeSubmitUrl,
199
- finalUrl: page.url(),
200
- title: await page.title(),
201
- fieldsFilled: filledFields,
202
- resumeFile: basename(job.resumePath),
203
- visibleText: (await page.locator("body").innerText({ timeout: 5_000 }).catch(() => "")).slice(0, 2_000),
204
- };
205
- this.addTaskEvent(task, "job.form.submit.completed", "Application submission completed", output);
206
- return output;
207
- }
208
- /**
209
- * Apply to a job that requires login/registration first.
210
- * Flow: navigate → detect login requirement → register or login → fill form → submit
211
- */
212
- async applyToAuthenticatedJob(task) {
213
- const input = normalizeAuthenticatedJobInput(task.input);
214
- const context = await this.getBrowserContext();
215
- const page = context.pages()[0] || (await context.newPage());
216
- // Step 1: Navigate to job page
217
- this.addTaskEvent(task, "browser.goto.started", "Opening job page", { url: input.url });
218
- await page.goto(input.url, { waitUntil: "domcontentloaded", timeout: 30_000 });
219
- this.addTaskEvent(task, "browser.goto.completed", "Job page loaded", {
220
- url: page.url(),
221
- title: await page.title(),
222
- });
223
- // Step 2: Check if we need to authenticate
224
- const needsAuth = await detectAuthRequirement(page);
225
- if (needsAuth) {
226
- this.addTaskEvent(task, "job.auth.required", "Authentication required", {
227
- loginDetected: true,
228
- });
229
- // Navigate to login page
230
- const loginUrl = input.loginUrl || await findLoginUrl(page, input.url);
231
- if (!loginUrl)
232
- throw new Error("Login required but no login URL found");
233
- await page.goto(loginUrl, { waitUntil: "domcontentloaded", timeout: 30_000 });
234
- if (input.registration && !input.accountExists) {
235
- // Register first
236
- this.addTaskEvent(task, "job.auth.register.started", "Registering new account");
237
- const registerUrl = await findRegisterUrl(page, loginUrl);
238
- if (registerUrl && registerUrl !== loginUrl) {
239
- await page.goto(registerUrl, { waitUntil: "domcontentloaded", timeout: 30_000 });
240
- }
241
- await fillRegistrationForm(page, input.registration);
242
- await submitForm(page);
243
- await page.waitForLoadState("domcontentloaded", { timeout: 10_000 }).catch(() => undefined);
244
- this.addTaskEvent(task, "job.auth.register.completed", "Registration submitted", {
245
- url: page.url(),
246
- });
247
- }
248
- else if (input.credentials) {
249
- // Login with existing credentials
250
- this.addTaskEvent(task, "job.auth.login.started", "Logging in");
251
- await fillLoginForm(page, input.credentials);
252
- await submitForm(page);
253
- await page.waitForLoadState("domcontentloaded", { timeout: 10_000 }).catch(() => undefined);
254
- this.addTaskEvent(task, "job.auth.login.completed", "Login submitted", {
255
- url: page.url(),
256
- });
257
- }
258
- else {
259
- throw new Error("Login required but no credentials or registration details provided");
260
- }
261
- // Verify we're logged in (check for common indicators)
262
- const stillNeedsAuth = await detectAuthRequirement(page);
263
- if (stillNeedsAuth) {
264
- // Try navigating back to the apply page — some sites redirect after login
265
- const applyUrl = input.url.includes("/apply") ? input.url : `${input.url}`;
266
- await page.goto(applyUrl, { waitUntil: "domcontentloaded", timeout: 30_000 });
267
- const thirdCheck = await detectAuthRequirement(page);
268
- if (thirdCheck) {
269
- throw new Error("Authentication failed — still seeing login/register prompts after login attempt");
270
- }
271
- }
272
- // Navigate to apply page (may have been redirected after login)
273
- if (!page.url().includes("apply")) {
274
- await page.goto(input.url, { waitUntil: "domcontentloaded", timeout: 30_000 });
275
- }
276
- }
277
- // Step 3: Look for the apply form (might be on current page or linked)
278
- const applyLink = await page.locator('a[href*="apply"], a:has-text("Apply")').first();
279
- if (await applyLink.count() > 0) {
280
- await applyLink.click();
281
- await page.waitForLoadState("domcontentloaded", { timeout: 10_000 }).catch(() => undefined);
282
- }
283
- // Step 4: Fill the application form
284
- this.addTaskEvent(task, "job.form.fill.started", "Filling application form");
285
- const filledFields = await fillBasicApplicationForm(page, input);
286
- this.addTaskEvent(task, "job.form.filled", "Form fields completed", {
287
- fieldsFilled: filledFields,
288
- resumeFile: basename(input.resumePath),
289
- });
290
- // Step 5: Submit
291
- const beforeSubmitUrl = page.url();
292
- const navigation = page.waitForNavigation({
293
- waitUntil: "domcontentloaded",
294
- timeout: 15_000,
295
- }).catch(() => null);
296
- this.addTaskEvent(task, "job.form.submit.started", "Submitting application");
297
- await submitApplicationForm(page);
298
- await navigation;
299
- await page.waitForLoadState("domcontentloaded", { timeout: 5_000 }).catch(() => undefined);
300
- const output = {
301
- taskType: "job.apply_authenticated",
302
- submitted: true,
303
- authenticated: needsAuth,
304
- beforeSubmitUrl,
305
- finalUrl: page.url(),
306
- title: await page.title(),
307
- fieldsFilled: filledFields,
308
- resumeFile: basename(input.resumePath),
309
- visibleText: (await page.locator("body").innerText({ timeout: 5_000 }).catch(() => "")).slice(0, 2_000),
310
- };
311
- this.addTaskEvent(task, "job.form.submit.completed", "Application submitted", output);
312
- return output;
313
- }
314
454
  requireTask(id) {
315
455
  const task = this.store.getTask(id);
316
456
  if (!task)
@@ -328,13 +468,269 @@ export class LocalRunner {
328
468
  const playwright = await import("playwright").catch((error) => {
329
469
  throw new Error(`Playwright is not installed. Run pnpm install in the PAGS platform repo. ${error instanceof Error ? error.message : String(error)}`);
330
470
  });
331
- this.browserContext = await playwright.chromium.launchPersistentContext(profileDir, {
471
+ const baseOpts = {
332
472
  headless: this.config.headless,
333
473
  acceptDownloads: true,
334
474
  downloadsPath,
335
- });
475
+ // Light anti-detection: drop the most obvious automation tell so fewer
476
+ // CAPTCHAs trigger in the first place (the human still solves the rest).
477
+ args: ["--disable-blink-features=AutomationControlled"],
478
+ };
479
+ // Prefer the real Chrome build (better TLS/fingerprint → fewer CAPTCHAs);
480
+ // fall back to bundled Chromium if Chrome isn't installed. Disable with
481
+ // PAGS_RUNNER_CHROMIUM=1.
482
+ //
483
+ // Real-profile mode (PAGS_RUNNER_REAL_PROFILE=1, or an explicit
484
+ // PAGS_RUNNER_CHROME_USER_DATA_DIR) uses YOUR actual Chrome profile —
485
+ // cookies, logins, browsing history — which is the strongest way to look
486
+ // human to CAPTCHA reputation scoring. Requires your normal Chrome to be
487
+ // CLOSED (Chrome locks a profile to one running instance). Otherwise we
488
+ // use a dedicated chrome-profile so we never fight your open browser.
489
+ const preferChrome = process.env.PAGS_RUNNER_CHROMIUM !== "1";
490
+ const realProfileDir = resolveRealChromeProfileDir();
491
+ try {
492
+ if (!preferChrome)
493
+ throw new Error("chromium forced");
494
+ if (realProfileDir) {
495
+ // The seed is a snapshot; PAGS_RUNNER_REFRESH_PROFILE=1 re-copies it
496
+ // so logins/cookies pick up changes from your everyday Chrome.
497
+ const seedDir = join(this.config.dataDir, "real-profile-copy");
498
+ if (process.env.PAGS_RUNNER_REFRESH_PROFILE === "1")
499
+ rmSync(seedDir, { recursive: true, force: true });
500
+ const seededDir = seedProfileCopy(realProfileDir, seedDir);
501
+ if (!seededDir)
502
+ throw new Error(`could not read your real Chrome profile at ${realProfileDir.userDataDir}`);
503
+ this.browserContext = await playwright.chromium.launchPersistentContext(seededDir, {
504
+ ...baseOpts,
505
+ channel: "chrome",
506
+ args: [...baseOpts.args, "--profile-directory=Default"],
507
+ });
508
+ console.log(`[runner] launched a private copy of your real Chrome profile (signed-in sessions seeded from "${realProfileDir.profile}")`);
509
+ }
510
+ else {
511
+ this.browserContext = await playwright.chromium.launchPersistentContext(join(this.config.dataDir, "chrome-profile"), { ...baseOpts, channel: "chrome" });
512
+ }
513
+ }
514
+ catch (err) {
515
+ if (realProfileDir) {
516
+ const msg = err instanceof Error ? err.message.split("\n")[0] : String(err);
517
+ console.warn(`[runner] real-profile launch failed (${msg}); falling back to a dedicated profile. Fully quit Chrome (Cmd+Q) to use your real profile.`);
518
+ }
519
+ this.browserContext = await playwright.chromium.launchPersistentContext(profileDir, baseOpts);
520
+ }
521
+ await this.browserContext
522
+ .addInitScript(() => {
523
+ Object.defineProperty(navigator, "webdriver", { get: () => undefined });
524
+ })
525
+ .catch(() => undefined);
526
+ // Follow the active page: when the ATS opens a new tab/popup it becomes the
527
+ // page both the brain and the human takeover act on. Without this, the
528
+ // runner blindly used pages()[0] and the two could diverge.
529
+ this.browserContext.on("page", (p) => this.trackPage(p));
530
+ const initial = this.browserContext.pages()[0];
531
+ if (initial)
532
+ this.trackPage(initial);
336
533
  return this.browserContext;
337
534
  }
535
+ /** Mark a page as the active one; fall back to the newest remaining page when it closes. */
536
+ trackPage(page) {
537
+ this.activePage = page;
538
+ if (this.applyResumePath)
539
+ this.armFileAutoAttach(page, this.applyResumePath);
540
+ page.once("close", () => {
541
+ if (this.activePage === page) {
542
+ this.activePage = this.browserContext?.pages().at(-1) ?? null;
543
+ }
544
+ });
545
+ }
546
+ /** The page the brain drives and the human takes over — created on demand. */
547
+ async getActivePage() {
548
+ const context = await this.getBrowserContext();
549
+ if (this.activePage && !this.activePage.isClosed())
550
+ return this.activePage;
551
+ const page = context.pages().at(-1) ?? (await context.newPage());
552
+ this.activePage = page;
553
+ return page;
554
+ }
555
+ /**
556
+ * What the remote brain "sees": a compact ARIA tree (roles + accessible names
557
+ * + values + states) of the active page — the same representation the brain
558
+ * acts on via {@link browserAct}. No raw HTML/screenshots → cheap on tokens.
559
+ */
560
+ async browserSnapshot(taskId) {
561
+ // If the user pressed Stop, the task is cancelled — tell the brain to halt.
562
+ if (taskId && this.store.getTask(taskId)?.status === "cancelled") {
563
+ return { url: "", title: "", snapshot: "", challenge: null, truncated: false, cancelled: true };
564
+ }
565
+ const page = await this.getActivePage();
566
+ await page.waitForLoadState("domcontentloaded", { timeout: 5_000 }).catch(() => undefined);
567
+ const full = await page.locator("body").ariaSnapshot().catch(() => "");
568
+ const MAX = 16_000;
569
+ const snapshot = full.length > MAX ? `${full.slice(0, MAX)}\n… [snapshot truncated]` : full;
570
+ return {
571
+ url: page.url(),
572
+ title: await page.title().catch(() => ""),
573
+ snapshot,
574
+ challenge: await detectHumanChallenge(page),
575
+ truncated: full.length > MAX,
576
+ };
577
+ }
578
+ /**
579
+ * Perform one brain-issued action on the active page. Elements are addressed
580
+ * by ARIA role + accessible name (from the snapshot) via Playwright's
581
+ * getByRole — robust and selector-free. Returns the resulting page state.
582
+ */
583
+ async browserAct(action, resumePath) {
584
+ const page = await this.getActivePage();
585
+ // Arm résumé auto-attach so a file chooser never blocks the flow (see method).
586
+ if (resumePath && existsSync(resolve(resumePath))) {
587
+ this.applyResumePath = resolve(resumePath);
588
+ }
589
+ if (this.applyResumePath)
590
+ this.armFileAutoAttach(page, this.applyResumePath);
591
+ // The selector-free action switch lives in browser-actions.ts (kept this
592
+ // file from ballooning); the post-action settle + result stay here.
593
+ await performBrowserAction(page, action, (p, loc) => this.clickRobustly(p, loc));
594
+ // A click may trigger SPA navigation or open a new tab/popup. Give it a beat
595
+ // to settle and follow the (possibly new) active page, so the next snapshot
596
+ // reflects the new state — not the element the brain just clicked.
597
+ await page.waitForTimeout(700).catch(() => undefined);
598
+ const active = await this.getActivePage();
599
+ await active.waitForLoadState("domcontentloaded", { timeout: 6_000 }).catch(() => undefined);
600
+ // SPA route changes (Dayforce/Workday) swap the view via XHR without a full
601
+ // load, so domcontentloaded fires instantly and the snapshot can catch the
602
+ // PRE-swap DOM (still showing the button just clicked → a wasted re-click).
603
+ // Wait for the network to go idle after navigation-ish actions.
604
+ if (action.action === "click" || action.action === "navigate" || action.action === "key") {
605
+ await active.waitForLoadState("networkidle", { timeout: 3_500 }).catch(() => undefined);
606
+ }
607
+ return {
608
+ ok: true,
609
+ url: active.url(),
610
+ title: await active.title().catch(() => ""),
611
+ challenge: await detectHumanChallenge(active),
612
+ };
613
+ }
614
+ /**
615
+ * Click an element through escalating fallbacks: normal → force (bypass
616
+ * actionability) → scroll-into-view + click the element's CENTER COORDINATES
617
+ * with the mouse. The coordinate click defeats custom-styled controls (hidden
618
+ * inputs, overlays) that swallow locator clicks. Returns false only if the
619
+ * element can't be located/positioned at all.
620
+ */
621
+ async clickRobustly(page, loc) {
622
+ if (await loc.click({ timeout: 6_000 }).then(() => true).catch(() => false))
623
+ return true;
624
+ if (await loc.click({ force: true, timeout: 4_000 }).then(() => true).catch(() => false))
625
+ return true;
626
+ await loc.scrollIntoViewIfNeeded({ timeout: 3_000 }).catch(() => undefined);
627
+ const box = await loc.boundingBox().catch(() => null);
628
+ if (!box)
629
+ return false;
630
+ await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2).catch(() => undefined);
631
+ return true;
632
+ }
633
+ // ── Agent-driven application lifecycle (called by the remote Workflow brain) ──
634
+ /** Append a decision/event to the agent task's activity (the console trace). */
635
+ browserEvent(taskId, type, message, data) {
636
+ const task = this.store.getTask(taskId);
637
+ if (task)
638
+ this.addTaskEvent(task, type, message, data);
639
+ return { ok: true };
640
+ }
641
+ /**
642
+ * Hand off for a CAPTCHA in the SAME session: register the live active page
643
+ * for remote takeover and flip the task to needs_human so the console shows
644
+ * the take-over view. The brain pauses; the human solves it on this page.
645
+ */
646
+ async browserHandoff(taskId, label, reason = "challenge") {
647
+ const page = await this.getActivePage();
648
+ this.takeovers.set(taskId, { page, reason, humanDone: false });
649
+ const screenshotBase64 = await captureScreenshotDataUrl(page);
650
+ const task = this.store.getTask(taskId);
651
+ if (task) {
652
+ task.status = "needs_human";
653
+ task.updatedAt = new Date().toISOString();
654
+ this.store.putTask(task);
655
+ const message = reason === "needs_input"
656
+ ? `The agent needs a value from you: ${label}. Enter it and the agent continues.`
657
+ : reason === "stuck"
658
+ ? `Take over: the agent is stuck on "${label}". Do that one step (e.g. tick the box / click continue) in the live view, then click Resume.`
659
+ : `Take over to solve the ${label} — the agent will continue once it's solved.`;
660
+ this.addTaskEvent(task, "job.human_handoff_required", message, {
661
+ reason,
662
+ challengeType: reason === "challenge" ? label : undefined,
663
+ stuckOn: reason === "stuck" ? label : undefined,
664
+ inputField: reason === "needs_input" ? label : undefined,
665
+ url: page.url(),
666
+ screenshotBase64,
667
+ });
668
+ }
669
+ return { ok: true, screenshotBase64 };
670
+ }
671
+ /** Whether the brain can resume: a solved captcha (auto), a human "Resume" for a stuck step, or a provided value. */
672
+ async browserHandoffStatus(taskId) {
673
+ const session = this.takeovers.get(taskId);
674
+ const page = session?.page ?? (await this.getActivePage());
675
+ if (page.isClosed())
676
+ return { solved: true, challenge: null };
677
+ // A needs_input handoff resumes once the user supplies the value.
678
+ if (session?.reason === "needs_input")
679
+ return { solved: !!session.inputValue, challenge: null, value: session.inputValue };
680
+ // A stuck handoff resumes only when the human explicitly clicks Resume —
681
+ // there's nothing to auto-detect.
682
+ if (session?.reason === "stuck")
683
+ return { solved: !!session.humanDone, challenge: null };
684
+ // A challenge resumes when the token/widget clears OR the human clicks Done —
685
+ // custom captchas (e.g. PageUp's "not a robot") have no detectable token, so
686
+ // the human's explicit Done is the authority; never strand them.
687
+ const challenge = await detectHumanChallenge(page);
688
+ const solved = !challenge || (await challengeSolved(page)) || !!session?.humanDone;
689
+ return { solved, challenge };
690
+ }
691
+ /** The user supplied the value the agent asked for (ask-and-hold). */
692
+ browserSubmitInput(taskId, value) {
693
+ const session = this.takeovers.get(taskId);
694
+ if (session)
695
+ session.inputValue = value;
696
+ const task = this.store.getTask(taskId);
697
+ if (task) {
698
+ task.status = "running";
699
+ task.updatedAt = new Date().toISOString();
700
+ this.store.putTask(task);
701
+ this.addTaskEvent(task, "job.input_provided", "You provided the requested value — the agent is continuing");
702
+ }
703
+ return { ok: true };
704
+ }
705
+ /** End the takeover and return the task to running so the brain drives again. */
706
+ async browserResume(taskId) {
707
+ await this.endTakeover(taskId).catch(() => undefined);
708
+ const task = this.store.getTask(taskId);
709
+ if (task) {
710
+ task.status = "running";
711
+ task.updatedAt = new Date().toISOString();
712
+ this.store.putTask(task);
713
+ this.addTaskEvent(task, "job.resumed", "Challenge solved — agent resuming the application.");
714
+ }
715
+ return { ok: true };
716
+ }
717
+ /** Finalize an agent-driven application (submitted/expired → completed; else failed). */
718
+ async browserComplete(taskId, outcome, detail) {
719
+ await this.endTakeover(taskId).catch(() => undefined);
720
+ const task = this.store.getTask(taskId);
721
+ if (task) {
722
+ const success = outcome === "submitted" || outcome === "ready" || outcome === "expired";
723
+ task.status = success ? "completed" : "failed";
724
+ task.output = { outcome, detail };
725
+ if (!success)
726
+ task.error = detail || outcome;
727
+ task.updatedAt = new Date().toISOString();
728
+ task.completedAt = task.updatedAt;
729
+ this.store.putTask(task);
730
+ this.addTaskEvent(task, success ? "task.completed" : "task.failed", detail || `Application ${outcome}`, { outcome, detail });
731
+ }
732
+ return { ok: true };
733
+ }
338
734
  ensureChromiumInstalled() {
339
735
  if (this.chromiumInstallChecked)
340
736
  return;
@@ -351,164 +747,6 @@ export class LocalRunner {
351
747
  }
352
748
  }
353
749
  }
354
- export function normalizeJobApplicationInput(input) {
355
- const url = stringValue(input.url);
356
- if (!url || !/^https?:\/\//.test(url)) {
357
- throw new RunnerInputError("job.apply_basic requires an http(s) url");
358
- }
359
- const resumePath = resolve(stringValue(input.resumePath));
360
- if (!resumePath || !existsSync(resumePath)) {
361
- throw new RunnerInputError("job.apply_basic requires an existing resumePath");
362
- }
363
- const candidate = isRecord(input.candidate) ? input.candidate : {};
364
- const fullName = stringValue(candidate.fullName);
365
- const email = stringValue(candidate.email);
366
- if (!fullName)
367
- throw new RunnerInputError("candidate.fullName is required");
368
- if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
369
- throw new RunnerInputError("candidate.email must be a valid email address");
370
- }
371
- return {
372
- url,
373
- resumePath,
374
- candidate: {
375
- fullName,
376
- email,
377
- phone: optionalString(candidate.phone),
378
- location: optionalString(candidate.location),
379
- linkedin: optionalString(candidate.linkedin),
380
- portfolio: optionalString(candidate.portfolio),
381
- workAuthorization: optionalString(candidate.workAuthorization),
382
- },
383
- coverNote: optionalString(input.coverNote),
384
- };
385
- }
386
- async function fillBasicApplicationForm(page, job) {
387
- const filled = [];
388
- const candidate = job.candidate;
389
- const fillSpecs = [
390
- ["fullName", candidate.fullName, [
391
- 'input[name="fullName"]',
392
- 'input[name="full_name"]',
393
- 'input[name="name"]',
394
- 'input[autocomplete="name"]',
395
- ]],
396
- ["email", candidate.email, [
397
- 'input[name="email"]',
398
- 'input[type="email"]',
399
- 'input[autocomplete="email"]',
400
- ]],
401
- ["phone", candidate.phone, [
402
- 'input[name="phone"]',
403
- 'input[name="mobile"]',
404
- 'input[autocomplete="tel"]',
405
- ]],
406
- ["location", candidate.location, [
407
- 'input[name="location"]',
408
- 'input[autocomplete="address-level2"]',
409
- 'input[autocomplete="address-level1"]',
410
- ]],
411
- ["linkedin", candidate.linkedin, [
412
- 'input[name="linkedin"]',
413
- 'input[name="linkedIn"]',
414
- 'input[placeholder*="LinkedIn" i]',
415
- ]],
416
- ["portfolio", candidate.portfolio, [
417
- 'input[name="portfolio"]',
418
- 'input[name="website"]',
419
- 'input[placeholder*="Portfolio" i]',
420
- 'input[placeholder*="Website" i]',
421
- ]],
422
- ["coverNote", job.coverNote, [
423
- 'textarea[name="coverNote"]',
424
- 'textarea[name="cover_letter"]',
425
- 'textarea[name="coverLetter"]',
426
- 'textarea[placeholder*="cover" i]',
427
- 'textarea[placeholder*="message" i]',
428
- ]],
429
- ];
430
- for (const [name, value, selectors] of fillSpecs) {
431
- if (value && await fillFirst(page, selectors, value))
432
- filled.push(name);
433
- }
434
- if (candidate.workAuthorization && await selectOrFillFirst(page, [
435
- 'select[name="workAuthorization"]',
436
- 'select[name="work_authorization"]',
437
- 'select[name*="authorization" i]',
438
- ], candidate.workAuthorization)) {
439
- filled.push("workAuthorization");
440
- }
441
- const resumeInput = page.locator('input[type="file"][name*="resume" i], input[type="file"][name*="cv" i], input[type="file"]').first();
442
- if (await resumeInput.count() === 0) {
443
- throw new Error("No resume upload field found on the application form");
444
- }
445
- await resumeInput.setInputFiles(job.resumePath);
446
- filled.push("resume");
447
- return filled;
448
- }
449
- async function fillFirst(page, selectors, value) {
450
- for (const selector of selectors) {
451
- const locator = page.locator(selector).first();
452
- if (await locator.count() === 0)
453
- continue;
454
- try {
455
- await locator.fill(value, { timeout: 5_000 });
456
- return true;
457
- }
458
- catch {
459
- /* try next selector */
460
- }
461
- }
462
- return false;
463
- }
464
- async function selectOrFillFirst(page, selectors, value) {
465
- for (const selector of selectors) {
466
- const locator = page.locator(selector).first();
467
- if (await locator.count() === 0)
468
- continue;
469
- try {
470
- await locator.selectOption({ label: value }, { timeout: 5_000 });
471
- return true;
472
- }
473
- catch {
474
- try {
475
- await locator.fill(value, { timeout: 5_000 });
476
- return true;
477
- }
478
- catch {
479
- /* try next selector */
480
- }
481
- }
482
- }
483
- return false;
484
- }
485
- async function submitApplicationForm(page) {
486
- const submit = page.locator('form button[type="submit"], form input[type="submit"], button:has-text("Submit"), input[type="submit"]').first();
487
- if (await submit.count() > 0) {
488
- await submit.click({ timeout: 10_000 });
489
- return;
490
- }
491
- const form = page.locator("form").first();
492
- if (await form.count() === 0)
493
- throw new Error("No application form found to submit");
494
- await form.evaluate((node) => {
495
- const htmlForm = node;
496
- if (typeof htmlForm.requestSubmit === "function")
497
- htmlForm.requestSubmit();
498
- else
499
- htmlForm.submit();
500
- });
501
- }
502
- function isRecord(value) {
503
- return typeof value === "object" && value !== null && !Array.isArray(value);
504
- }
505
- function stringValue(value) {
506
- return String(value || "").trim();
507
- }
508
- function optionalString(value) {
509
- const text = stringValue(value);
510
- return text || undefined;
511
- }
512
750
  function normalizeCreateTaskRequest(request) {
513
751
  if (!isRecord(request) || typeof request.type !== "string" || !request.type.trim()) {
514
752
  throw new RunnerInputError("task type required");
@@ -522,102 +760,10 @@ function normalizeCreateTaskRequest(request) {
522
760
  : "",
523
761
  };
524
762
  }
763
+ function validateTaskInput(_type, _input) {
764
+ // The agent-driven apply (job.apply_agent) validates inputs in the workflow;
765
+ // no runner-side schema validation needed for the remaining task types.
766
+ }
525
767
  export function fileUrl(path) {
526
768
  return pathToFileURL(path).toString();
527
769
  }
528
- // ── Authenticated job application helpers ─────────────────────────────────
529
- export function normalizeAuthenticatedJobInput(input) {
530
- const base = normalizeJobApplicationInput(input);
531
- const credentials = isRecord(input.credentials) ? input.credentials : undefined;
532
- const registration = isRecord(input.registration) ? input.registration : undefined;
533
- return {
534
- ...base,
535
- credentials: credentials ? {
536
- email: stringValue(credentials.email),
537
- password: stringValue(credentials.password),
538
- } : undefined,
539
- loginUrl: optionalString(input.loginUrl),
540
- accountExists: input.accountExists === true,
541
- registration: registration ? {
542
- fullName: stringValue(registration.fullName),
543
- email: stringValue(registration.email),
544
- password: stringValue(registration.password),
545
- } : undefined,
546
- };
547
- }
548
- async function detectAuthRequirement(page) {
549
- const body = await page.locator("body").innerHTML({ timeout: 5_000 }).catch(() => "");
550
- const lower = body.toLowerCase();
551
- // Check for login/register prompts
552
- const hasLoginForm = await page.locator('input[type="password"], form[action*="login"], form[action*="signin"]').count() > 0;
553
- const hasLoginText = /sign\s*in|log\s*in|create\s*(an\s*)?account|register\s*to\s*apply/i.test(lower);
554
- const hasLoginLink = await page.locator('a[href*="login"], a[href*="signin"], a[href*="register"]').count() > 0;
555
- return hasLoginForm || (hasLoginText && hasLoginLink);
556
- }
557
- async function findLoginUrl(page, baseUrl) {
558
- const loginLink = page.locator('a[href*="login"], a[href*="signin"], a:has-text("Sign in"), a:has-text("Log in")').first();
559
- if (await loginLink.count() > 0) {
560
- const href = await loginLink.getAttribute("href");
561
- if (href)
562
- return new URL(href, baseUrl).toString();
563
- }
564
- return null;
565
- }
566
- async function findRegisterUrl(page, baseUrl) {
567
- const regLink = page.locator('a[href*="register"], a[href*="signup"], a:has-text("Create account"), a:has-text("Sign up")').first();
568
- if (await regLink.count() > 0) {
569
- const href = await regLink.getAttribute("href");
570
- if (href)
571
- return new URL(href, baseUrl).toString();
572
- }
573
- return null;
574
- }
575
- async function fillLoginForm(page, credentials) {
576
- await fillFirst(page, [
577
- 'input[name="email"]',
578
- 'input[type="email"]',
579
- 'input[autocomplete="email"]',
580
- 'input[autocomplete="username"]',
581
- 'input[name="username"]',
582
- ], credentials.email);
583
- await fillFirst(page, [
584
- 'input[name="password"]',
585
- 'input[type="password"]',
586
- 'input[autocomplete="current-password"]',
587
- ], credentials.password);
588
- }
589
- async function fillRegistrationForm(page, registration) {
590
- await fillFirst(page, [
591
- 'input[name="fullName"]',
592
- 'input[name="full_name"]',
593
- 'input[name="name"]',
594
- 'input[autocomplete="name"]',
595
- ], registration.fullName);
596
- await fillFirst(page, [
597
- 'input[name="email"]',
598
- 'input[type="email"]',
599
- 'input[autocomplete="email"]',
600
- ], registration.email);
601
- await fillFirst(page, [
602
- 'input[name="password"]',
603
- 'input[type="password"]',
604
- 'input[autocomplete="new-password"]',
605
- ], registration.password);
606
- }
607
- async function submitForm(page) {
608
- const submit = page.locator('button[type="submit"], input[type="submit"]').first();
609
- if (await submit.count() > 0) {
610
- await submit.click({ timeout: 10_000 });
611
- return;
612
- }
613
- const form = page.locator("form").first();
614
- if (await form.count() > 0) {
615
- await form.evaluate((node) => {
616
- const f = node;
617
- if (typeof f.requestSubmit === "function")
618
- f.requestSubmit();
619
- else
620
- f.submit();
621
- });
622
- }
623
- }