site-agent-pro 1.0.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/README.md +689 -0
- package/dist/auth/credentialStore.js +62 -0
- package/dist/auth/inbox.js +193 -0
- package/dist/auth/profile.js +379 -0
- package/dist/auth/runner.js +1124 -0
- package/dist/backend/dashboardData.js +194 -0
- package/dist/backend/runArtifacts.js +48 -0
- package/dist/backend/runRepository.js +93 -0
- package/dist/bin.js +2 -0
- package/dist/cli/backfillSiteChecks.js +143 -0
- package/dist/cli/run.js +309 -0
- package/dist/cli/trade.js +69 -0
- package/dist/config.js +199 -0
- package/dist/core/agentProfiles.js +55 -0
- package/dist/core/aggregateReport.js +382 -0
- package/dist/core/audit.js +30 -0
- package/dist/core/customTaskSuite.js +148 -0
- package/dist/core/evaluator.js +217 -0
- package/dist/core/executor.js +788 -0
- package/dist/core/fallbackReport.js +335 -0
- package/dist/core/formHeuristics.js +411 -0
- package/dist/core/gameplaySummary.js +164 -0
- package/dist/core/interaction.js +202 -0
- package/dist/core/pageState.js +201 -0
- package/dist/core/planner.js +1669 -0
- package/dist/core/processSubmissionBatch.js +204 -0
- package/dist/core/runAuditJob.js +170 -0
- package/dist/core/runner.js +2352 -0
- package/dist/core/siteBrief.js +107 -0
- package/dist/core/siteChecks.js +1526 -0
- package/dist/core/taskDirectives.js +279 -0
- package/dist/core/taskHeuristics.js +263 -0
- package/dist/dashboard/client.js +1256 -0
- package/dist/dashboard/contracts.js +95 -0
- package/dist/dashboard/narrative.js +277 -0
- package/dist/dashboard/server.js +458 -0
- package/dist/dashboard/theme.js +888 -0
- package/dist/index.js +84 -0
- package/dist/llm/client.js +188 -0
- package/dist/paystack/account.js +123 -0
- package/dist/paystack/client.js +100 -0
- package/dist/paystack/index.js +13 -0
- package/dist/paystack/test-paystack.js +83 -0
- package/dist/paystack/transfer.js +138 -0
- package/dist/paystack/types.js +74 -0
- package/dist/paystack/webhook.js +121 -0
- package/dist/prompts/browserAgent.js +124 -0
- package/dist/prompts/reviewer.js +71 -0
- package/dist/reporting/clickReplay.js +290 -0
- package/dist/reporting/html.js +930 -0
- package/dist/reporting/markdown.js +238 -0
- package/dist/reporting/template.js +1141 -0
- package/dist/schemas/types.js +361 -0
- package/dist/submissions/customTasks.js +196 -0
- package/dist/submissions/html.js +770 -0
- package/dist/submissions/model.js +56 -0
- package/dist/submissions/publicUrl.js +76 -0
- package/dist/submissions/service.js +74 -0
- package/dist/submissions/store.js +37 -0
- package/dist/submissions/types.js +65 -0
- package/dist/trade/engine.js +241 -0
- package/dist/trade/evm/erc20.js +44 -0
- package/dist/trade/extractor.js +148 -0
- package/dist/trade/policy.js +35 -0
- package/dist/trade/session.js +31 -0
- package/dist/trade/types.js +107 -0
- package/dist/trade/validator.js +148 -0
- package/dist/utils/files.js +59 -0
- package/dist/utils/log.js +24 -0
- package/dist/utils/playwrightCompat.js +14 -0
- package/dist/utils/time.js +3 -0
- package/dist/wallet/provider.js +345 -0
- package/dist/wallet/relay.js +129 -0
- package/dist/wallet/wallet.js +178 -0
- package/docs/01-installation.md +134 -0
- package/docs/02-running-your-first-audit.md +136 -0
- package/docs/03-configuration.md +233 -0
- package/docs/04-how-the-agent-thinks.md +41 -0
- package/docs/05-extending-personas-and-tasks.md +42 -0
- package/docs/06-hardening-for-production.md +92 -0
- package/package.json +60 -0
|
@@ -0,0 +1,1124 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { chromium, devices } from "playwright";
|
|
3
|
+
import { clampRunDurationMs, config } from "../config.js";
|
|
4
|
+
import { buildFormFieldKey, buildSupplementalAccessProfile, findMatchingSelectOption, fitValueToField } from "../core/formHeuristics.js";
|
|
5
|
+
import { buildLooseAccessiblePattern, prepareLocatorForInteraction, resolvePreferredFieldLocator, typeLikeHuman } from "../core/interaction.js";
|
|
6
|
+
import { ensureDir, writeJson } from "../utils/files.js";
|
|
7
|
+
import { captureInboxCheckpoint, waitForVerificationEmail } from "./inbox.js";
|
|
8
|
+
import { saveStoredIdentity } from "./credentialStore.js";
|
|
9
|
+
import { createAuthIdentityPlan, getMailboxConfig, resolveAuthSessionStatePath } from "./profile.js";
|
|
10
|
+
import { installPlaywrightPageCompat } from "../utils/playwrightCompat.js";
|
|
11
|
+
const SIGNUP_ENTRY_LABELS = [
|
|
12
|
+
"sign up",
|
|
13
|
+
"signup",
|
|
14
|
+
"register",
|
|
15
|
+
"create account",
|
|
16
|
+
"get started",
|
|
17
|
+
"join now",
|
|
18
|
+
"start free trial",
|
|
19
|
+
"start trial",
|
|
20
|
+
"try free"
|
|
21
|
+
];
|
|
22
|
+
const LOGIN_ENTRY_LABELS = [
|
|
23
|
+
"log in",
|
|
24
|
+
"login",
|
|
25
|
+
"sign in",
|
|
26
|
+
"signin",
|
|
27
|
+
"existing account",
|
|
28
|
+
"already have an account"
|
|
29
|
+
];
|
|
30
|
+
const SIGNUP_SUBMIT_LABELS = [
|
|
31
|
+
"create account",
|
|
32
|
+
"sign up",
|
|
33
|
+
"signup",
|
|
34
|
+
"register",
|
|
35
|
+
"continue",
|
|
36
|
+
"next",
|
|
37
|
+
"get started",
|
|
38
|
+
"submit",
|
|
39
|
+
"verify email",
|
|
40
|
+
"send code"
|
|
41
|
+
];
|
|
42
|
+
const LOGIN_SUBMIT_LABELS = [
|
|
43
|
+
"log in",
|
|
44
|
+
"login",
|
|
45
|
+
"sign in",
|
|
46
|
+
"signin",
|
|
47
|
+
"continue",
|
|
48
|
+
"submit",
|
|
49
|
+
"verify",
|
|
50
|
+
"next"
|
|
51
|
+
];
|
|
52
|
+
const VERIFY_SUBMIT_LABELS = [
|
|
53
|
+
"verify",
|
|
54
|
+
"confirm",
|
|
55
|
+
"continue",
|
|
56
|
+
"submit",
|
|
57
|
+
"finish",
|
|
58
|
+
"complete",
|
|
59
|
+
"activate"
|
|
60
|
+
];
|
|
61
|
+
const AUTHENTICATED_SIGNAL_PATTERNS = [
|
|
62
|
+
/log out/i,
|
|
63
|
+
/logout/i,
|
|
64
|
+
/sign out/i,
|
|
65
|
+
/my account/i,
|
|
66
|
+
/account settings/i,
|
|
67
|
+
/profile/i,
|
|
68
|
+
/dashboard/i,
|
|
69
|
+
/billing/i
|
|
70
|
+
];
|
|
71
|
+
const VERIFICATION_SIGNAL_PATTERNS = [
|
|
72
|
+
/check your email/i,
|
|
73
|
+
/verify your email/i,
|
|
74
|
+
/verification code/i,
|
|
75
|
+
/one[- ]time passcode/i,
|
|
76
|
+
/one[- ]time code/i,
|
|
77
|
+
/enter the code/i,
|
|
78
|
+
/confirm your email/i,
|
|
79
|
+
/activation link/i
|
|
80
|
+
];
|
|
81
|
+
const EXISTING_ACCOUNT_PATTERNS = [
|
|
82
|
+
/already (?:have|has) an account/i,
|
|
83
|
+
/already exists/i,
|
|
84
|
+
/email.*already.*use/i,
|
|
85
|
+
/account already/i,
|
|
86
|
+
/user already/i
|
|
87
|
+
];
|
|
88
|
+
const AUTH_WALL_TEXT_PATTERNS = [
|
|
89
|
+
/log in/i,
|
|
90
|
+
/login/i,
|
|
91
|
+
/sign in/i,
|
|
92
|
+
/signin/i,
|
|
93
|
+
/sign up/i,
|
|
94
|
+
/signup/i,
|
|
95
|
+
/register/i,
|
|
96
|
+
/create account/i,
|
|
97
|
+
/already have an account/i
|
|
98
|
+
];
|
|
99
|
+
const AUTH_WALL_EXCLUSION_PATTERNS = [/newsletter/i, /subscribe/i, /marketing emails?/i];
|
|
100
|
+
function shouldUseServerlessChromium() {
|
|
101
|
+
return process.env.USE_SERVERLESS_CHROMIUM === "true";
|
|
102
|
+
}
|
|
103
|
+
async function resolveLaunchOptions(options) {
|
|
104
|
+
const explicitExecutablePath = process.env.PLAYWRIGHT_EXECUTABLE_PATH?.trim();
|
|
105
|
+
if (explicitExecutablePath) {
|
|
106
|
+
return {
|
|
107
|
+
executablePath: explicitExecutablePath,
|
|
108
|
+
headless: options.headed ? false : config.headless
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
if (!shouldUseServerlessChromium()) {
|
|
112
|
+
return {
|
|
113
|
+
headless: options.headed ? false : config.headless
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const imported = (await import("@sparticuz/chromium"));
|
|
117
|
+
const serverlessChromium = imported.default ?? imported;
|
|
118
|
+
const location = process.env.SPARTICUZ_CHROMIUM_LOCATION?.trim() || undefined;
|
|
119
|
+
if ("setGraphicsMode" in serverlessChromium) {
|
|
120
|
+
serverlessChromium.setGraphicsMode = false;
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
args: serverlessChromium.args,
|
|
124
|
+
executablePath: await serverlessChromium.executablePath(location),
|
|
125
|
+
headless: true
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function cleanErrorMessage(error) {
|
|
129
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
130
|
+
return message.replace(/\u001b\[[0-9;]*m/g, "").replace(/\s+/g, " ").trim() || "Unknown error";
|
|
131
|
+
}
|
|
132
|
+
function normalizeText(value) {
|
|
133
|
+
return value.replace(/\s+/g, " ").trim();
|
|
134
|
+
}
|
|
135
|
+
function summarizeLocalPath(filePath) {
|
|
136
|
+
const relativePath = path.relative(process.cwd(), filePath);
|
|
137
|
+
return relativePath && relativePath !== "" && !relativePath.startsWith("..") ? relativePath : filePath;
|
|
138
|
+
}
|
|
139
|
+
function pushEvent(events, type, note, extra = {}) {
|
|
140
|
+
events.push({
|
|
141
|
+
type,
|
|
142
|
+
time: new Date().toISOString(),
|
|
143
|
+
note,
|
|
144
|
+
...extra
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
async function readVisibleSnapshot(page) {
|
|
148
|
+
return {
|
|
149
|
+
url: page.url(),
|
|
150
|
+
title: await page.title().catch(() => ""),
|
|
151
|
+
textSnippet: normalizeText(await page.locator("body").innerText().catch(() => "")).slice(0, 1200)
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function visibleStateChanged(before, after) {
|
|
155
|
+
return before.url !== after.url || before.title !== after.title || before.textSnippet !== after.textSnippet;
|
|
156
|
+
}
|
|
157
|
+
async function firstVisible(locators) {
|
|
158
|
+
for (const entry of locators) {
|
|
159
|
+
try {
|
|
160
|
+
if (await entry.locator.first().isVisible({ timeout: 1200 })) {
|
|
161
|
+
return { locator: entry.locator.first(), label: entry.label, strategy: entry.strategy };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
// continue
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
function buildActionLocators(page, labels) {
|
|
171
|
+
return labels.flatMap((label) => {
|
|
172
|
+
const pattern = buildLooseAccessiblePattern(label);
|
|
173
|
+
if (!pattern) {
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
176
|
+
return [
|
|
177
|
+
{ locator: page.getByRole("button", { name: pattern }), label, strategy: "getByRole(button)" },
|
|
178
|
+
{ locator: page.getByRole("link", { name: pattern }), label, strategy: "getByRole(link)" },
|
|
179
|
+
{ locator: page.getByText(pattern), label, strategy: "getByText" }
|
|
180
|
+
];
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
async function clickFirstMatchingAction(args) {
|
|
184
|
+
const match = await firstVisible(buildActionLocators(args.page, args.labels));
|
|
185
|
+
if (!match) {
|
|
186
|
+
if (!args.optional) {
|
|
187
|
+
pushEvent(args.events, `${args.eventType}_missing`, `Could not find any visible action matching: ${args.labels.join(", ")}`);
|
|
188
|
+
}
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
const before = await readVisibleSnapshot(args.page);
|
|
192
|
+
const startedAt = Date.now();
|
|
193
|
+
const preparedLocator = await prepareLocatorForInteraction(match.locator);
|
|
194
|
+
await preparedLocator.click({ timeout: 5000 }).catch(async () => {
|
|
195
|
+
await preparedLocator.click({ force: true, timeout: 5000 });
|
|
196
|
+
});
|
|
197
|
+
await args.page.waitForLoadState("domcontentloaded").catch(() => undefined);
|
|
198
|
+
await args.page.waitForTimeout(config.actionDelayMs);
|
|
199
|
+
const after = await readVisibleSnapshot(args.page);
|
|
200
|
+
pushEvent(args.events, args.eventType, `Clicked '${match.label}' using ${match.strategy}.`, {
|
|
201
|
+
label: match.label,
|
|
202
|
+
strategy: match.strategy,
|
|
203
|
+
elapsedMs: Date.now() - startedAt,
|
|
204
|
+
stateChanged: visibleStateChanged(before, after),
|
|
205
|
+
destinationUrl: after.url,
|
|
206
|
+
destinationTitle: after.title
|
|
207
|
+
});
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
async function gotoUrl(page, url, events, eventType) {
|
|
211
|
+
await page.goto(url, { waitUntil: "domcontentloaded" });
|
|
212
|
+
await page.waitForTimeout(config.actionDelayMs);
|
|
213
|
+
const snapshot = await readVisibleSnapshot(page);
|
|
214
|
+
pushEvent(events, eventType, `Navigated to ${url}.`, {
|
|
215
|
+
destinationUrl: snapshot.url,
|
|
216
|
+
destinationTitle: snapshot.title
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
async function collectVisibleFields(page) {
|
|
220
|
+
return page.evaluate(() => {
|
|
221
|
+
const fields = [];
|
|
222
|
+
const elements = Array.from(document.querySelectorAll("input, textarea, select"));
|
|
223
|
+
let visibleIndex = 0;
|
|
224
|
+
for (const element of elements) {
|
|
225
|
+
const rect = element.getBoundingClientRect();
|
|
226
|
+
const style = window.getComputedStyle(element);
|
|
227
|
+
const inputType = element instanceof HTMLInputElement ? (element.type || "text").replace(/\s+/g, " ").trim().toLowerCase() : "";
|
|
228
|
+
if (rect.width <= 0 ||
|
|
229
|
+
rect.height <= 0 ||
|
|
230
|
+
style.visibility === "hidden" ||
|
|
231
|
+
style.display === "none" ||
|
|
232
|
+
element.disabled ||
|
|
233
|
+
inputType === "hidden" ||
|
|
234
|
+
inputType === "submit" ||
|
|
235
|
+
inputType === "button" ||
|
|
236
|
+
inputType === "image") {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
visibleIndex += 1;
|
|
240
|
+
const marker = String(visibleIndex);
|
|
241
|
+
element.setAttribute("data-site-agent-auth-field", marker);
|
|
242
|
+
const labelParts = [];
|
|
243
|
+
if ("labels" in element && element.labels) {
|
|
244
|
+
for (const label of Array.from(element.labels)) {
|
|
245
|
+
const labelText = (label.innerText || label.textContent || "").replace(/\s+/g, " ").trim();
|
|
246
|
+
if (labelText) {
|
|
247
|
+
labelParts.push(labelText);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const ariaLabelledBy = element.getAttribute("aria-labelledby");
|
|
252
|
+
if (ariaLabelledBy) {
|
|
253
|
+
for (const id of ariaLabelledBy.split(/\s+/)) {
|
|
254
|
+
const labelElement = document.getElementById(id);
|
|
255
|
+
if (!labelElement) {
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const labelText = (labelElement.textContent || "").replace(/\s+/g, " ").trim();
|
|
259
|
+
if (labelText) {
|
|
260
|
+
labelParts.push(labelText);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const closestLabel = element.closest("label");
|
|
265
|
+
if (closestLabel) {
|
|
266
|
+
const labelText = (closestLabel.innerText || closestLabel.textContent || "").replace(/\s+/g, " ").trim();
|
|
267
|
+
if (labelText) {
|
|
268
|
+
labelParts.push(labelText);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const ariaLabel = (element.getAttribute("aria-label") || "").replace(/\s+/g, " ").trim();
|
|
272
|
+
if (ariaLabel) {
|
|
273
|
+
labelParts.push(ariaLabel);
|
|
274
|
+
}
|
|
275
|
+
const options = [];
|
|
276
|
+
if (element instanceof HTMLSelectElement) {
|
|
277
|
+
for (const option of Array.from(element.options)) {
|
|
278
|
+
const optionText = (option.textContent || "").replace(/\s+/g, " ").trim();
|
|
279
|
+
if (optionText && options.length < 120) {
|
|
280
|
+
options.push(optionText);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
fields.push({
|
|
285
|
+
marker,
|
|
286
|
+
tag: element.tagName.toLowerCase(),
|
|
287
|
+
inputType: inputType || element.tagName.toLowerCase(),
|
|
288
|
+
label: labelParts.join(" ").replace(/\s+/g, " ").trim(),
|
|
289
|
+
placeholder: (element.getAttribute("placeholder") || "").replace(/\s+/g, " ").trim(),
|
|
290
|
+
name: (element.getAttribute("name") || "").replace(/\s+/g, " ").trim(),
|
|
291
|
+
id: (element.id || "").replace(/\s+/g, " ").trim(),
|
|
292
|
+
autocomplete: (element.getAttribute("autocomplete") || "").replace(/\s+/g, " ").trim().toLowerCase(),
|
|
293
|
+
inputMode: (element.getAttribute("inputmode") || "").replace(/\s+/g, " ").trim().toLowerCase(),
|
|
294
|
+
required: element.required || element.getAttribute("aria-required") === "true",
|
|
295
|
+
disabled: element.disabled,
|
|
296
|
+
value: element instanceof HTMLSelectElement
|
|
297
|
+
? (element.selectedOptions[0]?.textContent || "").replace(/\s+/g, " ").trim()
|
|
298
|
+
: (element.value || "").replace(/\s+/g, " ").trim(),
|
|
299
|
+
maxLength: element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement
|
|
300
|
+
? element.maxLength > 0
|
|
301
|
+
? element.maxLength
|
|
302
|
+
: null
|
|
303
|
+
: null,
|
|
304
|
+
options
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
return fields;
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
async function collectVisibleCheckboxes(page) {
|
|
311
|
+
return page.evaluate(() => {
|
|
312
|
+
const checkboxes = [];
|
|
313
|
+
const elements = Array.from(document.querySelectorAll('input[type="checkbox"]'));
|
|
314
|
+
let visibleIndex = 0;
|
|
315
|
+
for (const element of elements) {
|
|
316
|
+
const rect = element.getBoundingClientRect();
|
|
317
|
+
const style = window.getComputedStyle(element);
|
|
318
|
+
if (rect.width <= 0 ||
|
|
319
|
+
rect.height <= 0 ||
|
|
320
|
+
style.visibility === "hidden" ||
|
|
321
|
+
style.display === "none" ||
|
|
322
|
+
element.disabled) {
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
visibleIndex += 1;
|
|
326
|
+
const marker = String(visibleIndex);
|
|
327
|
+
element.setAttribute("data-site-agent-auth-checkbox", marker);
|
|
328
|
+
const labels = [];
|
|
329
|
+
if (element.labels) {
|
|
330
|
+
for (const label of Array.from(element.labels)) {
|
|
331
|
+
const labelText = (label.innerText || label.textContent || "").replace(/\s+/g, " ").trim();
|
|
332
|
+
if (labelText) {
|
|
333
|
+
labels.push(labelText);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const closestLabel = element.closest("label");
|
|
338
|
+
if (closestLabel) {
|
|
339
|
+
const labelText = (closestLabel.innerText || closestLabel.textContent || "").replace(/\s+/g, " ").trim();
|
|
340
|
+
if (labelText) {
|
|
341
|
+
labels.push(labelText);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
const ariaLabel = (element.getAttribute("aria-label") || "").replace(/\s+/g, " ").trim();
|
|
345
|
+
if (ariaLabel) {
|
|
346
|
+
labels.push(ariaLabel);
|
|
347
|
+
}
|
|
348
|
+
checkboxes.push({
|
|
349
|
+
marker,
|
|
350
|
+
label: labels.join(" ").replace(/\s+/g, " ").trim(),
|
|
351
|
+
checked: element.checked,
|
|
352
|
+
required: element.required || element.getAttribute("aria-required") === "true",
|
|
353
|
+
disabled: element.disabled
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
return checkboxes;
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
function buildFieldKey(field) {
|
|
360
|
+
return buildFormFieldKey(field);
|
|
361
|
+
}
|
|
362
|
+
function inferFieldKind(field) {
|
|
363
|
+
const key = buildFieldKey(field);
|
|
364
|
+
if (/credit card|cardholder|card number|\bcvv\b|\bcvc\b|expiry|expiration|mm\/yy|mm-yy/.test(key)) {
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
if (field.autocomplete === "one-time-code" || /otp|one[- ]?time|verification|passcode|security code|auth code/.test(key)) {
|
|
368
|
+
return "otp";
|
|
369
|
+
}
|
|
370
|
+
if (field.inputType === "email" || /\bemail\b|e-mail/.test(key)) {
|
|
371
|
+
return "email";
|
|
372
|
+
}
|
|
373
|
+
if (/user.?name/.test(key)) {
|
|
374
|
+
return "username";
|
|
375
|
+
}
|
|
376
|
+
if (field.inputType === "password" && /confirm|repeat|again/.test(key)) {
|
|
377
|
+
return "confirmPassword";
|
|
378
|
+
}
|
|
379
|
+
if (field.inputType === "password") {
|
|
380
|
+
return "password";
|
|
381
|
+
}
|
|
382
|
+
if (/first.?name|given.?name/.test(key)) {
|
|
383
|
+
return "firstName";
|
|
384
|
+
}
|
|
385
|
+
if (/last.?name|family.?name|surname/.test(key)) {
|
|
386
|
+
return "lastName";
|
|
387
|
+
}
|
|
388
|
+
if (/full.?name/.test(key) || (/\bname\b/.test(key) && !/company|business|organization/.test(key))) {
|
|
389
|
+
return "fullName";
|
|
390
|
+
}
|
|
391
|
+
if (field.inputType === "tel" || /phone|mobile|telephone|tel\b/.test(key)) {
|
|
392
|
+
return "phone";
|
|
393
|
+
}
|
|
394
|
+
if (/address.*line.*2|address 2|suite|unit|apt|apartment/.test(key)) {
|
|
395
|
+
return "addressLine2";
|
|
396
|
+
}
|
|
397
|
+
if (/street|address/.test(key)) {
|
|
398
|
+
return "addressLine1";
|
|
399
|
+
}
|
|
400
|
+
if (/city|town/.test(key)) {
|
|
401
|
+
return "city";
|
|
402
|
+
}
|
|
403
|
+
if (/state|province|region/.test(key)) {
|
|
404
|
+
return "state";
|
|
405
|
+
}
|
|
406
|
+
if (/zip|postal/.test(key)) {
|
|
407
|
+
return "postalCode";
|
|
408
|
+
}
|
|
409
|
+
if (/country/.test(key)) {
|
|
410
|
+
return "country";
|
|
411
|
+
}
|
|
412
|
+
if (/company|organization|business/.test(key)) {
|
|
413
|
+
return "company";
|
|
414
|
+
}
|
|
415
|
+
if (field.autocomplete === "bday" ||
|
|
416
|
+
/date of birth|dob|birthday/.test(key) ||
|
|
417
|
+
(field.inputType === "date" && !/arrival|departure|check[- ]?in|check[- ]?out|appointment|delivery|pickup|schedule/.test(key))) {
|
|
418
|
+
return "dateOfBirth";
|
|
419
|
+
}
|
|
420
|
+
if (field.autocomplete === "bday-day" || /(?:birth|dob|bday).*(?:day)|day of birth/.test(key)) {
|
|
421
|
+
return "birthDay";
|
|
422
|
+
}
|
|
423
|
+
if (field.autocomplete === "bday-month" || /(?:birth|dob|bday).*(?:month)|month of birth/.test(key)) {
|
|
424
|
+
return "birthMonth";
|
|
425
|
+
}
|
|
426
|
+
if (field.autocomplete === "bday-year" || /(?:birth|dob|bday).*(?:year)|year of birth/.test(key)) {
|
|
427
|
+
return "birthYear";
|
|
428
|
+
}
|
|
429
|
+
if (/\bage\b|years?\b|how old/.test(key)) {
|
|
430
|
+
return "age";
|
|
431
|
+
}
|
|
432
|
+
if (field.inputType === "url" || /website|url|portfolio|linkedin|homepage/.test(key)) {
|
|
433
|
+
return "website";
|
|
434
|
+
}
|
|
435
|
+
if (/occupation|job title|profession|role|what do you do/.test(key)) {
|
|
436
|
+
return "occupation";
|
|
437
|
+
}
|
|
438
|
+
if (/bio|about|summary|description|introduce yourself|tell us about yourself/.test(key)) {
|
|
439
|
+
return "bio";
|
|
440
|
+
}
|
|
441
|
+
if (/message|why are you interested|why do you want|comment|notes?/.test(key)) {
|
|
442
|
+
return "message";
|
|
443
|
+
}
|
|
444
|
+
if (/pronouns?/.test(key)) {
|
|
445
|
+
return "pronouns";
|
|
446
|
+
}
|
|
447
|
+
if (/gender|sex/.test(key)) {
|
|
448
|
+
return "gender";
|
|
449
|
+
}
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
function fieldValueForKind(kind, field, identity, otpCode) {
|
|
453
|
+
const supplemental = buildSupplementalAccessProfile(identity);
|
|
454
|
+
switch (kind) {
|
|
455
|
+
case "email":
|
|
456
|
+
return identity.email;
|
|
457
|
+
case "username":
|
|
458
|
+
return supplemental.username;
|
|
459
|
+
case "password":
|
|
460
|
+
case "confirmPassword":
|
|
461
|
+
return identity.password;
|
|
462
|
+
case "fullName":
|
|
463
|
+
return identity.fullName;
|
|
464
|
+
case "firstName":
|
|
465
|
+
return identity.firstName;
|
|
466
|
+
case "lastName":
|
|
467
|
+
return identity.lastName;
|
|
468
|
+
case "phone":
|
|
469
|
+
return identity.phone;
|
|
470
|
+
case "addressLine1":
|
|
471
|
+
return identity.addressLine1;
|
|
472
|
+
case "addressLine2":
|
|
473
|
+
return identity.addressLine2;
|
|
474
|
+
case "city":
|
|
475
|
+
return identity.city;
|
|
476
|
+
case "state":
|
|
477
|
+
return identity.state;
|
|
478
|
+
case "postalCode":
|
|
479
|
+
return identity.postalCode;
|
|
480
|
+
case "country":
|
|
481
|
+
return identity.country;
|
|
482
|
+
case "company":
|
|
483
|
+
return identity.company;
|
|
484
|
+
case "dateOfBirth":
|
|
485
|
+
return supplemental.birthDateIso;
|
|
486
|
+
case "birthDay":
|
|
487
|
+
return supplemental.birthDay;
|
|
488
|
+
case "birthMonth":
|
|
489
|
+
return field.tag === "select" ? supplemental.birthMonthName : supplemental.birthMonthNumber;
|
|
490
|
+
case "birthYear":
|
|
491
|
+
return supplemental.birthYear;
|
|
492
|
+
case "age":
|
|
493
|
+
return supplemental.age;
|
|
494
|
+
case "website":
|
|
495
|
+
return supplemental.website;
|
|
496
|
+
case "occupation":
|
|
497
|
+
return supplemental.occupation;
|
|
498
|
+
case "bio":
|
|
499
|
+
return fitValueToField(field, supplemental.bio);
|
|
500
|
+
case "message":
|
|
501
|
+
return fitValueToField(field, supplemental.message);
|
|
502
|
+
case "pronouns":
|
|
503
|
+
return supplemental.pronouns;
|
|
504
|
+
case "gender":
|
|
505
|
+
return supplemental.gender;
|
|
506
|
+
case "otp":
|
|
507
|
+
return otpCode;
|
|
508
|
+
default:
|
|
509
|
+
return undefined;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
function fieldAllowedInPhase(kind, phase) {
|
|
513
|
+
if (phase === "otp") {
|
|
514
|
+
return kind === "otp";
|
|
515
|
+
}
|
|
516
|
+
if (phase === "login") {
|
|
517
|
+
return kind === "email" || kind === "username" || kind === "password";
|
|
518
|
+
}
|
|
519
|
+
return kind !== "otp";
|
|
520
|
+
}
|
|
521
|
+
async function selectOption(locator, field, value) {
|
|
522
|
+
const preparedLocator = await prepareLocatorForInteraction(locator);
|
|
523
|
+
const desiredOptions = [value];
|
|
524
|
+
if (field.options.some((option) => option.toLowerCase() === "united states of america")) {
|
|
525
|
+
desiredOptions.push("United States of America");
|
|
526
|
+
}
|
|
527
|
+
for (const desired of desiredOptions) {
|
|
528
|
+
try {
|
|
529
|
+
await preparedLocator.selectOption({ label: desired });
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
// continue
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
const matchingOption = findMatchingSelectOption(field.options, desiredOptions);
|
|
537
|
+
if (matchingOption) {
|
|
538
|
+
await preparedLocator.selectOption({ label: matchingOption });
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
throw new Error(`No select option matched '${value}'.`);
|
|
542
|
+
}
|
|
543
|
+
async function fillFields(args) {
|
|
544
|
+
const fields = await collectVisibleFields(args.page);
|
|
545
|
+
let filledCount = 0;
|
|
546
|
+
for (const field of fields) {
|
|
547
|
+
const kind = inferFieldKind(field);
|
|
548
|
+
if (!kind || !fieldAllowedInPhase(kind, args.phase)) {
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
const value = fieldValueForKind(kind, field, args.identity, args.otpCode);
|
|
552
|
+
if (!value) {
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
const currentValue = normalizeText(field.value).toLowerCase();
|
|
556
|
+
if (currentValue === normalizeText(value).toLowerCase()) {
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
const fallbackLocator = args.page.locator(`[data-site-agent-auth-field="${field.marker}"]`).first();
|
|
560
|
+
try {
|
|
561
|
+
const resolvedField = await resolvePreferredFieldLocator({
|
|
562
|
+
page: args.page,
|
|
563
|
+
field,
|
|
564
|
+
fallbackLocator,
|
|
565
|
+
preferredNames: [field.label, field.placeholder, kind]
|
|
566
|
+
});
|
|
567
|
+
if (field.tag === "select") {
|
|
568
|
+
await selectOption(resolvedField.locator, field, value);
|
|
569
|
+
}
|
|
570
|
+
else {
|
|
571
|
+
await typeLikeHuman(resolvedField.locator, fitValueToField(field, value));
|
|
572
|
+
}
|
|
573
|
+
filledCount += 1;
|
|
574
|
+
pushEvent(args.events, `${args.phase}_field_filled`, `Filled ${kind} field '${field.label || field.name || field.id || field.placeholder}'.`, {
|
|
575
|
+
fieldKind: kind,
|
|
576
|
+
fieldLabel: field.label || field.name || field.id || field.placeholder,
|
|
577
|
+
strategy: resolvedField.strategy
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
catch (error) {
|
|
581
|
+
pushEvent(args.events, `${args.phase}_field_fill_error`, `Failed to fill ${kind} field '${field.label || field.name || field.id || field.placeholder}': ${cleanErrorMessage(error)}.`, {
|
|
582
|
+
fieldKind: kind,
|
|
583
|
+
fieldLabel: field.label || field.name || field.id || field.placeholder
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return filledCount;
|
|
588
|
+
}
|
|
589
|
+
async function checkRequiredBoxes(page, events) {
|
|
590
|
+
const checkboxes = await collectVisibleCheckboxes(page);
|
|
591
|
+
let checkedCount = 0;
|
|
592
|
+
for (const checkbox of checkboxes) {
|
|
593
|
+
if (checkbox.checked || checkbox.disabled) {
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
const key = checkbox.label.toLowerCase();
|
|
597
|
+
const isTermsBox = /agree|accept|terms|privacy|conditions|policy/.test(key);
|
|
598
|
+
if (!checkbox.required && !isTermsBox) {
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
const locator = page.locator(`[data-site-agent-auth-checkbox="${checkbox.marker}"]`).first();
|
|
602
|
+
const preparedLocator = await prepareLocatorForInteraction(locator).catch(() => locator);
|
|
603
|
+
await preparedLocator.check({ force: true }).catch(() => undefined);
|
|
604
|
+
checkedCount += 1;
|
|
605
|
+
pushEvent(events, "signup_checkbox_checked", `Checked '${checkbox.label || "required checkbox"}'.`, {
|
|
606
|
+
label: checkbox.label
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
return checkedCount;
|
|
610
|
+
}
|
|
611
|
+
async function pageContainsPattern(page, patterns) {
|
|
612
|
+
const snapshot = await readVisibleSnapshot(page);
|
|
613
|
+
return patterns.some((pattern) => pattern.test(`${snapshot.title} ${snapshot.textSnippet}`));
|
|
614
|
+
}
|
|
615
|
+
async function hasFieldKind(page, kinds) {
|
|
616
|
+
const fields = await collectVisibleFields(page);
|
|
617
|
+
return fields.some((field) => {
|
|
618
|
+
const kind = inferFieldKind(field);
|
|
619
|
+
return kind ? kinds.includes(kind) : false;
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
async function hasLoginForm(page) {
|
|
623
|
+
return (await hasFieldKind(page, ["email", "username"])) && (await hasFieldKind(page, ["password"]));
|
|
624
|
+
}
|
|
625
|
+
async function hasOtpField(page) {
|
|
626
|
+
return hasFieldKind(page, ["otp"]);
|
|
627
|
+
}
|
|
628
|
+
async function isProbablyAuthenticated(page) {
|
|
629
|
+
if (await hasLoginForm(page)) {
|
|
630
|
+
return false;
|
|
631
|
+
}
|
|
632
|
+
const snapshot = await readVisibleSnapshot(page);
|
|
633
|
+
const haystack = `${snapshot.title} ${snapshot.textSnippet}`;
|
|
634
|
+
if (AUTHENTICATED_SIGNAL_PATTERNS.some((pattern) => pattern.test(haystack))) {
|
|
635
|
+
return true;
|
|
636
|
+
}
|
|
637
|
+
return !/login|sign in|sign up|register|verify/i.test(new URL(snapshot.url).pathname);
|
|
638
|
+
}
|
|
639
|
+
function isAuthGateUrl(url) {
|
|
640
|
+
try {
|
|
641
|
+
const { pathname } = new URL(url);
|
|
642
|
+
return /login|sign-in|signin|register|signup|sign-up|verify|confirmation|activate/i.test(pathname);
|
|
643
|
+
}
|
|
644
|
+
catch {
|
|
645
|
+
return false;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
export async function detectAuthWall(page) {
|
|
649
|
+
if (await hasOtpField(page)) {
|
|
650
|
+
return {
|
|
651
|
+
required: true,
|
|
652
|
+
reason: "The page is asking for a verification or one-time code.",
|
|
653
|
+
kind: "verification"
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
if (await hasLoginForm(page)) {
|
|
657
|
+
return {
|
|
658
|
+
required: true,
|
|
659
|
+
reason: "A visible login form is blocking access to the destination.",
|
|
660
|
+
kind: "login"
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
if (await pageContainsPattern(page, VERIFICATION_SIGNAL_PATTERNS)) {
|
|
664
|
+
return {
|
|
665
|
+
required: true,
|
|
666
|
+
reason: "The page is asking the visitor to verify an email before continuing.",
|
|
667
|
+
kind: "verification"
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
const hasEntryFields = (await hasFieldKind(page, ["email", "username"])) || (await hasFieldKind(page, ["password"]));
|
|
671
|
+
const snapshot = await readVisibleSnapshot(page);
|
|
672
|
+
const haystack = `${snapshot.url} ${snapshot.title} ${snapshot.textSnippet}`;
|
|
673
|
+
if (hasEntryFields &&
|
|
674
|
+
AUTH_WALL_TEXT_PATTERNS.some((pattern) => pattern.test(haystack)) &&
|
|
675
|
+
!AUTH_WALL_EXCLUSION_PATTERNS.some((pattern) => pattern.test(haystack))) {
|
|
676
|
+
return {
|
|
677
|
+
required: true,
|
|
678
|
+
reason: "The page is presenting visible login or registration copy instead of the requested content.",
|
|
679
|
+
kind: /log in|login|sign in|signin/i.test(haystack) ? "login" : "signup"
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
if (isAuthGateUrl(snapshot.url)) {
|
|
683
|
+
return {
|
|
684
|
+
required: true,
|
|
685
|
+
reason: "The current URL path looks like an auth gate.",
|
|
686
|
+
kind: "unknown"
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
return {
|
|
690
|
+
required: false,
|
|
691
|
+
reason: "No obvious auth wall was detected.",
|
|
692
|
+
kind: "unknown"
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
async function ensureSignupEntry(args) {
|
|
696
|
+
await gotoUrl(args.page, args.signupUrl ?? args.baseUrl, args.events, "signup_navigation");
|
|
697
|
+
if ((await hasFieldKind(args.page, ["email"])) || (await pageContainsPattern(args.page, [/sign up|register|create account/i]))) {
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
await clickFirstMatchingAction({
|
|
701
|
+
page: args.page,
|
|
702
|
+
labels: SIGNUP_ENTRY_LABELS,
|
|
703
|
+
events: args.events,
|
|
704
|
+
eventType: "signup_entry_click",
|
|
705
|
+
optional: true
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
async function ensureLoginEntry(args) {
|
|
709
|
+
if (args.loginUrl) {
|
|
710
|
+
await gotoUrl(args.page, args.loginUrl, args.events, "login_navigation");
|
|
711
|
+
}
|
|
712
|
+
if (await hasLoginForm(args.page)) {
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
await clickFirstMatchingAction({
|
|
716
|
+
page: args.page,
|
|
717
|
+
labels: LOGIN_ENTRY_LABELS,
|
|
718
|
+
events: args.events,
|
|
719
|
+
eventType: "login_entry_click",
|
|
720
|
+
optional: true
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
async function submitPhase(args) {
|
|
724
|
+
const labels = args.phase === "signup" ? SIGNUP_SUBMIT_LABELS : args.phase === "login" ? LOGIN_SUBMIT_LABELS : VERIFY_SUBMIT_LABELS;
|
|
725
|
+
return clickFirstMatchingAction({
|
|
726
|
+
page: args.page,
|
|
727
|
+
labels,
|
|
728
|
+
events: args.events,
|
|
729
|
+
eventType: `${args.phase}_submit_click`,
|
|
730
|
+
optional: false
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
async function attemptSignupWithIdentity(args) {
|
|
734
|
+
let checkpoint = args.mailboxCheckpoint;
|
|
735
|
+
for (let attempt = 1; attempt <= 4; attempt += 1) {
|
|
736
|
+
const filledCount = await fillFields({
|
|
737
|
+
page: args.page,
|
|
738
|
+
identity: args.identity,
|
|
739
|
+
phase: "signup",
|
|
740
|
+
events: args.events
|
|
741
|
+
});
|
|
742
|
+
await checkRequiredBoxes(args.page, args.events);
|
|
743
|
+
if (await hasOtpField(args.page)) {
|
|
744
|
+
pushEvent(args.events, "signup_requires_otp", "Signup flow reached an on-page OTP step.");
|
|
745
|
+
return { checkpoint, outcome: "verification" };
|
|
746
|
+
}
|
|
747
|
+
if (await pageContainsPattern(args.page, EXISTING_ACCOUNT_PATTERNS)) {
|
|
748
|
+
pushEvent(args.events, "signup_existing_account", "Signup page indicates the account already exists.", {
|
|
749
|
+
accountEmail: args.identity.email
|
|
750
|
+
});
|
|
751
|
+
return { checkpoint, outcome: "existing_account" };
|
|
752
|
+
}
|
|
753
|
+
if (await pageContainsPattern(args.page, VERIFICATION_SIGNAL_PATTERNS)) {
|
|
754
|
+
pushEvent(args.events, "signup_verification_prompt", "Signup page is asking for email verification.");
|
|
755
|
+
return { checkpoint, outcome: "verification" };
|
|
756
|
+
}
|
|
757
|
+
if (await isProbablyAuthenticated(args.page)) {
|
|
758
|
+
pushEvent(args.events, "signup_authenticated", "Signup flow appears to have already produced an authenticated session.");
|
|
759
|
+
return { checkpoint, outcome: "authenticated" };
|
|
760
|
+
}
|
|
761
|
+
if (!checkpoint) {
|
|
762
|
+
const mailbox = getMailboxConfig();
|
|
763
|
+
if (mailbox) {
|
|
764
|
+
checkpoint = await captureInboxCheckpoint(mailbox);
|
|
765
|
+
pushEvent(args.events, "mailbox_checkpoint", `Captured mailbox checkpoint at UID ${checkpoint.uidNext}.`, {
|
|
766
|
+
uidNext: checkpoint.uidNext
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
const submitted = await submitPhase({
|
|
771
|
+
page: args.page,
|
|
772
|
+
phase: "signup",
|
|
773
|
+
events: args.events
|
|
774
|
+
});
|
|
775
|
+
if (!submitted && filledCount === 0) {
|
|
776
|
+
break;
|
|
777
|
+
}
|
|
778
|
+
if (await pageContainsPattern(args.page, EXISTING_ACCOUNT_PATTERNS)) {
|
|
779
|
+
pushEvent(args.events, "signup_existing_account", "Signup submission indicates the account already exists.", {
|
|
780
|
+
accountEmail: args.identity.email
|
|
781
|
+
});
|
|
782
|
+
return { checkpoint, outcome: "existing_account" };
|
|
783
|
+
}
|
|
784
|
+
if ((await hasOtpField(args.page)) || (await pageContainsPattern(args.page, VERIFICATION_SIGNAL_PATTERNS))) {
|
|
785
|
+
return { checkpoint, outcome: "verification" };
|
|
786
|
+
}
|
|
787
|
+
if (await isProbablyAuthenticated(args.page)) {
|
|
788
|
+
return { checkpoint, outcome: "authenticated" };
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
return { checkpoint, outcome: "stalled" };
|
|
792
|
+
}
|
|
793
|
+
async function driveSignup(args) {
|
|
794
|
+
const identityPlan = createAuthIdentityPlan(args.baseUrl);
|
|
795
|
+
let checkpoint = args.mailboxCheckpoint;
|
|
796
|
+
let activeIdentity = identityPlan.identities[0];
|
|
797
|
+
if (identityPlan.source === "stored") {
|
|
798
|
+
pushEvent(args.events, "stored_identity_loaded", "Loaded saved credentials for this target origin and will reuse them during auth.", {
|
|
799
|
+
credentialScope: new URL(args.baseUrl).origin,
|
|
800
|
+
accountEmail: activeIdentity.email,
|
|
801
|
+
accountUsername: activeIdentity.username ?? null
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
for (const [index, identity] of identityPlan.identities.entries()) {
|
|
805
|
+
activeIdentity = identity;
|
|
806
|
+
pushEvent(args.events, "signup_identity_attempt", `Attempting signup with test identity ${index + 1}/${identityPlan.maxAttempts}.`, {
|
|
807
|
+
accountEmail: identity.email,
|
|
808
|
+
attempt: index + 1,
|
|
809
|
+
maxAttempts: identityPlan.maxAttempts
|
|
810
|
+
});
|
|
811
|
+
await ensureSignupEntry({
|
|
812
|
+
page: args.page,
|
|
813
|
+
signupUrl: args.signupUrl,
|
|
814
|
+
baseUrl: args.baseUrl,
|
|
815
|
+
events: args.events
|
|
816
|
+
});
|
|
817
|
+
const result = await attemptSignupWithIdentity({
|
|
818
|
+
page: args.page,
|
|
819
|
+
identity,
|
|
820
|
+
events: args.events,
|
|
821
|
+
mailboxCheckpoint: checkpoint
|
|
822
|
+
});
|
|
823
|
+
checkpoint = result.checkpoint;
|
|
824
|
+
if (result.outcome !== "existing_account") {
|
|
825
|
+
return { checkpoint, identity };
|
|
826
|
+
}
|
|
827
|
+
if (index < identityPlan.identities.length - 1) {
|
|
828
|
+
checkpoint = undefined;
|
|
829
|
+
pushEvent(args.events, "signup_identity_retry", `Signup rejected '${identity.email}' as an existing account, so the runner will retry with a fresh generated identity.`, {
|
|
830
|
+
accountEmail: identity.email,
|
|
831
|
+
nextAttempt: index + 2
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
return { checkpoint, identity: activeIdentity };
|
|
836
|
+
}
|
|
837
|
+
async function handleVerification(args) {
|
|
838
|
+
const needsVerification = (await hasOtpField(args.page)) || (await pageContainsPattern(args.page, VERIFICATION_SIGNAL_PATTERNS));
|
|
839
|
+
if (!needsVerification) {
|
|
840
|
+
return "none";
|
|
841
|
+
}
|
|
842
|
+
const mailbox = getMailboxConfig();
|
|
843
|
+
if (!mailbox) {
|
|
844
|
+
throw new Error("The page is asking for email verification, but AUTH_IMAP_* mailbox settings are not configured.");
|
|
845
|
+
}
|
|
846
|
+
const effectiveCheckpoint = args.checkpoint ?? (await captureInboxCheckpoint(mailbox));
|
|
847
|
+
const siteHost = new URL(args.baseUrl).hostname;
|
|
848
|
+
const message = await waitForVerificationEmail({
|
|
849
|
+
mailbox,
|
|
850
|
+
checkpoint: effectiveCheckpoint,
|
|
851
|
+
siteHost,
|
|
852
|
+
recipientEmail: args.identity.email
|
|
853
|
+
});
|
|
854
|
+
pushEvent(args.events, "verification_email_received", `Received verification email '${message.subject}'.`, {
|
|
855
|
+
receivedAt: message.receivedAt,
|
|
856
|
+
from: message.from,
|
|
857
|
+
hasOtpCode: Boolean(message.otpCode),
|
|
858
|
+
hasVerificationLink: Boolean(message.verificationLink)
|
|
859
|
+
});
|
|
860
|
+
if ((await hasOtpField(args.page)) && message.otpCode) {
|
|
861
|
+
await fillFields({
|
|
862
|
+
page: args.page,
|
|
863
|
+
identity: args.identity,
|
|
864
|
+
phase: "otp",
|
|
865
|
+
events: args.events,
|
|
866
|
+
otpCode: message.otpCode
|
|
867
|
+
});
|
|
868
|
+
await submitPhase({
|
|
869
|
+
page: args.page,
|
|
870
|
+
phase: "verify",
|
|
871
|
+
events: args.events
|
|
872
|
+
});
|
|
873
|
+
pushEvent(args.events, "otp_submitted", "Submitted OTP from verification email.");
|
|
874
|
+
return "otp";
|
|
875
|
+
}
|
|
876
|
+
if (message.verificationLink) {
|
|
877
|
+
await gotoUrl(args.page, message.verificationLink, args.events, "verification_link_navigation");
|
|
878
|
+
return "link";
|
|
879
|
+
}
|
|
880
|
+
throw new Error("A verification email arrived, but no usable OTP code or verification link could be extracted.");
|
|
881
|
+
}
|
|
882
|
+
async function driveLogin(args) {
|
|
883
|
+
if (await isProbablyAuthenticated(args.page)) {
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
await ensureLoginEntry({
|
|
887
|
+
page: args.page,
|
|
888
|
+
loginUrl: args.loginUrl,
|
|
889
|
+
baseUrl: args.baseUrl,
|
|
890
|
+
events: args.events
|
|
891
|
+
});
|
|
892
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
893
|
+
const filledCount = await fillFields({
|
|
894
|
+
page: args.page,
|
|
895
|
+
identity: args.identity,
|
|
896
|
+
phase: "login",
|
|
897
|
+
events: args.events
|
|
898
|
+
});
|
|
899
|
+
if (filledCount === 0 && !(await hasLoginForm(args.page))) {
|
|
900
|
+
break;
|
|
901
|
+
}
|
|
902
|
+
await submitPhase({
|
|
903
|
+
page: args.page,
|
|
904
|
+
phase: "login",
|
|
905
|
+
events: args.events
|
|
906
|
+
});
|
|
907
|
+
if (await isProbablyAuthenticated(args.page)) {
|
|
908
|
+
pushEvent(args.events, "login_authenticated", "Login flow appears to have produced an authenticated session.");
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
async function confirmAccess(args) {
|
|
914
|
+
if (args.accessUrl) {
|
|
915
|
+
await gotoUrl(args.page, args.accessUrl, args.events, "access_navigation");
|
|
916
|
+
}
|
|
917
|
+
const snapshot = await readVisibleSnapshot(args.page);
|
|
918
|
+
const authenticated = await isProbablyAuthenticated(args.page);
|
|
919
|
+
const accessConfirmed = authenticated && !isAuthGateUrl(snapshot.url);
|
|
920
|
+
pushEvent(args.events, "access_check", accessConfirmed
|
|
921
|
+
? "Protected content appears accessible with the authenticated session."
|
|
922
|
+
: "Protected content still appears gated or redirected to auth.", {
|
|
923
|
+
destinationUrl: snapshot.url,
|
|
924
|
+
destinationTitle: snapshot.title
|
|
925
|
+
});
|
|
926
|
+
return { snapshot, authenticated, accessConfirmed };
|
|
927
|
+
}
|
|
928
|
+
async function saveStorageState(args) {
|
|
929
|
+
if (!args.context) {
|
|
930
|
+
return undefined;
|
|
931
|
+
}
|
|
932
|
+
ensureDir(path.dirname(args.savePath));
|
|
933
|
+
await args.context.storageState({ path: args.savePath });
|
|
934
|
+
const summarizedPath = summarizeLocalPath(args.savePath);
|
|
935
|
+
pushEvent(args.events, "storage_state_saved", `Saved Playwright storage state to '${summarizedPath}'.`, {
|
|
936
|
+
path: summarizedPath
|
|
937
|
+
});
|
|
938
|
+
return summarizedPath;
|
|
939
|
+
}
|
|
940
|
+
async function executeAuthFlowInContext(args) {
|
|
941
|
+
let verificationMethod = "none";
|
|
942
|
+
let savedStorageStatePath;
|
|
943
|
+
if (Date.now() >= args.deadline) {
|
|
944
|
+
throw new Error("Auth flow ran out of time before it could begin.");
|
|
945
|
+
}
|
|
946
|
+
const signupResult = await driveSignup({
|
|
947
|
+
page: args.page,
|
|
948
|
+
baseUrl: args.baseUrl,
|
|
949
|
+
signupUrl: args.signupUrl,
|
|
950
|
+
events: args.events
|
|
951
|
+
});
|
|
952
|
+
const identity = signupResult.identity;
|
|
953
|
+
if (Date.now() >= args.deadline) {
|
|
954
|
+
throw new Error("Auth flow ran out of time before verification.");
|
|
955
|
+
}
|
|
956
|
+
verificationMethod = await handleVerification({
|
|
957
|
+
page: args.page,
|
|
958
|
+
events: args.events,
|
|
959
|
+
baseUrl: args.baseUrl,
|
|
960
|
+
identity,
|
|
961
|
+
checkpoint: signupResult.checkpoint
|
|
962
|
+
});
|
|
963
|
+
if (Date.now() >= args.deadline) {
|
|
964
|
+
throw new Error("Auth flow ran out of time before login.");
|
|
965
|
+
}
|
|
966
|
+
await driveLogin({
|
|
967
|
+
page: args.page,
|
|
968
|
+
identity,
|
|
969
|
+
baseUrl: args.baseUrl,
|
|
970
|
+
loginUrl: args.loginUrl,
|
|
971
|
+
events: args.events
|
|
972
|
+
});
|
|
973
|
+
const accessCheck = await confirmAccess({
|
|
974
|
+
page: args.page,
|
|
975
|
+
accessUrl: args.accessUrl,
|
|
976
|
+
events: args.events
|
|
977
|
+
});
|
|
978
|
+
if (accessCheck.authenticated) {
|
|
979
|
+
saveStoredIdentity(args.baseUrl, identity);
|
|
980
|
+
pushEvent(args.events, "stored_identity_saved", "Saved the successful credentials for this target origin for future reuse.", {
|
|
981
|
+
credentialScope: new URL(args.baseUrl).origin,
|
|
982
|
+
accountEmail: identity.email,
|
|
983
|
+
accountUsername: identity.username ?? null
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
if (args.saveStorageStatePath) {
|
|
987
|
+
savedStorageStatePath = await saveStorageState({
|
|
988
|
+
context: args.context,
|
|
989
|
+
savePath: args.saveStorageStatePath,
|
|
990
|
+
events: args.events
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
const snapshot = accessCheck.snapshot;
|
|
994
|
+
return {
|
|
995
|
+
result: {
|
|
996
|
+
status: accessCheck.accessConfirmed ? "authenticated" : "partial_success",
|
|
997
|
+
finalUrl: snapshot.url,
|
|
998
|
+
finalTitle: snapshot.title,
|
|
999
|
+
accessConfirmed: accessCheck.accessConfirmed,
|
|
1000
|
+
verificationMethod,
|
|
1001
|
+
runDir: args.runDir,
|
|
1002
|
+
...(savedStorageStatePath ? { savedStorageStatePath } : {})
|
|
1003
|
+
},
|
|
1004
|
+
accountEmail: identity.email,
|
|
1005
|
+
verificationMethod,
|
|
1006
|
+
...(savedStorageStatePath ? { savedStorageStatePath } : {})
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
export async function runAuthFlowInContext(options) {
|
|
1010
|
+
const authFlowPath = path.join(options.runDir, "auth-flow.json");
|
|
1011
|
+
const events = [];
|
|
1012
|
+
const timeoutMs = clampRunDurationMs(options.timeoutMs ?? config.maxSessionDurationMs);
|
|
1013
|
+
const deadline = Date.now() + timeoutMs;
|
|
1014
|
+
let verificationMethod = "none";
|
|
1015
|
+
let savedStorageStatePath;
|
|
1016
|
+
let accountEmail = "";
|
|
1017
|
+
let result = {
|
|
1018
|
+
status: "failed",
|
|
1019
|
+
finalUrl: options.page.url() || options.baseUrl,
|
|
1020
|
+
finalTitle: "",
|
|
1021
|
+
accessConfirmed: false,
|
|
1022
|
+
verificationMethod,
|
|
1023
|
+
runDir: options.runDir
|
|
1024
|
+
};
|
|
1025
|
+
pushEvent(events, "auth_flow_start", `Starting auth bootstrap for ${options.baseUrl}.`, {
|
|
1026
|
+
headed: Boolean(options.headed),
|
|
1027
|
+
mobile: Boolean(options.mobile),
|
|
1028
|
+
timeoutSeconds: Math.round(timeoutMs / 1000)
|
|
1029
|
+
});
|
|
1030
|
+
try {
|
|
1031
|
+
options.page.setDefaultNavigationTimeout(config.navigationTimeoutMs);
|
|
1032
|
+
options.page.setDefaultTimeout(config.navigationTimeoutMs);
|
|
1033
|
+
const execution = await executeAuthFlowInContext({
|
|
1034
|
+
page: options.page,
|
|
1035
|
+
context: options.context,
|
|
1036
|
+
baseUrl: options.baseUrl,
|
|
1037
|
+
runDir: options.runDir,
|
|
1038
|
+
signupUrl: options.signupUrl,
|
|
1039
|
+
loginUrl: options.loginUrl,
|
|
1040
|
+
accessUrl: options.accessUrl,
|
|
1041
|
+
saveStorageStatePath: options.saveStorageStatePath,
|
|
1042
|
+
events,
|
|
1043
|
+
deadline
|
|
1044
|
+
});
|
|
1045
|
+
verificationMethod = execution.verificationMethod;
|
|
1046
|
+
savedStorageStatePath = execution.savedStorageStatePath;
|
|
1047
|
+
accountEmail = execution.accountEmail;
|
|
1048
|
+
result = execution.result;
|
|
1049
|
+
}
|
|
1050
|
+
catch (error) {
|
|
1051
|
+
const snapshot = await readVisibleSnapshot(options.page).catch(() => ({
|
|
1052
|
+
url: options.baseUrl,
|
|
1053
|
+
title: "",
|
|
1054
|
+
textSnippet: ""
|
|
1055
|
+
}));
|
|
1056
|
+
pushEvent(events, "auth_flow_error", `Auth flow failed: ${cleanErrorMessage(error)}.`);
|
|
1057
|
+
result = {
|
|
1058
|
+
status: "failed",
|
|
1059
|
+
finalUrl: snapshot.url,
|
|
1060
|
+
finalTitle: snapshot.title,
|
|
1061
|
+
accessConfirmed: false,
|
|
1062
|
+
verificationMethod,
|
|
1063
|
+
runDir: options.runDir,
|
|
1064
|
+
error: cleanErrorMessage(error)
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
finally {
|
|
1068
|
+
writeJson(authFlowPath, {
|
|
1069
|
+
...result,
|
|
1070
|
+
accountEmail: accountEmail || null,
|
|
1071
|
+
savedStorageStatePath: savedStorageStatePath ?? null,
|
|
1072
|
+
verificationMethod,
|
|
1073
|
+
generatedAt: new Date().toISOString(),
|
|
1074
|
+
events
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
return {
|
|
1078
|
+
...result,
|
|
1079
|
+
accountEmail,
|
|
1080
|
+
events
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
export async function runAuthFlow(options) {
|
|
1084
|
+
const saveStorageStatePath = options.saveStorageStatePath ?? resolveAuthSessionStatePath();
|
|
1085
|
+
const contextOptions = options.mobile
|
|
1086
|
+
? {
|
|
1087
|
+
...devices["iPhone 13"],
|
|
1088
|
+
viewport: config.mobileViewport,
|
|
1089
|
+
ignoreHTTPSErrors: Boolean(options.ignoreHttpsErrors),
|
|
1090
|
+
timezoneId: config.deviceTimezone
|
|
1091
|
+
}
|
|
1092
|
+
: {
|
|
1093
|
+
viewport: config.desktopViewport,
|
|
1094
|
+
ignoreHTTPSErrors: Boolean(options.ignoreHttpsErrors),
|
|
1095
|
+
timezoneId: config.deviceTimezone,
|
|
1096
|
+
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
|
|
1097
|
+
};
|
|
1098
|
+
let browser = null;
|
|
1099
|
+
let context = null;
|
|
1100
|
+
try {
|
|
1101
|
+
browser = await chromium.launch(await resolveLaunchOptions({ headed: options.headed }));
|
|
1102
|
+
context = await browser.newContext(contextOptions);
|
|
1103
|
+
await installPlaywrightPageCompat(context);
|
|
1104
|
+
const page = await context.newPage();
|
|
1105
|
+
const execution = await runAuthFlowInContext({
|
|
1106
|
+
page,
|
|
1107
|
+
context,
|
|
1108
|
+
baseUrl: options.baseUrl,
|
|
1109
|
+
runDir: options.runDir,
|
|
1110
|
+
signupUrl: options.signupUrl,
|
|
1111
|
+
loginUrl: options.loginUrl,
|
|
1112
|
+
accessUrl: options.accessUrl,
|
|
1113
|
+
saveStorageStatePath,
|
|
1114
|
+
timeoutMs: config.maxSessionDurationMs,
|
|
1115
|
+
headed: Boolean(options.headed),
|
|
1116
|
+
mobile: Boolean(options.mobile)
|
|
1117
|
+
});
|
|
1118
|
+
return execution;
|
|
1119
|
+
}
|
|
1120
|
+
finally {
|
|
1121
|
+
await context?.close().catch(() => undefined);
|
|
1122
|
+
await browser?.close().catch(() => undefined);
|
|
1123
|
+
}
|
|
1124
|
+
}
|