teamshare-bridge 0.26.11 → 0.26.13
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/dist/bridge/local-server.js +6 -10
- package/dist/bridge/local-server.js.map +1 -1
- package/dist/cli/commands/research.js +15 -0
- package/dist/cli/commands/research.js.map +1 -1
- package/dist/lib/browser/batch.d.ts +47 -2
- package/dist/lib/browser/batch.js +48 -5
- package/dist/lib/browser/batch.js.map +1 -1
- package/dist/lib/browser/browse-cdp.js +5 -1
- package/dist/lib/browser/browse-cdp.js.map +1 -1
- package/dist/lib/browser/browse-editor.js +8 -0
- package/dist/lib/browser/browse-editor.js.map +1 -1
- package/dist/lib/browser/cdp.d.ts +2 -0
- package/dist/lib/browser/cdp.js +8 -0
- package/dist/lib/browser/cdp.js.map +1 -1
- package/dist/lib/browser/index.d.ts +3 -1
- package/dist/lib/browser/index.js +12 -1
- package/dist/lib/browser/index.js.map +1 -1
- package/dist/lib/browser/login.d.ts +67 -25
- package/dist/lib/browser/login.js +681 -174
- package/dist/lib/browser/login.js.map +1 -1
- package/dist/lib/browser/manager.d.ts +26 -0
- package/dist/lib/browser/manager.js +48 -0
- package/dist/lib/browser/manager.js.map +1 -1
- package/dist/lib/browser/network-log.d.ts +9 -0
- package/dist/lib/browser/network-log.js +21 -0
- package/dist/lib/browser/network-log.js.map +1 -1
- package/dist/lib/browser/page-context.d.ts +176 -0
- package/dist/lib/browser/page-context.js +637 -0
- package/dist/lib/browser/page-context.js.map +1 -0
- package/dist/lib/browser/page-events.d.ts +39 -0
- package/dist/lib/browser/page-events.js +128 -0
- package/dist/lib/browser/page-events.js.map +1 -0
- package/dist/lib/browser/screen-change.js +3 -0
- package/dist/lib/browser/screen-change.js.map +1 -1
- package/dist/lib/browser/session-pool.d.ts +44 -0
- package/dist/lib/browser/session-pool.js +128 -0
- package/dist/lib/browser/session-pool.js.map +1 -0
- package/dist/lib/browser/session.d.ts +115 -1
- package/dist/lib/browser/session.js +355 -2
- package/dist/lib/browser/session.js.map +1 -1
- package/dist/lib/config.js +25 -4
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/orchestrator/handlers/browser.js +283 -40
- package/dist/lib/orchestrator/handlers/browser.js.map +1 -1
- package/dist/lib/tools/browser-tool.d.ts +2 -2
- package/dist/lib/tools/browser-tool.js +58 -19
- package/dist/lib/tools/browser-tool.js.map +1 -1
- package/package.json +1 -1
- package/templates/common/AGENTS.md.tpl +19 -19
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FIND_LOGIN_FIELDS_JS = void 0;
|
|
3
4
|
exports.findLoginFields = findLoginFields;
|
|
5
|
+
exports.loginStepOf = loginStepOf;
|
|
4
6
|
exports.typeTextVerified = typeTextVerified;
|
|
5
7
|
exports.fillFieldVerified = fillFieldVerified;
|
|
6
8
|
exports.performLogin = performLogin;
|
|
@@ -14,17 +16,21 @@ exports.isCredentialFocused = isCredentialFocused;
|
|
|
14
16
|
* module is the explicit, OPT-IN escape hatch (`allowCredentials`), authored so
|
|
15
17
|
* the LLM never has to type a secret itself:
|
|
16
18
|
*
|
|
17
|
-
* - `findLoginFields(page)` locates the username / password / submit
|
|
18
|
-
* with
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
19
|
+
* - `findLoginFields(page)` locates the username / password / submit / OTP
|
|
20
|
+
* controls with DOM heuristics. It scopes the search to the TOPMOST OPEN
|
|
21
|
+
* DIALOG when one exists, because a login opened in a modal must not be
|
|
22
|
+
* confused with a footer newsletter field or the page's nav "Login" button.
|
|
23
|
+
* - `performLogin(page, { username, password, otp? })` clears every field,
|
|
24
|
+
* fills it, verifies the exact value, submits, and follows the site's real
|
|
25
|
+
* flow: email → password, email → OTP, email → password → OTP, password →
|
|
26
|
+
* OTP, OTP only. It NEVER reports success while an OTP field is visible.
|
|
27
|
+
* - `fillFieldVerified(page, control, value)` always clears first (select-all
|
|
28
|
+
* + Backspace + native value setter), verifies the field is EMPTY, then
|
|
29
|
+
* types and verifies the value EXACTLY. The old version compared only the
|
|
30
|
+
* length, so an autofilled value plus the typed text could pass as
|
|
31
|
+
* "filled" - or fail forever.
|
|
26
32
|
* - `typeTextVerified(page, text)` is the same verification for the generic
|
|
27
|
-
* `type` action
|
|
33
|
+
* `type` action; `clearFirst` makes it replace instead of append.
|
|
28
34
|
*
|
|
29
35
|
* The auto-answer loop (`browse --auto-answer`) is a DIFFERENT code path and
|
|
30
36
|
* still never touches credentials - see `personal-fields.ts`.
|
|
@@ -32,34 +38,60 @@ exports.isCredentialFocused = isCredentialFocused;
|
|
|
32
38
|
* NO secret is ever logged here.
|
|
33
39
|
*/
|
|
34
40
|
const cdp_1 = require("./cdp");
|
|
35
|
-
/** DOM heuristic: locate the login controls
|
|
36
|
-
|
|
41
|
+
/** DOM heuristic: locate the login controls, scoped to the topmost modal. */
|
|
42
|
+
exports.FIND_LOGIN_FIELDS_JS = `(() => {
|
|
37
43
|
const vis = (el) => {
|
|
38
44
|
if (!el) return false;
|
|
39
45
|
const r = el.getBoundingClientRect();
|
|
40
46
|
if (r.width === 0 || r.height === 0) return false;
|
|
41
47
|
const cs = getComputedStyle(el);
|
|
42
|
-
return cs.visibility !== 'hidden' && cs.display !== 'none';
|
|
43
|
-
};
|
|
44
|
-
const box = (el, hint) => {
|
|
45
|
-
const r = el.getBoundingClientRect();
|
|
46
|
-
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), hint: hint || '' };
|
|
48
|
+
return cs.visibility !== 'hidden' && cs.display !== 'none' && cs.opacity !== '0';
|
|
47
49
|
};
|
|
48
50
|
const hintOf = (el) => [el.getAttribute('name'), el.id, el.getAttribute('autocomplete')].filter(Boolean).join('/');
|
|
49
|
-
const inputs = [...document.querySelectorAll('input, textarea')].filter(vis);
|
|
50
51
|
const lowerType = (el) => (el.type || '').toLowerCase();
|
|
52
|
+
const autocomplete = (el) => (el.getAttribute('autocomplete') || '').toLowerCase();
|
|
53
|
+
|
|
54
|
+
// MODAL SCOPE (2026-10): a login opened in a dialog must win over a footer
|
|
55
|
+
// newsletter input or the page's nav Login button. Prefer the first visible
|
|
56
|
+
// dialog/frame that actually contains a login-ish field; otherwise the last
|
|
57
|
+
// visible dialog; otherwise the document.
|
|
58
|
+
const dialogSel = 'dialog[open],[role="dialog"],[aria-modal="true"],' +
|
|
59
|
+
'[class*="modal"],[class*="Modal"],[class*="dialog"],[class*="Dialog"]';
|
|
60
|
+
const dialogEls = [...document.querySelectorAll(dialogSel)].filter(vis);
|
|
61
|
+
const hasStrongLogin = (d) => !!d.querySelector('input[type="password"],input[autocomplete="username"],input[autocomplete="current-password"],input[autocomplete="new-password"],input[autocomplete="one-time-code"]');
|
|
62
|
+
const hasEmailAndContinue = (d) => {
|
|
63
|
+
if (!d.querySelector('input[type="email"],input[autocomplete="email"]')) return false;
|
|
64
|
+
return [...d.querySelectorAll('button,[role="button"],input[type="submit"]')].some((el) =>
|
|
65
|
+
/continue|next|sign|log\\s?in|verify|submit/i.test((el.innerText || el.textContent || el.value || '').trim()),
|
|
66
|
+
);
|
|
67
|
+
};
|
|
68
|
+
let scope =
|
|
69
|
+
dialogEls.find(hasStrongLogin) ||
|
|
70
|
+
dialogEls.find(hasEmailAndContinue) ||
|
|
71
|
+
dialogEls.find((d) => d.querySelector('input,textarea')) ||
|
|
72
|
+
null;
|
|
73
|
+
if (!scope && dialogEls.length) scope = dialogEls[dialogEls.length - 1];
|
|
74
|
+
const root = scope || document;
|
|
75
|
+
|
|
76
|
+
// Clear the previous scan's tags so a re-rendered modal cannot be addressed
|
|
77
|
+
// through a stale element.
|
|
78
|
+
document.querySelectorAll('[data-ts-login]').forEach((e) => e.removeAttribute('data-ts-login'));
|
|
79
|
+
|
|
80
|
+
const inputs = [...root.querySelectorAll('input,textarea')]
|
|
81
|
+
.filter(vis)
|
|
82
|
+
.filter((el) => lowerType(el) !== 'hidden' && !el.disabled && !el.readOnly);
|
|
51
83
|
|
|
52
84
|
const pw =
|
|
53
85
|
inputs.find((el) => lowerType(el) === 'password') ||
|
|
54
|
-
inputs.find((el) => ['current-password', 'new-password'].includes((el
|
|
86
|
+
inputs.find((el) => ['current-password', 'new-password'].includes(autocomplete(el))) ||
|
|
55
87
|
null;
|
|
56
88
|
|
|
57
89
|
let user =
|
|
58
|
-
inputs.find((el) => (el
|
|
90
|
+
inputs.find((el) => autocomplete(el) === 'username') ||
|
|
59
91
|
inputs.find((el) => lowerType(el) === 'email') ||
|
|
60
92
|
null;
|
|
61
93
|
if (!user) {
|
|
62
|
-
const textish = inputs.filter((el) => ['', 'text', 'email', 'tel'].includes(lowerType(el))
|
|
94
|
+
const textish = inputs.filter((el) => ['', 'text', 'email', 'tel'].includes(lowerType(el)));
|
|
63
95
|
if (pw) {
|
|
64
96
|
user = textish.find((el) => (el.compareDocumentPosition(pw) & Node.DOCUMENT_POSITION_PRECEDING)) || null;
|
|
65
97
|
}
|
|
@@ -67,44 +99,83 @@ const FIND_LOGIN_FIELDS_JS = `(() => {
|
|
|
67
99
|
}
|
|
68
100
|
|
|
69
101
|
const otp =
|
|
70
|
-
inputs.find((el) => (el
|
|
71
|
-
inputs.find((el) => /otp|one[- ]?time|verif|code/i.test(hintOf(el))) ||
|
|
102
|
+
inputs.find((el) => autocomplete(el) === 'one-time-code') ||
|
|
103
|
+
inputs.find((el) => /otp|one[- ]?time|verif|code/i.test(hintOf(el) || '')) ||
|
|
72
104
|
null;
|
|
73
105
|
|
|
74
|
-
|
|
75
|
-
|
|
106
|
+
// The same element can match several heuristics; one role wins.
|
|
107
|
+
if (user && otp && user === otp) user = null;
|
|
108
|
+
if (user && pw && user === pw) user = null;
|
|
109
|
+
|
|
110
|
+
const submitRe = /sign\\s?in|log\\s?in|login|continue|next|submit|log\\s?on|enter|proceed|verify|confirm/i;
|
|
111
|
+
const clickable = [...root.querySelectorAll('button,input[type=submit],input[type=button],[role=button],a')]
|
|
112
|
+
.filter(vis)
|
|
113
|
+
.filter((el) => el.disabled !== true && el.getAttribute('aria-disabled') !== 'true');
|
|
76
114
|
let submit =
|
|
77
115
|
clickable.find((el) => (el.getAttribute('type') || '').toLowerCase() === 'submit') ||
|
|
78
116
|
clickable.find((el) => submitRe.test((el.innerText || el.textContent || el.value || '').trim())) ||
|
|
79
|
-
clickable.find((el) => /submit/i.test(hintOf(el))) ||
|
|
117
|
+
clickable.find((el) => /submit/i.test(hintOf(el) || '')) ||
|
|
80
118
|
null;
|
|
81
119
|
|
|
120
|
+
const box = (el, hint, role) => {
|
|
121
|
+
el.setAttribute('data-ts-login', role);
|
|
122
|
+
const r = el.getBoundingClientRect();
|
|
123
|
+
return {
|
|
124
|
+
x: Math.round(r.x + r.width / 2),
|
|
125
|
+
y: Math.round(r.y + r.height / 2),
|
|
126
|
+
hint: hint || '',
|
|
127
|
+
role: role,
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
|
|
82
131
|
return {
|
|
83
|
-
username: user && vis(user) ? box(user, hintOf(user)) : null,
|
|
84
|
-
password: pw && vis(pw) ? box(pw, hintOf(pw)) : null,
|
|
85
|
-
submit: submit ? box(submit, (submit.innerText || submit.textContent || submit.value || '').trim().slice(0, 40)) : null,
|
|
86
|
-
otp: otp ? box(otp, hintOf(otp)) : null,
|
|
132
|
+
username: user && vis(user) ? box(user, hintOf(user), 'username') : null,
|
|
133
|
+
password: pw && vis(pw) ? box(pw, hintOf(pw), 'password') : null,
|
|
134
|
+
submit: submit ? box(submit, (submit.innerText || submit.textContent || submit.value || '').trim().slice(0, 40), 'submit') : null,
|
|
135
|
+
otp: otp && vis(otp) ? box(otp, hintOf(otp), 'otp') : null,
|
|
136
|
+
modal: !!scope,
|
|
87
137
|
};
|
|
88
138
|
})()`;
|
|
89
139
|
async function findLoginFields(page) {
|
|
140
|
+
const empty = {
|
|
141
|
+
username: null,
|
|
142
|
+
password: null,
|
|
143
|
+
submit: null,
|
|
144
|
+
otp: null,
|
|
145
|
+
modal: false,
|
|
146
|
+
};
|
|
90
147
|
try {
|
|
91
|
-
const value = await (0, cdp_1.evaluateJs)(page, FIND_LOGIN_FIELDS_JS);
|
|
92
|
-
if (!value || typeof value !== 'object')
|
|
93
|
-
return
|
|
94
|
-
|
|
148
|
+
const value = await (0, cdp_1.evaluateJs)(page, exports.FIND_LOGIN_FIELDS_JS);
|
|
149
|
+
if (!value || typeof value !== 'object')
|
|
150
|
+
return empty;
|
|
151
|
+
const v = value;
|
|
95
152
|
return {
|
|
96
|
-
username:
|
|
97
|
-
password:
|
|
98
|
-
submit:
|
|
99
|
-
otp:
|
|
153
|
+
username: v.username ?? null,
|
|
154
|
+
password: v.password ?? null,
|
|
155
|
+
submit: v.submit ?? null,
|
|
156
|
+
otp: v.otp ?? null,
|
|
157
|
+
modal: v.modal === true,
|
|
100
158
|
};
|
|
101
159
|
}
|
|
102
160
|
catch {
|
|
103
|
-
return
|
|
161
|
+
return empty;
|
|
104
162
|
}
|
|
105
163
|
}
|
|
106
|
-
/**
|
|
107
|
-
|
|
164
|
+
/**
|
|
165
|
+
* The login phase a `waitFor {for:"loginStep"}` caller can wait for. Password
|
|
166
|
+
* wins over OTP (a password+OTP screen is still the password step), then OTP,
|
|
167
|
+
* then username; no fields means the step is finished.
|
|
168
|
+
*/
|
|
169
|
+
function loginStepOf(fields) {
|
|
170
|
+
if (fields.password)
|
|
171
|
+
return 'password';
|
|
172
|
+
if (fields.otp)
|
|
173
|
+
return 'otp';
|
|
174
|
+
if (fields.username)
|
|
175
|
+
return 'username';
|
|
176
|
+
return 'done';
|
|
177
|
+
}
|
|
178
|
+
/** Read the active element's value (input/textarea; contenteditable is null). */ async function activeValue(page) {
|
|
108
179
|
try {
|
|
109
180
|
const v = await (0, cdp_1.evaluateJs)(page, `(() => { const a = document.activeElement; return a && typeof a.value === 'string' ? a.value : null; })()`);
|
|
110
181
|
return typeof v === 'string' ? v : null;
|
|
@@ -113,6 +184,19 @@ async function activeValue(page) {
|
|
|
113
184
|
return null;
|
|
114
185
|
}
|
|
115
186
|
}
|
|
187
|
+
/** Read a tagged login control's value. Never logged or returned to the agent. */
|
|
188
|
+
async function controlValue(page, control) {
|
|
189
|
+
const role = control.role;
|
|
190
|
+
if (!role || role === 'submit')
|
|
191
|
+
return null;
|
|
192
|
+
try {
|
|
193
|
+
const v = await (0, cdp_1.evaluateJs)(page, `(() => { const el = document.querySelector('[data-ts-login="${role}"]'); return el && typeof el.value === 'string' ? el.value : null; })()`);
|
|
194
|
+
return typeof v === 'string' ? v : null;
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
116
200
|
/** CDP key/code for a printable char (local copy - avoids a browse-editor cycle). */
|
|
117
201
|
function charKey(c) {
|
|
118
202
|
if (/^[a-z]$/.test(c))
|
|
@@ -185,36 +269,16 @@ async function typeCharByChar(page, text) {
|
|
|
185
269
|
await (0, cdp_1.sleep)(12);
|
|
186
270
|
}
|
|
187
271
|
}
|
|
188
|
-
/** Select the focused control's current contents
|
|
272
|
+
/** Select the focused control's current contents. */
|
|
189
273
|
async function selectActive(page) {
|
|
190
274
|
try {
|
|
191
|
-
await (0, cdp_1.evaluateJs)(page, `(() => { const a = document.activeElement; if (a
|
|
275
|
+
await (0, cdp_1.evaluateJs)(page, `(() => { const a = document.activeElement; if (!a) return false; if (a.select) { try { a.select(); return true; } catch (e) {} } if (a.setSelectionRange && typeof a.value === 'string') { try { a.setSelectionRange(0, a.value.length); return true; } catch (e) {} } if (a.isContentEditable) { const sel = window.getSelection(); if (sel) { const r = document.createRange(); r.selectNodeContents(a); sel.removeAllRanges(); sel.addRange(r); return true; } } return false; })()`);
|
|
192
276
|
}
|
|
193
277
|
catch {
|
|
194
278
|
/* best-effort */
|
|
195
279
|
}
|
|
196
280
|
}
|
|
197
|
-
|
|
198
|
-
* Type text into the currently focused control and VERIFY it landed.
|
|
199
|
-
* `Input.insertText` first (fires a proper `input` event for controlled
|
|
200
|
-
* components); on a shortfall, retry character-by-character via key events.
|
|
201
|
-
* Returns the value actually observed in the field.
|
|
202
|
-
*/
|
|
203
|
-
async function typeTextVerified(page, text) {
|
|
204
|
-
const before = (await activeValue(page)) ?? '';
|
|
205
|
-
// Insert path: replaces any current selection.
|
|
206
|
-
try {
|
|
207
|
-
await (0, cdp_1.cdpSend)(page, 'Input.insertText', { text });
|
|
208
|
-
}
|
|
209
|
-
catch {
|
|
210
|
-
/* fall through to key events */
|
|
211
|
-
}
|
|
212
|
-
await (0, cdp_1.sleep)(60);
|
|
213
|
-
let after = (await activeValue(page)) ?? '';
|
|
214
|
-
if (after.length >= before.length + text.length)
|
|
215
|
-
return true;
|
|
216
|
-
// Fallback: clear, then key-event each character.
|
|
217
|
-
await selectActive(page);
|
|
281
|
+
async function pressBackspace(page) {
|
|
218
282
|
try {
|
|
219
283
|
await (0, cdp_1.cdpSend)(page, 'Input.dispatchKeyEvent', {
|
|
220
284
|
type: 'keyDown',
|
|
@@ -234,17 +298,29 @@ async function typeTextVerified(page, text) {
|
|
|
234
298
|
catch {
|
|
235
299
|
/* best-effort */
|
|
236
300
|
}
|
|
237
|
-
await (0, cdp_1.sleep)(40);
|
|
238
|
-
await typeCharByChar(page, text);
|
|
239
|
-
await (0, cdp_1.sleep)(60);
|
|
240
|
-
after = (await activeValue(page)) ?? '';
|
|
241
|
-
return after.length >= text.length || after.length > before.length;
|
|
242
301
|
}
|
|
243
|
-
/**
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
302
|
+
/** Set a tagged input's value through the prototype setter (React-safe). */
|
|
303
|
+
async function setNativeControlValue(page, role, value) {
|
|
304
|
+
try {
|
|
305
|
+
const ok = await (0, cdp_1.evaluateJs)(page, `(() => {
|
|
306
|
+
const el = document.querySelector('[data-ts-login="${role}"]');
|
|
307
|
+
if (!el || typeof el.value !== 'string') return false;
|
|
308
|
+
const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
309
|
+
const desc = Object.getOwnPropertyDescriptor(proto, 'value');
|
|
310
|
+
if (!desc || !desc.set) return false;
|
|
311
|
+
desc.set.call(el, ${JSON.stringify(value)});
|
|
312
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
313
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
314
|
+
return true;
|
|
315
|
+
})()`);
|
|
316
|
+
return ok === true;
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
/** Focus the exact tagged control (click + JS focus, not a coordinate guess). */
|
|
323
|
+
async function focusControl(page, control) {
|
|
248
324
|
try {
|
|
249
325
|
await (0, cdp_1.cdpSend)(page, 'Input.dispatchMouseEvent', {
|
|
250
326
|
type: 'mousePressed',
|
|
@@ -260,41 +336,167 @@ async function fillFieldVerified(page, control, value) {
|
|
|
260
336
|
button: 'left',
|
|
261
337
|
clickCount: 1,
|
|
262
338
|
});
|
|
263
|
-
await (0, cdp_1.sleep)(80);
|
|
264
|
-
// Focus the element directly too (a click can land on a wrapping label).
|
|
265
|
-
await (0, cdp_1.evaluateJs)(page, `(() => { const el = document.elementFromPoint(${control.x}, ${control.y}); const f = el && (el.closest ? el.closest('input,textarea') : null); if (f) { try { f.focus(); } catch (e) {} } return !!f; })()`);
|
|
266
|
-
await selectActive(page);
|
|
267
|
-
await (0, cdp_1.cdpSend)(page, 'Input.insertText', { text: value });
|
|
268
339
|
await (0, cdp_1.sleep)(60);
|
|
269
|
-
|
|
270
|
-
if (
|
|
340
|
+
const role = control.role;
|
|
341
|
+
if (!role)
|
|
271
342
|
return true;
|
|
272
|
-
|
|
343
|
+
const ok = await (0, cdp_1.evaluateJs)(page, `(() => { const el = document.querySelector('[data-ts-login="${role}"]'); if (!el) return false; try { el.focus(); } catch (e) {} return true; })()`);
|
|
344
|
+
return ok === true;
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Clear a login control and VERIFY it is empty. Tries selection+Backspace
|
|
352
|
+
* first, then the native value setter, up to 3 times - because Chrome/site
|
|
353
|
+
* autofill can re-populate a field right after it is cleared.
|
|
354
|
+
*/
|
|
355
|
+
async function clearControl(page, control) {
|
|
356
|
+
const role = control.role;
|
|
357
|
+
if (!role || role === 'submit')
|
|
358
|
+
return false;
|
|
359
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
360
|
+
await focusControl(page, control);
|
|
273
361
|
await selectActive(page);
|
|
274
|
-
await (
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
await (
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
362
|
+
await pressBackspace(page);
|
|
363
|
+
await (0, cdp_1.sleep)(50);
|
|
364
|
+
let current = await controlValue(page, control);
|
|
365
|
+
if (current === '')
|
|
366
|
+
return true;
|
|
367
|
+
await setNativeControlValue(page, role, '');
|
|
368
|
+
await (0, cdp_1.sleep)(50);
|
|
369
|
+
current = await controlValue(page, control);
|
|
370
|
+
if (current === '')
|
|
371
|
+
return true;
|
|
372
|
+
await (0, cdp_1.sleep)(80);
|
|
373
|
+
}
|
|
374
|
+
return (await controlValue(page, control)) === '';
|
|
375
|
+
}
|
|
376
|
+
/** Clear the currently focused element (generic type path). */
|
|
377
|
+
async function clearActiveField(page) {
|
|
378
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
379
|
+
await selectActive(page);
|
|
380
|
+
await pressBackspace(page);
|
|
288
381
|
await (0, cdp_1.sleep)(40);
|
|
289
|
-
await
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
382
|
+
let current = await activeValue(page);
|
|
383
|
+
if (current === '')
|
|
384
|
+
return true;
|
|
385
|
+
if (current === null) {
|
|
386
|
+
// Contenteditable: the selection path above may have selected it; one
|
|
387
|
+
// more Backspace removes the selection.
|
|
388
|
+
await pressBackspace(page);
|
|
389
|
+
await (0, cdp_1.sleep)(40);
|
|
390
|
+
if ((await activeValue(page)) === null)
|
|
391
|
+
return true;
|
|
392
|
+
}
|
|
393
|
+
try {
|
|
394
|
+
await (0, cdp_1.evaluateJs)(page, `(() => {
|
|
395
|
+
const a = document.activeElement;
|
|
396
|
+
if (!a || typeof a.value !== 'string') return false;
|
|
397
|
+
const proto = a.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
398
|
+
const desc = Object.getOwnPropertyDescriptor(proto, 'value');
|
|
399
|
+
if (!desc || !desc.set) return false;
|
|
400
|
+
desc.set.call(a, '');
|
|
401
|
+
a.dispatchEvent(new Event('input', { bubbles: true }));
|
|
402
|
+
a.dispatchEvent(new Event('change', { bubbles: true }));
|
|
403
|
+
return true;
|
|
404
|
+
})()`);
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
/* best-effort */
|
|
408
|
+
}
|
|
409
|
+
await (0, cdp_1.sleep)(40);
|
|
410
|
+
current = await activeValue(page);
|
|
411
|
+
if (current === '' || current === null)
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Type text into the currently focused control and VERIFY it landed.
|
|
418
|
+
* With `clearFirst`, the field is emptied and verified empty BEFORE the insert.
|
|
419
|
+
* Success is an EXACT value match (never a length comparison).
|
|
420
|
+
*/
|
|
421
|
+
async function typeTextVerified(page, text, opts) {
|
|
422
|
+
const before = await activeValue(page);
|
|
423
|
+
const clearFirst = opts?.clearFirst === true;
|
|
424
|
+
if (clearFirst && !(await clearActiveField(page)))
|
|
425
|
+
return false;
|
|
426
|
+
try {
|
|
427
|
+
await (0, cdp_1.cdpSend)(page, 'Input.insertText', { text });
|
|
293
428
|
}
|
|
294
429
|
catch {
|
|
430
|
+
/* fall through to key events */
|
|
431
|
+
}
|
|
432
|
+
await (0, cdp_1.sleep)(80);
|
|
433
|
+
let after = await activeValue(page);
|
|
434
|
+
if (after === null)
|
|
435
|
+
return true; // contenteditable: no readable value
|
|
436
|
+
const expected = clearFirst ? text : `${before ?? ''}${text}`;
|
|
437
|
+
if (after === expected)
|
|
438
|
+
return true;
|
|
439
|
+
if (clearFirst && after === text)
|
|
440
|
+
return true;
|
|
441
|
+
// Generic `type` may insert at a cursor in the middle of an existing value:
|
|
442
|
+
// accept a value that grew by at least the typed text (the old semantics).
|
|
443
|
+
if (!clearFirst && after.length >= (before?.length ?? 0) + text.length) {
|
|
444
|
+
return true;
|
|
445
|
+
}
|
|
446
|
+
// Fallback: clear, then key-event each character.
|
|
447
|
+
if (!(await clearActiveField(page)))
|
|
448
|
+
return false;
|
|
449
|
+
await typeCharByChar(page, text);
|
|
450
|
+
await (0, cdp_1.sleep)(100);
|
|
451
|
+
after = await activeValue(page);
|
|
452
|
+
return after === text || after === null;
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Fill a specific login control with a value and verify it EXACTLY.
|
|
456
|
+
*
|
|
457
|
+
* ALWAYS clears first (the old version only selected), verifies the field is
|
|
458
|
+
* empty, inserts, and compares the observed value to the expected one. If the
|
|
459
|
+
* site/autofill fights the insert, it retries with key events and finally with
|
|
460
|
+
* the native value setter. Returns false when the value could not be made to
|
|
461
|
+
* stick.
|
|
462
|
+
*/
|
|
463
|
+
async function fillFieldVerified(page, control, value) {
|
|
464
|
+
if (!control.role || control.role === 'submit')
|
|
295
465
|
return false;
|
|
466
|
+
// 1. Read (for diagnostics only), clear, verify EMPTY.
|
|
467
|
+
if (!(await clearControl(page, control)))
|
|
468
|
+
return false;
|
|
469
|
+
// 2. Native insert (fires the real input event React observes).
|
|
470
|
+
await focusControl(page, control);
|
|
471
|
+
try {
|
|
472
|
+
await (0, cdp_1.cdpSend)(page, 'Input.insertText', { text: value });
|
|
296
473
|
}
|
|
474
|
+
catch {
|
|
475
|
+
/* fall through */
|
|
476
|
+
}
|
|
477
|
+
await (0, cdp_1.sleep)(80);
|
|
478
|
+
let current = await controlValue(page, control);
|
|
479
|
+
if (current === value)
|
|
480
|
+
return true;
|
|
481
|
+
// 3. Clear + per-character key events.
|
|
482
|
+
if (!(await clearControl(page, control)))
|
|
483
|
+
return false;
|
|
484
|
+
await focusControl(page, control);
|
|
485
|
+
await typeCharByChar(page, value);
|
|
486
|
+
await (0, cdp_1.sleep)(100);
|
|
487
|
+
current = await controlValue(page, control);
|
|
488
|
+
if (current === value)
|
|
489
|
+
return true;
|
|
490
|
+
// 4. Last resort: native setter + input/change (controlled components that
|
|
491
|
+
// reject synthetic key events still accept this).
|
|
492
|
+
if (!(await clearControl(page, control)))
|
|
493
|
+
return false;
|
|
494
|
+
await setNativeControlValue(page, control.role, value);
|
|
495
|
+
await (0, cdp_1.sleep)(80);
|
|
496
|
+
current = await controlValue(page, control);
|
|
497
|
+
return current === value;
|
|
297
498
|
}
|
|
499
|
+
/** Click a tagged control by its coordinates. */
|
|
298
500
|
async function clickControl(page, control) {
|
|
299
501
|
if (!control)
|
|
300
502
|
return false;
|
|
@@ -336,125 +538,430 @@ async function pressEnter(page) {
|
|
|
336
538
|
nativeVirtualKeyCode: 13,
|
|
337
539
|
});
|
|
338
540
|
}
|
|
339
|
-
/**
|
|
340
|
-
|
|
341
|
-
|
|
541
|
+
/**
|
|
542
|
+
* Read a visible validation/credential error. Scoped to the top modal when one
|
|
543
|
+
* is open, so the page's unrelated "error" banner is not mistaken for a login
|
|
544
|
+
* rejection. Never returns a field value.
|
|
545
|
+
*/
|
|
546
|
+
async function visibleLoginError(page) {
|
|
547
|
+
try {
|
|
548
|
+
const value = await (0, cdp_1.evaluateJs)(page, `(() => {
|
|
549
|
+
const vis = (el) => {
|
|
550
|
+
if (!el) return false;
|
|
551
|
+
const r = el.getBoundingClientRect();
|
|
552
|
+
if (r.width === 0 || r.height === 0) return false;
|
|
553
|
+
const cs = getComputedStyle(el);
|
|
554
|
+
return cs.visibility !== 'hidden' && cs.display !== 'none';
|
|
555
|
+
};
|
|
556
|
+
const dialogs = [...document.querySelectorAll('dialog[open],[role="dialog"],[aria-modal="true"]')].filter(vis);
|
|
557
|
+
const scope = dialogs.length ? dialogs[dialogs.length - 1] : document;
|
|
558
|
+
const out = [];
|
|
559
|
+
const add = (t) => {
|
|
560
|
+
const text = String(t == null ? '' : t).replace(/\\s+/g, ' ').trim().slice(0, 240);
|
|
561
|
+
if (text.length < 3) return;
|
|
562
|
+
if (/^(error|errors|warning|warnings)$/i.test(text)) return;
|
|
563
|
+
if (!out.includes(text) && out.length < 3) out.push(text);
|
|
564
|
+
};
|
|
565
|
+
for (const el of [...scope.querySelectorAll('[role="alert"],[aria-invalid="true"]')].filter(vis).slice(0, 4)) {
|
|
566
|
+
add(el.innerText || el.textContent || el.getAttribute('aria-label') || '');
|
|
567
|
+
}
|
|
568
|
+
const sels = '[class*="field-error"],[class*="form-error"],[class*="validation-error"],[class*="error-message"],[class*="invalid-feedback"],[class*="error"],[class*="danger"]';
|
|
569
|
+
for (const el of [...scope.querySelectorAll(sels)].filter(vis).slice(0, 12)) {
|
|
570
|
+
add(el.innerText || el.textContent || '');
|
|
571
|
+
}
|
|
572
|
+
return out.join(' | ');
|
|
573
|
+
})()`);
|
|
574
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
575
|
+
}
|
|
576
|
+
catch {
|
|
577
|
+
return null;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
/** Poll for the next login step after a submit. */
|
|
581
|
+
async function waitForLoginChange(page, opts) {
|
|
582
|
+
const deadline = Date.now() + opts.timeoutMs;
|
|
583
|
+
let startUrl = '';
|
|
584
|
+
try {
|
|
585
|
+
startUrl = String(await (0, cdp_1.evaluateJs)(page, 'location.href'));
|
|
586
|
+
}
|
|
587
|
+
catch {
|
|
588
|
+
startUrl = '';
|
|
589
|
+
}
|
|
590
|
+
let last = await findLoginFields(page);
|
|
591
|
+
while (Date.now() < deadline) {
|
|
592
|
+
await (0, cdp_1.sleep)(250);
|
|
593
|
+
const fields = await findLoginFields(page);
|
|
594
|
+
let url = '';
|
|
595
|
+
try {
|
|
596
|
+
url = String(await (0, cdp_1.evaluateJs)(page, 'location.href'));
|
|
597
|
+
}
|
|
598
|
+
catch {
|
|
599
|
+
url = '';
|
|
600
|
+
}
|
|
601
|
+
// The dialog that held the form closed: the sign-in was accepted (or the
|
|
602
|
+
// modal was dismissed). Do not fall through to a footer email field.
|
|
603
|
+
if (opts.wasModal && !fields.modal && !fields.password && !fields.otp) {
|
|
604
|
+
return { fields, closed: true };
|
|
605
|
+
}
|
|
606
|
+
if (url &&
|
|
607
|
+
startUrl &&
|
|
608
|
+
url !== startUrl &&
|
|
609
|
+
!fields.password &&
|
|
610
|
+
!fields.otp) {
|
|
611
|
+
return { fields, closed: true };
|
|
612
|
+
}
|
|
613
|
+
if (fields.password || fields.otp)
|
|
614
|
+
return { fields, closed: false };
|
|
615
|
+
if (fields.username && !last.username)
|
|
616
|
+
return { fields, closed: false };
|
|
617
|
+
if (!fields.username && !fields.password && !fields.otp) {
|
|
618
|
+
return { fields, closed: true };
|
|
619
|
+
}
|
|
620
|
+
last = fields;
|
|
621
|
+
}
|
|
622
|
+
return { fields: last, closed: false };
|
|
623
|
+
}
|
|
624
|
+
/** Fill a role, re-scanning once if the modal re-rendered during the clear. */
|
|
625
|
+
async function fillRole(page, role, value) {
|
|
342
626
|
let fields = await findLoginFields(page);
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
627
|
+
let control = fields[role];
|
|
628
|
+
if (!control)
|
|
629
|
+
return false;
|
|
630
|
+
if (await fillFieldVerified(page, control, value))
|
|
631
|
+
return true;
|
|
632
|
+
// A controlled modal can replace the input while clearControl is running;
|
|
633
|
+
// the tagged element then no longer exists. Re-scan and try the new one.
|
|
634
|
+
fields = await findLoginFields(page);
|
|
635
|
+
control = fields[role];
|
|
636
|
+
return control ? await fillFieldVerified(page, control, value) : false;
|
|
637
|
+
}
|
|
638
|
+
/** Submit the current step: click its tagged control, else Enter in a field. */
|
|
639
|
+
async function submitLogin(page) {
|
|
640
|
+
// Re-scan before clicking: the control coordinates may be stale after a
|
|
641
|
+
// React re-render triggered by the value change.
|
|
642
|
+
const fields = await findLoginFields(page);
|
|
643
|
+
if (fields.submit) {
|
|
644
|
+
await clickControl(page, fields.submit);
|
|
645
|
+
return true;
|
|
346
646
|
}
|
|
347
|
-
|
|
647
|
+
const target = fields.password ?? fields.username ?? fields.otp;
|
|
648
|
+
if (target) {
|
|
649
|
+
await focusControl(page, target);
|
|
650
|
+
await pressEnter(page);
|
|
651
|
+
return true;
|
|
652
|
+
}
|
|
653
|
+
return false;
|
|
348
654
|
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
* handles the common two-step (email -> Continue -> password) flow. Returns a
|
|
352
|
-
* structured result; it NEVER throws and NEVER logs a secret.
|
|
353
|
-
*/
|
|
354
|
-
async function performLogin(page, opts) {
|
|
355
|
-
const submit = opts.submit !== false;
|
|
356
|
-
const stepTimeoutMs = opts.stepTimeoutMs ?? 6000;
|
|
357
|
-
const maxSteps = opts.maxSteps ?? 2;
|
|
358
|
-
const result = {
|
|
655
|
+
function baseResult() {
|
|
656
|
+
return {
|
|
359
657
|
ok: false,
|
|
360
658
|
usernameFilled: false,
|
|
361
659
|
passwordFilled: false,
|
|
362
660
|
otpFilled: false,
|
|
363
661
|
submitted: false,
|
|
364
662
|
twoStep: false,
|
|
663
|
+
phase: 'unknown',
|
|
664
|
+
mfaRequired: false,
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
function mfaResult(result, detail) {
|
|
668
|
+
return {
|
|
669
|
+
...result,
|
|
670
|
+
ok: false,
|
|
671
|
+
phase: 'otp',
|
|
672
|
+
mfaRequired: true,
|
|
673
|
+
error: 'MFA_REQUIRED',
|
|
674
|
+
detail,
|
|
365
675
|
};
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Deterministic login. Clears and fills every credential field, submits, and
|
|
679
|
+
* follows the real multi-step flow (including a modal):
|
|
680
|
+
*
|
|
681
|
+
* email → password
|
|
682
|
+
* email → OTP
|
|
683
|
+
* email → password → OTP
|
|
684
|
+
* password → OTP
|
|
685
|
+
* OTP only
|
|
686
|
+
*
|
|
687
|
+
* It NEVER reports ok while an OTP field is visible. It returns the visible
|
|
688
|
+
* site error on a rejection and a `phase` on every outcome, so the agent knows
|
|
689
|
+
* where it stopped instead of improvising with raw `evaluate`.
|
|
690
|
+
*/
|
|
691
|
+
async function performLogin(page, opts) {
|
|
692
|
+
const submit = opts.submit !== false;
|
|
693
|
+
const stepTimeoutMs = opts.stepTimeoutMs ?? 6000;
|
|
694
|
+
const maxSteps = opts.maxSteps ?? 4;
|
|
695
|
+
const result = baseResult();
|
|
366
696
|
let fields = await findLoginFields(page);
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
697
|
+
if (!fields.username && !fields.password && !fields.otp) {
|
|
698
|
+
const err = await visibleLoginError(page);
|
|
699
|
+
return {
|
|
700
|
+
...result,
|
|
701
|
+
error: 'LOGIN_FIELD_NOT_FOUND',
|
|
702
|
+
...(err ? { visibleError: err } : {}),
|
|
703
|
+
detail: 'No username, password or one-time-code field was found on this page (including any open modal).',
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
for (let step = 0; step < maxSteps; step++) {
|
|
707
|
+
const visibleError = await visibleLoginError(page);
|
|
708
|
+
if (visibleError) {
|
|
709
|
+
return {
|
|
710
|
+
...result,
|
|
711
|
+
error: 'LOGIN_REJECTED',
|
|
712
|
+
visibleError,
|
|
713
|
+
detail: `The site rejected the sign-in: ${visibleError}`,
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
fields = await findLoginFields(page);
|
|
717
|
+
// ── OTP-only challenge ────────────────────────────────────────────────
|
|
718
|
+
if (fields.otp && !fields.password && !fields.username) {
|
|
719
|
+
result.phase = 'otp';
|
|
720
|
+
result.mfaRequired = true;
|
|
721
|
+
if (!opts.otp) {
|
|
722
|
+
return mfaResult(result, 'A one-time-code field is on screen. Read the code with wait_for_qa_email (fresh/reuse) or ask the human with ask_human, then END YOUR TURN; call login again with {otp} after the resume.');
|
|
723
|
+
}
|
|
724
|
+
const filled = await fillRole(page, 'otp', opts.otp);
|
|
725
|
+
if (!filled) {
|
|
373
726
|
return {
|
|
374
727
|
...result,
|
|
375
728
|
error: 'CREDENTIAL_FILL_FAILED',
|
|
729
|
+
phase: 'otp',
|
|
376
730
|
detail: 'The one-time-code field did not accept the value.',
|
|
377
731
|
};
|
|
378
732
|
}
|
|
379
|
-
result.
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
733
|
+
result.otpFilled = true;
|
|
734
|
+
result.mfaRequired = false;
|
|
735
|
+
if (!submit) {
|
|
736
|
+
return {
|
|
737
|
+
...result,
|
|
738
|
+
ok: true,
|
|
739
|
+
phase: 'otp',
|
|
740
|
+
detail: 'Code filled; submit was disabled.',
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
const submitted = await submitLogin(page);
|
|
744
|
+
result.submitted = submitted;
|
|
745
|
+
if (!submitted) {
|
|
746
|
+
return {
|
|
747
|
+
...result,
|
|
748
|
+
error: 'LOGIN_FIELD_NOT_FOUND',
|
|
749
|
+
detail: 'No code submit control was found.',
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
const next = await waitForLoginChange(page, {
|
|
753
|
+
timeoutMs: stepTimeoutMs,
|
|
754
|
+
wasModal: fields.modal,
|
|
755
|
+
});
|
|
756
|
+
const nextError = await visibleLoginError(page);
|
|
757
|
+
const advancedOtp = next.closed ||
|
|
758
|
+
(!next.fields.otp && !next.fields.password && !next.fields.username);
|
|
759
|
+
if (nextError && !advancedOtp) {
|
|
760
|
+
return {
|
|
761
|
+
...result,
|
|
762
|
+
error: 'LOGIN_REJECTED',
|
|
763
|
+
phase: 'otp',
|
|
764
|
+
visibleError: nextError,
|
|
765
|
+
detail: `The site rejected the code: ${nextError}`,
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
if (next.fields.otp) {
|
|
769
|
+
return mfaResult(result, 'Another one-time-code challenge is on screen. Get the new code and call login again with {otp}.');
|
|
383
770
|
}
|
|
384
|
-
|
|
385
|
-
|
|
771
|
+
return {
|
|
772
|
+
...result,
|
|
773
|
+
ok: true,
|
|
774
|
+
phase: 'submitted',
|
|
775
|
+
detail: 'Code submitted. Verify the destination page.',
|
|
776
|
+
};
|
|
386
777
|
}
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
for (let step = 1; step <= maxSteps; step++) {
|
|
403
|
-
// Fill username on the first step that offers it.
|
|
404
|
-
if (opts.username && fields.username && !result.usernameFilled) {
|
|
405
|
-
result.usernameFilled = await fillFieldVerified(page, fields.username, opts.username);
|
|
778
|
+
// ── Username / email step ─────────────────────────────────────────────
|
|
779
|
+
if (fields.username && opts.username && !result.usernameFilled) {
|
|
780
|
+
const filled = await fillRole(page, 'username', opts.username);
|
|
781
|
+
if (!filled) {
|
|
782
|
+
const err = await visibleLoginError(page);
|
|
783
|
+
return {
|
|
784
|
+
...result,
|
|
785
|
+
error: 'CREDENTIAL_FILL_FAILED',
|
|
786
|
+
phase: 'username',
|
|
787
|
+
...(err ? { visibleError: err } : {}),
|
|
788
|
+
detail: 'The username/email field did not accept the value (it may be autofilled, readonly or a custom widget). The field was cleared and retyped.',
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
result.usernameFilled = true;
|
|
792
|
+
result.phase = 'username';
|
|
406
793
|
}
|
|
407
|
-
// Password
|
|
794
|
+
// ── Password step ─────────────────────────────────────────────────────
|
|
408
795
|
if (fields.password) {
|
|
409
|
-
|
|
410
|
-
|
|
796
|
+
if (!opts.password) {
|
|
797
|
+
return {
|
|
798
|
+
...result,
|
|
799
|
+
error: 'CREDENTIAL_FILL_FAILED',
|
|
800
|
+
phase: 'password',
|
|
801
|
+
detail: 'A password field is on screen but no password was supplied. Pass password, or use the email/OTP-only flow when the site sends a code.',
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
const filled = await fillRole(page, 'password', opts.password);
|
|
805
|
+
if (!filled) {
|
|
806
|
+
const err = await visibleLoginError(page);
|
|
411
807
|
return {
|
|
412
808
|
...result,
|
|
413
809
|
error: 'CREDENTIAL_FILL_FAILED',
|
|
414
|
-
|
|
810
|
+
phase: 'password',
|
|
811
|
+
...(err ? { visibleError: err } : {}),
|
|
812
|
+
detail: 'The password field did not accept the value. It was cleared and retyped, but the value did not stick - usually an autofilled field or a custom widget.',
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
result.passwordFilled = true;
|
|
816
|
+
result.phase = 'password';
|
|
817
|
+
if (!submit) {
|
|
818
|
+
return {
|
|
819
|
+
...result,
|
|
820
|
+
ok: true,
|
|
821
|
+
phase: 'password',
|
|
822
|
+
detail: 'Credentials filled; submit was disabled.',
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
const submitted = await submitLogin(page);
|
|
826
|
+
result.submitted = submitted;
|
|
827
|
+
if (!submitted) {
|
|
828
|
+
return {
|
|
829
|
+
...result,
|
|
830
|
+
error: 'LOGIN_FIELD_NOT_FOUND',
|
|
831
|
+
detail: 'No submit/sign-in control was found.',
|
|
415
832
|
};
|
|
416
833
|
}
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
834
|
+
const next = await waitForLoginChange(page, {
|
|
835
|
+
timeoutMs: stepTimeoutMs,
|
|
836
|
+
wasModal: fields.modal,
|
|
837
|
+
});
|
|
838
|
+
const nextError = await visibleLoginError(page);
|
|
839
|
+
const advancedPassword = next.closed ||
|
|
840
|
+
!!next.fields.otp ||
|
|
841
|
+
(!next.fields.password && !next.fields.username);
|
|
842
|
+
if (nextError && !advancedPassword) {
|
|
843
|
+
return {
|
|
844
|
+
...result,
|
|
845
|
+
error: 'LOGIN_REJECTED',
|
|
846
|
+
phase: 'password',
|
|
847
|
+
visibleError: nextError,
|
|
848
|
+
detail: `The site rejected the sign-in: ${nextError}`,
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
if (next.fields.otp) {
|
|
852
|
+
if (!opts.otp) {
|
|
853
|
+
return mfaResult(result, 'The password was submitted and a one-time-code challenge appeared. Read the code with wait_for_qa_email (fresh/reuse) or ask the human with ask_human, then END YOUR TURN; call login again with {otp} after the resume.');
|
|
422
854
|
}
|
|
855
|
+
fields = next.fields;
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
if (next.fields.password || next.fields.username) {
|
|
859
|
+
return {
|
|
860
|
+
...result,
|
|
861
|
+
error: 'LOGIN_REJECTED',
|
|
862
|
+
phase: 'password',
|
|
863
|
+
detail: 'The password step did not advance and no visible error was found. Check the page manually - the site may require a different field or a captcha.',
|
|
864
|
+
};
|
|
423
865
|
}
|
|
424
|
-
result.ok = true;
|
|
425
|
-
return result;
|
|
426
|
-
}
|
|
427
|
-
// No password yet: this is a multi-step form. Submit step 1 and wait.
|
|
428
|
-
result.twoStep = true;
|
|
429
|
-
if (!submit) {
|
|
430
866
|
return {
|
|
431
867
|
...result,
|
|
432
|
-
|
|
433
|
-
|
|
868
|
+
ok: true,
|
|
869
|
+
phase: 'submitted',
|
|
870
|
+
detail: 'Submitted. Verify the destination page (the sign-in dialog closed or the URL changed).',
|
|
434
871
|
};
|
|
435
872
|
}
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
873
|
+
// ── Email-only step: submit and wait for password/OTP ─────────────────
|
|
874
|
+
if (fields.username && !fields.password) {
|
|
875
|
+
if (!submit) {
|
|
876
|
+
return {
|
|
877
|
+
...result,
|
|
878
|
+
ok: true,
|
|
879
|
+
phase: 'username',
|
|
880
|
+
detail: 'Email filled; submit was disabled.',
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
const submitted = await submitLogin(page);
|
|
884
|
+
result.submitted = submitted;
|
|
885
|
+
if (!submitted) {
|
|
441
886
|
return {
|
|
442
887
|
...result,
|
|
443
|
-
error: '
|
|
444
|
-
detail: '
|
|
888
|
+
error: 'LOGIN_FIELD_NOT_FOUND',
|
|
889
|
+
detail: 'No continue/submit control was found.',
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
result.twoStep = true;
|
|
893
|
+
const next = await waitForLoginChange(page, {
|
|
894
|
+
timeoutMs: stepTimeoutMs,
|
|
895
|
+
wasModal: fields.modal,
|
|
896
|
+
});
|
|
897
|
+
const nextError = await visibleLoginError(page);
|
|
898
|
+
const advancedEmail = next.closed ||
|
|
899
|
+
!!next.fields.password ||
|
|
900
|
+
!!next.fields.otp ||
|
|
901
|
+
(!!next.fields.username &&
|
|
902
|
+
!!fields.username &&
|
|
903
|
+
next.fields.username.hint !== fields.username.hint) ||
|
|
904
|
+
(!next.fields.username && !next.fields.password && !next.fields.otp);
|
|
905
|
+
if (nextError && !advancedEmail) {
|
|
906
|
+
return {
|
|
907
|
+
...result,
|
|
908
|
+
error: 'LOGIN_REJECTED',
|
|
909
|
+
phase: 'username',
|
|
910
|
+
visibleError: nextError,
|
|
911
|
+
detail: `The site rejected the email: ${nextError}`,
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
if (next.fields.username &&
|
|
915
|
+
fields.username &&
|
|
916
|
+
next.fields.username.hint === fields.username.hint &&
|
|
917
|
+
!next.fields.password &&
|
|
918
|
+
!next.fields.otp) {
|
|
919
|
+
// Submitting the email again would double-submit a form that simply did
|
|
920
|
+
// not advance (captcha, disabled button, slow validation).
|
|
921
|
+
return {
|
|
922
|
+
...result,
|
|
923
|
+
error: 'LOGIN_REJECTED',
|
|
924
|
+
phase: 'username',
|
|
925
|
+
detail: 'The email step did not advance and no visible error was found. The site may require a captcha, a different Continue control, or a moment longer - take a snapshot and check before retrying.',
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
if (!next.fields.username && !next.fields.password && !next.fields.otp) {
|
|
929
|
+
return {
|
|
930
|
+
...result,
|
|
931
|
+
ok: true,
|
|
932
|
+
phase: 'submitted',
|
|
933
|
+
detail: 'Submitted. Verify the destination page; if a code field appears, call login again with {otp}.',
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
fields = next.fields;
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
939
|
+
// ── Nothing editable left ─────────────────────────────────────────────
|
|
940
|
+
if (!fields.username && !fields.password && !fields.otp) {
|
|
941
|
+
if (result.submitted) {
|
|
942
|
+
return {
|
|
943
|
+
...result,
|
|
944
|
+
ok: true,
|
|
945
|
+
phase: 'submitted',
|
|
946
|
+
detail: 'Submitted. Verify the destination page.',
|
|
445
947
|
};
|
|
446
948
|
}
|
|
447
949
|
return {
|
|
448
950
|
...result,
|
|
449
951
|
error: 'LOGIN_FIELD_NOT_FOUND',
|
|
450
|
-
detail: '
|
|
952
|
+
detail: 'No editable login field was found.',
|
|
451
953
|
};
|
|
452
954
|
}
|
|
955
|
+
// An OTP is visible together with a credential field and no code was
|
|
956
|
+
// supplied - do not loop; the human/code is required next.
|
|
957
|
+
if (fields.otp && !opts.otp) {
|
|
958
|
+
return mfaResult(result, 'A one-time-code field is on screen. Read the code with wait_for_qa_email (fresh/reuse) or ask the human with ask_human, then END YOUR TURN; call login again with {otp} after the resume.');
|
|
959
|
+
}
|
|
453
960
|
}
|
|
454
961
|
return {
|
|
455
962
|
...result,
|
|
456
963
|
error: 'LOGIN_FIELD_NOT_FOUND',
|
|
457
|
-
detail: 'Login did not
|
|
964
|
+
detail: 'Login did not settle within the step budget. Take a snapshot and inspect the dialog/page before retrying.',
|
|
458
965
|
};
|
|
459
966
|
}
|
|
460
967
|
/** True when the currently focused element is a password/credential control. */
|