scenescout 3.14.1 → 3.16.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/CHANGELOG.md +94 -0
- package/README.md +90 -18
- package/dist/browsers.js +28 -0
- package/dist/check-run.js +211 -24
- package/dist/ci-run.js +374 -62
- package/dist/cli.js +221 -47
- package/dist/commands.js +42 -4
- package/dist/engine/baseline.js +377 -0
- package/dist/engine/brief.js +16 -7
- package/dist/engine/browser.js +1660 -386
- package/dist/engine/calibration.js +61 -30
- package/dist/engine/capture.js +164 -0
- package/dist/engine/check.js +258 -68
- package/dist/engine/ci-lanes.js +215 -0
- package/dist/engine/ci.js +233 -18
- package/dist/engine/claims.js +234 -12
- package/dist/engine/collector.js +851 -78
- package/dist/engine/crawl.js +49 -0
- package/dist/engine/dedup.js +329 -9
- package/dist/engine/design.js +355 -43
- package/dist/engine/export.js +877 -0
- package/dist/engine/fingerprint.js +92 -4
- package/dist/engine/flow.js +18 -6
- package/dist/engine/forms.js +198 -18
- package/dist/engine/journey.js +29 -1
- package/dist/engine/lane.js +13 -3
- package/dist/engine/launch.js +45 -6
- package/dist/engine/limits.js +7 -0
- package/dist/engine/live-page.js +49 -2
- package/dist/engine/live.js +4 -1
- package/dist/engine/memory.js +668 -70
- package/dist/engine/open.js +118 -0
- package/dist/engine/oracles.js +167 -2
- package/dist/engine/plain.js +268 -0
- package/dist/engine/png.js +127 -0
- package/dist/engine/policy.js +379 -9
- package/dist/engine/probes.js +3 -2
- package/dist/engine/profiles.js +45 -9
- package/dist/engine/project-folder.js +191 -0
- package/dist/engine/provider.js +4 -3
- package/dist/engine/refresh.js +338 -25
- package/dist/engine/replay.js +63 -10
- package/dist/engine/report.js +248 -40
- package/dist/engine/request.js +317 -23
- package/dist/engine/sarif.js +120 -0
- package/dist/engine/scripted-login.js +342 -62
- package/dist/engine/settle.js +67 -0
- package/dist/engine/signed-in.js +256 -0
- package/dist/engine/tickets.js +671 -0
- package/dist/engine/unload.js +3 -2
- package/dist/export-run.js +633 -0
- package/dist/first-run.js +581 -0
- package/dist/installer.js +267 -3
- package/dist/intake.js +104 -0
- package/dist/login-run.js +386 -71
- package/dist/mcp-server.js +637 -68
- package/dist/playbook.js +5 -0
- package/package.json +6 -3
- package/skills/scenescout/SKILL.md +51 -18
package/dist/login-run.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Runs `scenescout login <url> --role <name
|
|
3
|
-
* URL, lets the person sign in however the app asks
|
|
4
|
-
* manager), and
|
|
5
|
-
* storage state as that role's profile.
|
|
2
|
+
* Runs `scenescout login <url> --role <name>` and scout_login: opens a
|
|
3
|
+
* visible browser at the URL, lets the person sign in however the app asks
|
|
4
|
+
* (SSO, MFA, a password manager), and once they are signed in saves the
|
|
5
|
+
* browser's storage state as that role's profile.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* The window saves by itself once engine/signed-in.ts says the sign-in has
|
|
8
|
+
* finished; Enter in the terminal saves at once, and `--save enter` makes it
|
|
9
|
+
* the only way, as before. Closing the window or Ctrl+C saves nothing: by the
|
|
10
|
+
* time a window is closed its state can no longer be read, and a profile saved
|
|
11
|
+
* by accident half-way through a sign-in would be attached later as if it
|
|
12
|
+
* worked.
|
|
11
13
|
*
|
|
12
14
|
* The rules (role names, where the file goes, its mode, what may be printed)
|
|
13
15
|
* are in engine/profiles.ts; this file only drives the browser and the terminal.
|
|
@@ -20,7 +22,8 @@ import { explicitLimits, isTimeoutMessage, LIMIT_NAMES } from "./engine/limits.j
|
|
|
20
22
|
import { redactRoute } from "./engine/check.js";
|
|
21
23
|
import { describeLifetime, readLifetime } from "./engine/expiry.js";
|
|
22
24
|
import { describeSaved, mergeSessionStorage, withSessionStorage, writeProfile } from "./engine/profiles.js";
|
|
23
|
-
import {
|
|
25
|
+
import { afterTyping, chooseFields, describeStep, fieldIdentity, filledNames, nextStep, otpBoxes, quotable, secondsLeft, splitCode, totp as totpCode, TOTP_MIN_SECONDS_LEFT, urlMatches, } from "./engine/scripted-login.js";
|
|
26
|
+
import { DEFAULT_SAVE_MODE, judgeSignIn, LOOK_EVERY_MS, onApp, startWatch, } from "./engine/signed-in.js";
|
|
24
27
|
/**
|
|
25
28
|
* Read what the profile keeps from a signed-in context: cookies, localStorage
|
|
26
29
|
* and IndexedDB through Playwright's storage state, and sessionStorage, which
|
|
@@ -62,8 +65,13 @@ export async function captureState(context) {
|
|
|
62
65
|
}
|
|
63
66
|
/** How long after the prompt input still counts as typed before it. */
|
|
64
67
|
const EARLY_INPUT_MS = 300;
|
|
65
|
-
/**
|
|
66
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Wait for Enter on the terminal, the input ending or Ctrl+C. With `keepOnEnd`
|
|
70
|
+
* (the window saves by itself), input that ends is not an answer: a client
|
|
71
|
+
* that starts the command with no terminal closes it at once, and the window
|
|
72
|
+
* is still the way to finish.
|
|
73
|
+
*/
|
|
74
|
+
function waitForEnter(keepOnEnd) {
|
|
67
75
|
const rl = readline.createInterface({ input: process.stdin, terminal: false });
|
|
68
76
|
// Lines typed before the prompt (while the browser launched or the page
|
|
69
77
|
// loaded) arrive at once, buffered; they are not an answer to it.
|
|
@@ -74,10 +82,10 @@ function waitForEnd(browserClosed) {
|
|
|
74
82
|
if (Date.now() >= listening)
|
|
75
83
|
resolve("enter");
|
|
76
84
|
});
|
|
77
|
-
|
|
85
|
+
if (!keepOnEnd)
|
|
86
|
+
rl.once("close", () => resolve("input-ended"));
|
|
78
87
|
onSigint = () => resolve("interrupted");
|
|
79
88
|
process.once("SIGINT", onSigint);
|
|
80
|
-
void browserClosed.then(() => resolve("window-closed"));
|
|
81
89
|
});
|
|
82
90
|
return {
|
|
83
91
|
done,
|
|
@@ -90,28 +98,30 @@ function waitForEnd(browserClosed) {
|
|
|
90
98
|
};
|
|
91
99
|
}
|
|
92
100
|
const NOT_SAVED = {
|
|
93
|
-
"window-closed": "The browser was closed before
|
|
94
|
-
"input-ended": "The terminal's input ended before Enter was pressed, so nothing was saved. Run this command in an interactive terminal.",
|
|
101
|
+
"window-closed": "The browser was closed before the sign-in finished, so nothing was saved. Run the command again and sign in in the window it opens.",
|
|
102
|
+
"input-ended": "The terminal's input ended before Enter was pressed, so nothing was saved. Run this command in an interactive terminal, or leave out --save enter so the window saves by itself once you are signed in.",
|
|
95
103
|
interrupted: "Interrupted, so nothing was saved.",
|
|
104
|
+
"timed-out": "The sign-in did not finish in time, so the window was closed and nothing was saved.",
|
|
96
105
|
};
|
|
97
|
-
|
|
106
|
+
/** Open the window a person signs in in. Throws a sentence when the browser cannot start or the page cannot load. */
|
|
107
|
+
export async function openLoginWindow(options, opts = {}) {
|
|
98
108
|
const engine = options.browser ?? defaultEngine(process.env);
|
|
99
109
|
const types = { chromium, firefox, webkit };
|
|
100
110
|
// Checked before a window opens: a limit out of bounds is a sentence, not a browser left behind.
|
|
101
111
|
const navMs = explicitLimits({}, process.env).navMs ?? LOGIN_NAV_MS;
|
|
112
|
+
const headless = opts.headless ?? false;
|
|
102
113
|
let browser;
|
|
103
114
|
try {
|
|
104
|
-
browser = await types[engine].launch({ headless
|
|
115
|
+
browser = await types[engine].launch({ headless });
|
|
105
116
|
}
|
|
106
117
|
catch (err) {
|
|
107
|
-
throw new Error(explainLaunchFailure(err instanceof Error ? err.message : String(err), 0, { engine, headed:
|
|
118
|
+
throw new Error(explainLaunchFailure(err instanceof Error ? err.message : String(err), 0, { engine, headed: !headless }));
|
|
108
119
|
}
|
|
109
|
-
const
|
|
110
|
-
let end;
|
|
120
|
+
const closed = new Promise((resolve) => browser.once("disconnected", () => resolve()));
|
|
111
121
|
try {
|
|
112
122
|
const context = await browser.newContext();
|
|
113
123
|
const page = await context.newPage();
|
|
114
|
-
// Closing the
|
|
124
|
+
// Closing the sign-in tab is how most people close the window; treat it the
|
|
115
125
|
// same. The browser may already be closing, and either way it ends up closed.
|
|
116
126
|
page.once("close", () => void browser.close().catch(() => { }));
|
|
117
127
|
try {
|
|
@@ -121,23 +131,223 @@ export async function runLogin(options, log) {
|
|
|
121
131
|
const explained = loginTimeout(err, "nav", navMs);
|
|
122
132
|
throw new Error(`could not open ${options.url}: ${explained instanceof Error ? explained.message.split("\n")[0] : String(explained)}`);
|
|
123
133
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
134
|
+
return { browser, context, page, engine, closed };
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
// The error being thrown is the one to report; a browser that fails to close as well adds nothing to it.
|
|
138
|
+
await browser.close().catch(() => { });
|
|
139
|
+
throw err;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** Longest storage value read for a look; a longer one is compared by its start. */
|
|
143
|
+
const MAX_HELD_VALUE = 64 * 1024;
|
|
144
|
+
/** The cookies the browser would send to `url` and the top document's storage: what a look compares with the baseline. */
|
|
145
|
+
async function readHeld(context, page, url) {
|
|
146
|
+
const held = (await context.cookies([url])).map((c) => ({
|
|
147
|
+
kind: "cookie",
|
|
148
|
+
key: `cookie|${c.domain}|${c.path}|${c.name}`,
|
|
149
|
+
name: c.name,
|
|
150
|
+
value: c.value,
|
|
151
|
+
}));
|
|
152
|
+
const storage = await page.evaluate((max) => {
|
|
153
|
+
const out = [];
|
|
154
|
+
for (const [kind, store] of [
|
|
155
|
+
["local", localStorage],
|
|
156
|
+
["session", sessionStorage],
|
|
157
|
+
]) {
|
|
158
|
+
for (let i = 0; i < store.length; i++) {
|
|
159
|
+
const name = store.key(i);
|
|
160
|
+
if (name !== null)
|
|
161
|
+
out.push({ kind, name, value: (store.getItem(name) ?? "").slice(0, max) });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return { origin: location.origin, out };
|
|
165
|
+
}, MAX_HELD_VALUE);
|
|
166
|
+
for (const e of storage.out)
|
|
167
|
+
held.push({ kind: e.kind, key: `${e.kind}|${storage.origin}|${e.name}`, name: e.name, value: e.value });
|
|
168
|
+
return held;
|
|
169
|
+
}
|
|
170
|
+
/** A read the page's own navigation interrupted: the look is skipped and taken again. */
|
|
171
|
+
const betweenPages = (err) => /context was destroyed|navigat|detached|target closed|has been closed/i.test(err.message);
|
|
172
|
+
/** One look at the window, or null when the page was between documents and the look should be taken again. */
|
|
173
|
+
async function lookAt(win, watch) {
|
|
174
|
+
const url = win.page.url();
|
|
175
|
+
const popupAway = win.context.pages().some((p) => p !== win.page && !p.isClosed() && /^https?:/i.test(p.url()) && !onApp(watch, p.url()));
|
|
176
|
+
// Off the app nothing is read, except at an absolute success URL, where a sign-in field still says it is not done.
|
|
177
|
+
const atSuccess = watch.successUrl !== undefined && /^https?:\/\//i.test(watch.successUrl) && urlMatches(url, watch.successUrl);
|
|
178
|
+
if (!onApp(watch, url) && !atSuccess)
|
|
179
|
+
return { url, signInField: false, held: [], popupAway };
|
|
180
|
+
try {
|
|
181
|
+
const fields = await win.page.evaluate(collectFields, { selectors: {}, tag: false });
|
|
182
|
+
if (!Array.isArray(fields))
|
|
183
|
+
return null;
|
|
184
|
+
const chosen = chooseFields(fields);
|
|
185
|
+
const held = await readHeld(win.context, win.page, url);
|
|
186
|
+
// The page moved while it was read: what was read belongs to two pages.
|
|
187
|
+
if (win.page.url() !== url)
|
|
188
|
+
return null;
|
|
189
|
+
return { url, signInField: Boolean(chosen.password || chosen.otp), held, popupAway };
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
if (err instanceof Error && betweenPages(err))
|
|
193
|
+
return null;
|
|
194
|
+
throw err;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Watch the window until the person is signed in (engine/signed-in.ts says
|
|
199
|
+
* when), the window closes, or `signal` aborts. Resolves with how it ended;
|
|
200
|
+
* the caller saves and closes.
|
|
201
|
+
*/
|
|
202
|
+
export async function watchForSignIn(win, options, opts = {}) {
|
|
203
|
+
let closed = false;
|
|
204
|
+
void win.closed.then(() => {
|
|
205
|
+
closed = true;
|
|
206
|
+
});
|
|
207
|
+
// The baseline is what the window holds once the first page is there: whatever the app set before anyone signed in.
|
|
208
|
+
const firstUrl = win.page.url();
|
|
209
|
+
let watch = startWatch(options.url, [], options.successUrl, firstUrl);
|
|
210
|
+
const baseline = [];
|
|
211
|
+
try {
|
|
212
|
+
for (const c of await win.context.cookies())
|
|
213
|
+
baseline.push({ kind: "cookie", key: `cookie|${c.domain}|${c.path}|${c.name}`, name: c.name, value: c.value });
|
|
214
|
+
if (onApp(watch, firstUrl)) {
|
|
215
|
+
const first = await readHeld(win.context, win.page, firstUrl).catch((err) => {
|
|
216
|
+
if (betweenPages(err))
|
|
217
|
+
return [];
|
|
218
|
+
throw err;
|
|
219
|
+
});
|
|
220
|
+
baseline.push(...first.filter((h) => h.kind !== "cookie"));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
if (opts.signal?.aborted)
|
|
225
|
+
return { kind: "aborted" };
|
|
226
|
+
if (closed || win.page.isClosed())
|
|
227
|
+
return { kind: "closed" };
|
|
228
|
+
throw err;
|
|
229
|
+
}
|
|
230
|
+
watch = startWatch(options.url, baseline, options.successUrl, firstUrl);
|
|
231
|
+
opts.progress?.({ reason: "starting", url: firstUrl });
|
|
232
|
+
for (;;) {
|
|
233
|
+
if (closed || win.page.isClosed())
|
|
234
|
+
return { kind: "closed" };
|
|
235
|
+
if (opts.signal?.aborted)
|
|
236
|
+
return { kind: "aborted" };
|
|
237
|
+
let look;
|
|
238
|
+
try {
|
|
239
|
+
look = await lookAt(win, watch);
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
// The window closing under a look is the window closing, not an error; nor is a look the caller already stopped
|
|
243
|
+
// (Enter was pressed and the window is being closed to save).
|
|
244
|
+
if (opts.signal?.aborted)
|
|
245
|
+
return { kind: "aborted" };
|
|
246
|
+
if (closed || win.page.isClosed())
|
|
247
|
+
return { kind: "closed" };
|
|
248
|
+
throw err;
|
|
249
|
+
}
|
|
250
|
+
if (look !== null) {
|
|
251
|
+
const judged = judgeSignIn(watch, look);
|
|
252
|
+
watch = judged.watch;
|
|
253
|
+
if (judged.verdict.kind === "signed-in")
|
|
254
|
+
return { kind: "signed-in", verdict: judged.verdict, url: look.url };
|
|
255
|
+
opts.progress?.({ reason: judged.verdict.reason, url: look.url });
|
|
256
|
+
}
|
|
257
|
+
await new Promise((resolve) => setTimeout(resolve, LOOK_EVERY_MS));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/** What the window says once the sign-in is seen: where, and by what. */
|
|
261
|
+
function sayDetected(verdict, url) {
|
|
262
|
+
const how = verdict.via === "success-url" ? "reached the success URL" : `the app holds a new session (${verdict.credential})`;
|
|
263
|
+
return `Signed in: back on ${redactRoute(url)} and ${how}. Saving and closing the window.`;
|
|
264
|
+
}
|
|
265
|
+
export async function runLogin(options, log) {
|
|
266
|
+
const mode = options.save ?? DEFAULT_SAVE_MODE;
|
|
267
|
+
const win = await openLoginWindow(options);
|
|
268
|
+
let enter;
|
|
269
|
+
const stop = new AbortController();
|
|
270
|
+
try {
|
|
271
|
+
log(`A ${win.engine} window is open at ${options.url}.`);
|
|
272
|
+
if (mode === "auto") {
|
|
273
|
+
log(`Sign in as "${options.role}" there — SSO, MFA, whatever the app asks. The window saves and closes by itself once you are signed in.`);
|
|
274
|
+
log(`(To save sooner, press Enter here. Closing the window or pressing Ctrl+C saves nothing.)`);
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
log(`Sign in as "${options.role}" there — SSO, MFA, whatever the app asks — then come back here and press Enter to save.`);
|
|
278
|
+
log(`(Closing the window or pressing Ctrl+C saves nothing.)`);
|
|
279
|
+
}
|
|
127
280
|
// Listening starts only now, after the prompt: an Enter pressed while the
|
|
128
281
|
// page was still loading must not save a signed-out profile.
|
|
129
|
-
|
|
130
|
-
const
|
|
131
|
-
if (
|
|
282
|
+
enter = waitForEnter(mode === "auto");
|
|
283
|
+
const racers = [enter.done, win.closed.then(() => "window-closed")];
|
|
284
|
+
if (mode === "auto") {
|
|
285
|
+
racers.push(watchForSignIn(win, options, { signal: stop.signal }).then((r) => {
|
|
286
|
+
if (r.kind === "signed-in") {
|
|
287
|
+
log(sayDetected(r.verdict, r.url));
|
|
288
|
+
return "signed-in";
|
|
289
|
+
}
|
|
290
|
+
return r.kind === "closed" ? "window-closed" : "interrupted";
|
|
291
|
+
}));
|
|
292
|
+
}
|
|
293
|
+
const how = await Promise.race(racers);
|
|
294
|
+
stop.abort();
|
|
295
|
+
if (how !== "enter" && how !== "signed-in")
|
|
132
296
|
throw new Error(NOT_SAVED[how]);
|
|
133
|
-
return await saveLogin(context, options);
|
|
297
|
+
return await saveLogin(win.context, options);
|
|
134
298
|
}
|
|
135
299
|
finally {
|
|
136
|
-
|
|
300
|
+
stop.abort();
|
|
301
|
+
enter?.dispose();
|
|
137
302
|
// Closing a browser the person already closed fails; nothing is left to clean up then.
|
|
138
|
-
await browser.close().catch(() => { });
|
|
303
|
+
await win.browser.close().catch(() => { });
|
|
139
304
|
}
|
|
140
305
|
}
|
|
306
|
+
/** How long a sign-in window opened from the conversation stays open, at most, before it closes saving nothing. */
|
|
307
|
+
export const LOGIN_WINDOW_MAX_MS = 15 * 60_000;
|
|
308
|
+
/**
|
|
309
|
+
* Open a window for a person to sign in, watch it, and save the profile once
|
|
310
|
+
* they are signed in: what scout_login does. Never waits for Enter; the
|
|
311
|
+
* window closing, `cancel`, or LOGIN_WINDOW_MAX_MS passing saves nothing.
|
|
312
|
+
*/
|
|
313
|
+
export async function startLoginWindow(options, opts = {}) {
|
|
314
|
+
const win = await openLoginWindow(options, opts);
|
|
315
|
+
const stop = new AbortController();
|
|
316
|
+
let last = { reason: "starting", url: options.url };
|
|
317
|
+
const timer = setTimeout(() => stop.abort(), opts.maxMs ?? LOGIN_WINDOW_MAX_MS);
|
|
318
|
+
const done = (async () => {
|
|
319
|
+
try {
|
|
320
|
+
const r = await watchForSignIn(win, options, { signal: stop.signal, progress: (p) => (last = p) });
|
|
321
|
+
if (r.kind === "closed")
|
|
322
|
+
return { ok: false, error: NOT_SAVED["window-closed"] };
|
|
323
|
+
if (r.kind === "aborted")
|
|
324
|
+
return { ok: false, error: NOT_SAVED["timed-out"] };
|
|
325
|
+
const saved = await saveLogin(win.context, options);
|
|
326
|
+
return { ok: true, saved, detected: sayDetected(r.verdict, r.url).replace(/ Saving and closing the window\.$/, "") };
|
|
327
|
+
}
|
|
328
|
+
catch (err) {
|
|
329
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
330
|
+
}
|
|
331
|
+
finally {
|
|
332
|
+
clearTimeout(timer);
|
|
333
|
+
// The outcome is decided; a window the person already closed fails to close again, and nothing is left then.
|
|
334
|
+
await win.browser.close().catch(() => { });
|
|
335
|
+
}
|
|
336
|
+
})();
|
|
337
|
+
return {
|
|
338
|
+
role: options.role,
|
|
339
|
+
url: options.url,
|
|
340
|
+
window: win,
|
|
341
|
+
done,
|
|
342
|
+
progress: () => last,
|
|
343
|
+
cancel: async () => {
|
|
344
|
+
stop.abort();
|
|
345
|
+
// Closed either way: a window already gone fails to close again. `done` settles once the watch sees it.
|
|
346
|
+
await win.browser.close().catch(() => { });
|
|
347
|
+
await done;
|
|
348
|
+
},
|
|
349
|
+
};
|
|
350
|
+
}
|
|
141
351
|
/**
|
|
142
352
|
* Save a signed-in context as the role's profile and read how long it lasts:
|
|
143
353
|
* what `scenescout login` does once Enter is pressed, apart so it can be run
|
|
@@ -179,10 +389,12 @@ const LOGIN_ACTION_MS = 10_000;
|
|
|
179
389
|
const MAX_SUBMITS = 5;
|
|
180
390
|
/**
|
|
181
391
|
* Read every visible form control on the page (the top document only), tag
|
|
182
|
-
* each with its index
|
|
183
|
-
*
|
|
392
|
+
* each with its index when asked (a scripted sign-in acts on them; watching a
|
|
393
|
+
* person sign in only reads them, so leaves the page as it is), and mark those
|
|
394
|
+
* matching a configured selector. Runs in the page, so it may use only what
|
|
395
|
+
* the page has.
|
|
184
396
|
*/
|
|
185
|
-
function collectFields(selectors) {
|
|
397
|
+
function collectFields({ selectors, tag: tagging }) {
|
|
186
398
|
const visible = (el) => {
|
|
187
399
|
const r = el.getBoundingClientRect();
|
|
188
400
|
const st = getComputedStyle(el);
|
|
@@ -220,8 +432,9 @@ function collectFields(selectors) {
|
|
|
220
432
|
return parts.join(" ").replace(/\s+/g, " ").trim();
|
|
221
433
|
};
|
|
222
434
|
// Tags from an earlier read name other elements now: one index, one element.
|
|
223
|
-
|
|
224
|
-
old.
|
|
435
|
+
if (tagging)
|
|
436
|
+
for (const old of Array.from(document.querySelectorAll("[data-scenescout-login]")))
|
|
437
|
+
old.removeAttribute("data-scenescout-login");
|
|
225
438
|
const out = [];
|
|
226
439
|
const all = Array.from(document.querySelectorAll("input, textarea, select, button"));
|
|
227
440
|
for (const el of all) {
|
|
@@ -229,7 +442,8 @@ function collectFields(selectors) {
|
|
|
229
442
|
if (type === "hidden" || !visible(el))
|
|
230
443
|
continue;
|
|
231
444
|
const index = out.length;
|
|
232
|
-
|
|
445
|
+
if (tagging)
|
|
446
|
+
el.setAttribute("data-scenescout-login", String(index));
|
|
233
447
|
const tag = el.tagName.toLowerCase();
|
|
234
448
|
const input = el;
|
|
235
449
|
out.push({
|
|
@@ -250,22 +464,37 @@ function collectFields(selectors) {
|
|
|
250
464
|
}
|
|
251
465
|
return out;
|
|
252
466
|
}
|
|
253
|
-
/**
|
|
467
|
+
/**
|
|
468
|
+
* The error or alert a page shows, if any: the first one on screen with text
|
|
469
|
+
* in it (a hidden, pre-rendered alert says nothing about this run), whole.
|
|
470
|
+
* The caller redacts it before shortening it (quotable).
|
|
471
|
+
*/
|
|
254
472
|
async function alertText(page) {
|
|
255
|
-
|
|
473
|
+
return (page
|
|
256
474
|
.evaluate(() => {
|
|
257
|
-
const el
|
|
258
|
-
|
|
475
|
+
for (const el of Array.from(document.querySelectorAll('[role="alert"], [aria-live="assertive"], .error, .alert'))) {
|
|
476
|
+
const r = el.getBoundingClientRect();
|
|
477
|
+
const st = getComputedStyle(el);
|
|
478
|
+
const text = (el.textContent ?? "").trim();
|
|
479
|
+
if (text && r.width > 0 && r.height > 0 && st.visibility !== "hidden" && st.display !== "none")
|
|
480
|
+
return text;
|
|
481
|
+
}
|
|
482
|
+
return "";
|
|
259
483
|
})
|
|
260
|
-
// The page's message only adds to the
|
|
261
|
-
.catch(() => "");
|
|
262
|
-
return text.slice(0, 200);
|
|
484
|
+
// The page's message only adds to the error being reported; a page navigating away as it is read leaves that error as it stands.
|
|
485
|
+
.catch(() => ""));
|
|
263
486
|
}
|
|
264
487
|
/**
|
|
265
488
|
* Sign in with no one at the keyboard: headless, the credentials from the
|
|
266
489
|
* environment, the form found by engine/scripted-login.ts's rules. Every line
|
|
267
490
|
* it logs and every error it throws goes through the credential redaction
|
|
268
491
|
* first. Saves the profile exactly as the manual login does.
|
|
492
|
+
*
|
|
493
|
+
* Each step fills what the page asks for, then reads the page again until it
|
|
494
|
+
* is clear how to submit (engine/scripted-login.ts's afterTyping): a page that
|
|
495
|
+
* submitted the step itself is not submitted again, a button the page enables
|
|
496
|
+
* only once the form is complete is waited for, and a field the typing
|
|
497
|
+
* revealed is filled first.
|
|
269
498
|
*/
|
|
270
499
|
export async function runScriptedLogin(options, config, redactor, log, now = () => Date.now() / 1000) {
|
|
271
500
|
const redact = (text) => redactor.redact(text);
|
|
@@ -327,10 +556,10 @@ export async function runScriptedLogin(options, config, redactor, log, now = ()
|
|
|
327
556
|
return true;
|
|
328
557
|
};
|
|
329
558
|
const read = async () => {
|
|
330
|
-
const got = await page.evaluate(collectFields, config.selectors).catch((err) => {
|
|
559
|
+
const got = await page.evaluate(collectFields, { selectors: config.selectors, tag: true }).catch((err) => {
|
|
331
560
|
if (midNavigation(err))
|
|
332
561
|
return null;
|
|
333
|
-
throw err;
|
|
562
|
+
throw fail(err.message);
|
|
334
563
|
});
|
|
335
564
|
if (got === null)
|
|
336
565
|
return null;
|
|
@@ -345,19 +574,49 @@ export async function runScriptedLogin(options, config, redactor, log, now = ()
|
|
|
345
574
|
return `${pageKey()}|${Object.keys(chosen).sort().join(",")}`;
|
|
346
575
|
};
|
|
347
576
|
const stepOpts = (successMatchedNow) => ({
|
|
348
|
-
|
|
577
|
+
hasPassword: config.password !== undefined,
|
|
578
|
+
...(config.code ? { code: config.code.kind } : {}),
|
|
349
579
|
successConfigured: Boolean(config.success.url || config.success.selector),
|
|
350
580
|
successMatched: successMatchedNow,
|
|
351
581
|
});
|
|
582
|
+
/** The code to type now: the fixed one, or the TOTP code, waiting for the next one when this one is about to expire. */
|
|
583
|
+
const currentCode = async (source) => {
|
|
584
|
+
if (source.kind === "fixed")
|
|
585
|
+
return source.code;
|
|
586
|
+
const params = source.params;
|
|
587
|
+
if (secondsLeft(params, now()) < TOTP_MIN_SECONDS_LEFT)
|
|
588
|
+
await page.waitForTimeout(secondsLeft(params, now()) * 1000 + 200);
|
|
589
|
+
const code = totpCode(params, now());
|
|
590
|
+
redactor.add(code);
|
|
591
|
+
return code;
|
|
592
|
+
};
|
|
593
|
+
const at = (f) => page.locator(`[${TAG}="${f.index}"]`);
|
|
594
|
+
/** A failed action as this run's error: the time limit explained, redacted like everything else it throws. */
|
|
595
|
+
const actionError = (err) => {
|
|
596
|
+
const explained = loginTimeout(err, "action", limits.actionMs);
|
|
597
|
+
return fail(explained instanceof Error ? explained.message : String(explained));
|
|
598
|
+
};
|
|
599
|
+
const actionFailed = (err) => Promise.reject(actionError(err));
|
|
600
|
+
/** What the page says, fit to quote. */
|
|
601
|
+
const pageSays = async () => quotable(await alertText(page), redact, 200);
|
|
602
|
+
/** A button's text fit to quote, redacted before it is shortened. */
|
|
603
|
+
const buttonText = (f) => quotable(f.text || f.label || "the submit button", redact, 40);
|
|
604
|
+
/** The run's deadline passed: say what it was waiting for, and what the page says, if anything. */
|
|
605
|
+
const timedOut = async (waitingFor) => {
|
|
606
|
+
const says = await pageSays();
|
|
607
|
+
return fail(`timed out after ${config.timeoutMs / 1000}s waiting for ${waitingFor} (at ${where()})${says ? `. The page says: "${says}"` : ""}`);
|
|
608
|
+
};
|
|
609
|
+
const submitOpts = { passwordless: config.password === undefined };
|
|
352
610
|
let lastWait = "the sign-in form to appear";
|
|
353
611
|
for (;;) {
|
|
354
612
|
if (Date.now() > deadline)
|
|
355
|
-
throw
|
|
613
|
+
throw await timedOut(lastWait);
|
|
356
614
|
const fields = await read();
|
|
357
615
|
if (fields === null) {
|
|
358
616
|
await page.waitForTimeout(POLL_MS);
|
|
359
617
|
continue;
|
|
360
618
|
}
|
|
619
|
+
const keyAtRead = pageKey();
|
|
361
620
|
const chosen = chooseFields(fields);
|
|
362
621
|
const step = nextStep(chosen, progress, stepOpts(await successMatched()));
|
|
363
622
|
if (step.kind === "done") {
|
|
@@ -367,7 +626,7 @@ export async function runScriptedLogin(options, config, redactor, log, now = ()
|
|
|
367
626
|
// a page between a redirect and its first render shows no fields either.
|
|
368
627
|
await page.waitForLoadState("load", { timeout: limits.actionMs }).catch((err) => {
|
|
369
628
|
if (!/timeout/i.test(err.message) && !midNavigation(err))
|
|
370
|
-
throw err;
|
|
629
|
+
throw fail(err.message);
|
|
371
630
|
});
|
|
372
631
|
await page.waitForTimeout(POLL_MS * 2);
|
|
373
632
|
const settled = await read();
|
|
@@ -376,48 +635,104 @@ export async function runScriptedLogin(options, config, redactor, log, now = ()
|
|
|
376
635
|
continue;
|
|
377
636
|
}
|
|
378
637
|
if (step.kind === "refused") {
|
|
379
|
-
const says = await
|
|
638
|
+
const says = await pageSays();
|
|
380
639
|
throw fail(`sign-in refused: ${step.reason}.${says ? ` The page says: "${says}"` : ""}`);
|
|
381
640
|
}
|
|
382
641
|
if (step.kind === "stuck")
|
|
383
642
|
throw fail(`sign-in stuck: ${step.reason}.`);
|
|
384
643
|
if (step.kind === "wait") {
|
|
644
|
+
const credentialSent = progress.submitted.has("password") || progress.submitted.has("otp");
|
|
385
645
|
lastWait =
|
|
386
646
|
progress.submits === 0
|
|
387
647
|
? "the sign-in form to appear (set the field selectors if the form is not found)"
|
|
388
|
-
:
|
|
648
|
+
: !credentialSent
|
|
649
|
+
? "the page to ask for the password or the one-time code (set --password-selector or --otp-selector if the field is there but not found)"
|
|
650
|
+
: "the signed-in page (the success URL or selector, or the sign-in fields to go)";
|
|
389
651
|
await page.waitForTimeout(POLL_MS);
|
|
390
652
|
continue;
|
|
391
653
|
}
|
|
392
654
|
if (progress.submits >= MAX_SUBMITS)
|
|
393
655
|
throw fail(`gave up after ${MAX_SUBMITS} submits without reaching the signed-in page`);
|
|
394
|
-
let
|
|
656
|
+
let boxOpts = {};
|
|
657
|
+
// What this step typed, recorded as submitted only once the step is: a step the page holds back (fill-more) is typed again.
|
|
658
|
+
const typedIds = new Map();
|
|
395
659
|
for (const kind of step.fill) {
|
|
396
660
|
const field = chosen[kind];
|
|
397
|
-
|
|
398
|
-
if (
|
|
399
|
-
const
|
|
400
|
-
if (
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
661
|
+
const boxes = kind === "otp" ? otpBoxes(fields, field) : null;
|
|
662
|
+
if (boxes) {
|
|
663
|
+
const split = splitCode(await currentCode(config.code), boxes.length, config.code.kind);
|
|
664
|
+
if (!split.ok)
|
|
665
|
+
throw fail(`sign-in stuck: ${split.error}.`);
|
|
666
|
+
for (const [i, box] of boxes.entries()) {
|
|
667
|
+
try {
|
|
668
|
+
// A box is often enabled only once the one before it is filled.
|
|
669
|
+
await page.waitForFunction(([attr, index]) => {
|
|
670
|
+
const el = document.querySelector(`[${attr}="${index}"]`);
|
|
671
|
+
return el !== null && !el.disabled;
|
|
672
|
+
}, [TAG, String(box.index)], { timeout: limits.actionMs, polling: 50 });
|
|
673
|
+
// Cleared only when it holds something: clearing sends Delete, which some boxes take as a step back to the box before.
|
|
674
|
+
if ((await at(box).inputValue({ timeout: limits.actionMs })) !== "")
|
|
675
|
+
await at(box).fill("", { timeout: limits.actionMs });
|
|
676
|
+
// Typed as keys: a box that moves on to the next by itself, or reads keys rather than its value, still gets its character.
|
|
677
|
+
await at(box).pressSequentially(split.chars[i], { timeout: limits.actionMs });
|
|
678
|
+
}
|
|
679
|
+
catch (err) {
|
|
680
|
+
// The first line only: the call log after it names the character being typed, which no redaction of the whole code catches.
|
|
681
|
+
const explained = loginTimeout(err, "action", limits.actionMs);
|
|
682
|
+
const first = (explained instanceof Error ? explained.message : String(explained)).split("\n")[0];
|
|
683
|
+
throw fail(`could not type into box ${i + 1} of ${boxes.length} of the one-time code: ${first}`);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
boxOpts = { codeBoxes: boxes.length };
|
|
687
|
+
}
|
|
688
|
+
else {
|
|
689
|
+
const value = kind === "otp" ? await currentCode(config.code) : kind === "username" ? config.username : config.password;
|
|
690
|
+
await at(field).fill(value, { timeout: limits.actionMs }).catch(actionFailed);
|
|
404
691
|
}
|
|
405
|
-
|
|
406
|
-
value = kind === "username" ? config.username : config.password;
|
|
407
|
-
await page
|
|
408
|
-
.locator(`[${TAG}="${field.index}"]`)
|
|
409
|
-
.fill(value, { timeout: limits.actionMs })
|
|
410
|
-
.catch((err) => Promise.reject(loginTimeout(err, "action", limits.actionMs)));
|
|
411
|
-
progress.submitted.set(kind, fieldIdentity(field));
|
|
412
|
-
last = field;
|
|
692
|
+
typedIds.set(kind, fieldIdentity(field));
|
|
413
693
|
}
|
|
414
|
-
const button = chooseSubmit(fields);
|
|
415
694
|
const before = signature(fields);
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
695
|
+
const shownBefore = new Set(Object.keys(chosen));
|
|
696
|
+
const readNow = async () => {
|
|
697
|
+
const now = await read();
|
|
698
|
+
return afterTyping(step.fill, { left: now === null || pageKey() !== keyAtRead, fields: now ?? [] }, shownBefore, submitOpts);
|
|
699
|
+
};
|
|
700
|
+
let next;
|
|
701
|
+
for (;;) {
|
|
702
|
+
await page.waitForTimeout(POLL_MS);
|
|
703
|
+
next = await readNow();
|
|
704
|
+
if (next.kind !== "wait")
|
|
705
|
+
break;
|
|
706
|
+
if (Date.now() > deadline) {
|
|
707
|
+
const control = next.button ? `the "${buttonText(next.button)}" button` : "the field just filled";
|
|
708
|
+
throw await timedOut(`${control} to be enabled, or the page to move on, after filling ${filledNames(step.fill, boxOpts)}`);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
if (next.kind === "fill-more") {
|
|
712
|
+
say(describeStep(step.fill, "more", boxOpts));
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
let by = "page";
|
|
716
|
+
if (next.kind === "click" || next.kind === "enter") {
|
|
717
|
+
try {
|
|
718
|
+
if (next.kind === "click")
|
|
719
|
+
await at(next.button).click({ timeout: limits.actionMs });
|
|
720
|
+
else
|
|
721
|
+
await at(next.field).press("Enter", { timeout: limits.actionMs });
|
|
722
|
+
}
|
|
723
|
+
catch (err) {
|
|
724
|
+
// The page moved on while the click waited (it took the step itself, or the click went through as it left): the step is
|
|
725
|
+
// done. Otherwise the click's own error is the one to report, also when reading the page to tell fails.
|
|
726
|
+
const moved = await readNow().then((after) => after.kind === "moved", () => false);
|
|
727
|
+
if (!moved)
|
|
728
|
+
throw actionError(err);
|
|
729
|
+
}
|
|
730
|
+
by = next.kind === "click" ? { button: buttonText(next.button) } : "enter";
|
|
731
|
+
}
|
|
732
|
+
for (const [kind, identity] of typedIds)
|
|
733
|
+
progress.submitted.set(kind, identity);
|
|
419
734
|
progress.submits += 1;
|
|
420
|
-
const did = describeStep(step.fill,
|
|
735
|
+
const did = describeStep(step.fill, by, boxOpts);
|
|
421
736
|
say(did);
|
|
422
737
|
lastWait = `the page to move on after that step (${did})`;
|
|
423
738
|
// Wait for the form to move on: another page, or other fields on this one.
|