scenescout 3.13.0 → 3.14.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 +29 -0
- package/README.md +34 -6
- package/dist/browsers.js +13 -0
- package/dist/check-run.js +2 -0
- package/dist/ci-run.js +134 -16
- package/dist/cli.js +72 -1
- package/dist/engine/authloss.js +149 -8
- package/dist/engine/brief.js +7 -1
- package/dist/engine/browser.js +615 -74
- package/dist/engine/capture.js +121 -0
- package/dist/engine/check.js +18 -0
- package/dist/engine/ci.js +117 -6
- package/dist/engine/dedup.js +50 -22
- package/dist/engine/expiry.js +254 -0
- package/dist/engine/fingerprint.js +2 -2
- package/dist/engine/flow.js +0 -2
- package/dist/engine/limits.js +144 -0
- package/dist/engine/live-page.js +57 -4
- package/dist/engine/oracles.js +8 -0
- package/dist/engine/png.js +238 -0
- package/dist/engine/postmessage.js +282 -0
- package/dist/engine/probes.js +4 -4
- package/dist/engine/profiles.js +392 -0
- package/dist/engine/refresh.js +383 -0
- package/dist/engine/scripted-login.js +400 -0
- package/dist/engine/teardown.js +41 -0
- package/dist/login-run.js +444 -0
- package/dist/mcp-server.js +136 -20
- package/package.json +7 -2
- package/skills/scenescout/SKILL.md +9 -6
package/dist/engine/authloss.js
CHANGED
|
@@ -14,6 +14,18 @@
|
|
|
14
14
|
export const LOGIN_ROUTE_RE = /\/(login|signin|sign-in|auth)(\/|$)/;
|
|
15
15
|
/** Consecutive login bounces before we stop assuming it is a permission wall. */
|
|
16
16
|
export const AUTH_LOSS_STREAK = 3;
|
|
17
|
+
/**
|
|
18
|
+
* Should this loss be answered by re-attaching from the role's saved profile?
|
|
19
|
+
*
|
|
20
|
+
* Only a session attached by role has a profile that `scenescout login` (or
|
|
21
|
+
* another process) may have refreshed since, so only it has something to
|
|
22
|
+
* re-read. A storage-state file or an anonymous session is reported as
|
|
23
|
+
* before. Once per session: a second loss means the refresh did not hold,
|
|
24
|
+
* and retrying on a loop would hide a dead profile behind endless recoveries.
|
|
25
|
+
*/
|
|
26
|
+
export function shouldReattach(opts) {
|
|
27
|
+
return opts.streak >= AUTH_LOSS_STREAK && opts.signIn === "role" && !opts.alreadyReattached;
|
|
28
|
+
}
|
|
17
29
|
export class AuthLossTracker {
|
|
18
30
|
/** Consecutive navigations that ended on a login page. */
|
|
19
31
|
streak = 0;
|
|
@@ -21,6 +33,37 @@ export class AuthLossTracker {
|
|
|
21
33
|
reported = false;
|
|
22
34
|
/** Notice for the in-flight call, consumed exactly once by take(). */
|
|
23
35
|
pending = "";
|
|
36
|
+
/** How the session signed in; set by beginSession. */
|
|
37
|
+
signIn = "none";
|
|
38
|
+
/** The role label of the session, for the notices. */
|
|
39
|
+
role = "";
|
|
40
|
+
/** What each navigation of the current streak asked for, in order. */
|
|
41
|
+
streakTargets = [];
|
|
42
|
+
/** A re-attach this navigation earned and the engine has not taken yet. */
|
|
43
|
+
due = null;
|
|
44
|
+
/** Taken by the engine: the next recorded navigation says whether it worked. */
|
|
45
|
+
awaiting = null;
|
|
46
|
+
/** What happened to this session's one re-attach. */
|
|
47
|
+
outcome = null;
|
|
48
|
+
/** Notices a batch (crawl) must still say at its end, since it discards the per-route ones. */
|
|
49
|
+
batchNotes = [];
|
|
50
|
+
/**
|
|
51
|
+
* Start tracking a newly attached session. An explicit attach is new
|
|
52
|
+
* credentials: the streak, the verdict and the one re-attach start afresh,
|
|
53
|
+
* so a new session neither inherits a dead one's bounces nor names its routes.
|
|
54
|
+
*/
|
|
55
|
+
beginSession(signIn, role) {
|
|
56
|
+
this.signIn = signIn;
|
|
57
|
+
this.role = role;
|
|
58
|
+
this.streak = 0;
|
|
59
|
+
this.streakTargets = [];
|
|
60
|
+
this.reported = false;
|
|
61
|
+
this.pending = "";
|
|
62
|
+
this.due = null;
|
|
63
|
+
this.awaiting = null;
|
|
64
|
+
this.outcome = null;
|
|
65
|
+
this.batchNotes = [];
|
|
66
|
+
}
|
|
24
67
|
/**
|
|
25
68
|
* Did a navigation to `requested` end up on a login screen?
|
|
26
69
|
*
|
|
@@ -46,14 +89,14 @@ export class AuthLossTracker {
|
|
|
46
89
|
*
|
|
47
90
|
* `bounced` decides both the streak and whether the route counts as covered;
|
|
48
91
|
* the caller owns the memory writes, because ownership of the store belongs
|
|
49
|
-
* to the engine, not to this tracker.
|
|
92
|
+
* to the engine, not to this tracker. `target` is what the caller navigated
|
|
93
|
+
* to (a path with its real ids, where `requestedRoute` is the normalized
|
|
94
|
+
* route), which a re-attach goes back to; it defaults to the route.
|
|
50
95
|
*/
|
|
51
96
|
record(opts) {
|
|
52
97
|
const { requestedRoute, landedRoute, bounced, role } = opts;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
else
|
|
56
|
-
this.streak = 0;
|
|
98
|
+
const target = opts.target ?? requestedRoute;
|
|
99
|
+
this.due = null;
|
|
57
100
|
const divergence = landedRoute === requestedRoute
|
|
58
101
|
? ""
|
|
59
102
|
: `⚠ REDIRECTED: asked for ${requestedRoute}, landed on ${landedRoute}` +
|
|
@@ -61,8 +104,101 @@ export class AuthLossTracker {
|
|
|
61
104
|
? ` — this is a login page, so the route is NOT counted as covered.`
|
|
62
105
|
: ` — the app redirected; the route counts as covered for role '${role}'.`) +
|
|
63
106
|
`\n`;
|
|
107
|
+
if (this.awaiting) {
|
|
108
|
+
this.settleReattach(requestedRoute, target, bounced, divergence);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (bounced) {
|
|
112
|
+
this.streak += 1;
|
|
113
|
+
this.streakTargets.push(target);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
this.streak = 0;
|
|
117
|
+
this.streakTargets = [];
|
|
118
|
+
}
|
|
119
|
+
if (shouldReattach({ streak: this.streak, signIn: this.signIn, alreadyReattached: this.outcome !== null })) {
|
|
120
|
+
// The verdict is replaced by the recovery's own notice once the engine
|
|
121
|
+
// has re-attached; if it never does (the call threw), the verdict stands.
|
|
122
|
+
this.due = { returnTo: target, retry: [...new Set(this.streakTargets)].filter((t) => t !== target) };
|
|
123
|
+
}
|
|
64
124
|
this.pending = this.banner() + divergence;
|
|
65
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* The re-attach this navigation earned, if any: consumed once, and from then
|
|
128
|
+
* on this session has used its one re-attach. `revisits` says which of the
|
|
129
|
+
* streak's other targets the caller will visit again itself (a crawl, its own
|
|
130
|
+
* paths); the plan it gets back holds only those, and the notice asks for the rest.
|
|
131
|
+
*/
|
|
132
|
+
takeReattach(opts = {}) {
|
|
133
|
+
const plan = this.due;
|
|
134
|
+
this.due = null;
|
|
135
|
+
if (!plan)
|
|
136
|
+
return null;
|
|
137
|
+
const revisits = opts.revisits;
|
|
138
|
+
this.awaiting = revisits
|
|
139
|
+
? { returnTo: plan.returnTo, retry: plan.retry.filter(revisits), left: plan.retry.filter((t) => !revisits(t)) }
|
|
140
|
+
: { returnTo: plan.returnTo, retry: [], left: plan.retry };
|
|
141
|
+
this.outcome = { kind: "failed", why: "it had not finished" };
|
|
142
|
+
return { returnTo: this.awaiting.returnTo, retry: this.awaiting.retry };
|
|
143
|
+
}
|
|
144
|
+
/** Whether a re-attach was taken and the navigation that decides it has not been recorded. */
|
|
145
|
+
get reattaching() {
|
|
146
|
+
return this.awaiting !== null;
|
|
147
|
+
}
|
|
148
|
+
/** The profile could not be applied, or the page to go back to never loaded: the loss is reported, with why. */
|
|
149
|
+
abortReattach(why) {
|
|
150
|
+
this.awaiting = null;
|
|
151
|
+
this.outcome = { kind: "failed", why };
|
|
152
|
+
const note = `The session tried to re-attach once from role '${this.role}''s saved profile, but ${why}.`;
|
|
153
|
+
this.pending = this.banner(this.lossTail(note));
|
|
154
|
+
this.batchNotes.push(`⚠ ${note}\n`);
|
|
155
|
+
}
|
|
156
|
+
/** The navigation after a re-attach decides it: signed in again, or not. */
|
|
157
|
+
settleReattach(requestedRoute, target, bounced, divergence) {
|
|
158
|
+
const plan = this.awaiting;
|
|
159
|
+
this.awaiting = null;
|
|
160
|
+
if (bounced) {
|
|
161
|
+
// Still on a login page with the latest profile: that profile is dead too.
|
|
162
|
+
this.streak = Math.max(this.streak, AUTH_LOSS_STREAK);
|
|
163
|
+
this.streakTargets.push(target);
|
|
164
|
+
const why = `its latest saved profile landed on a login page as well, so that profile has expired too`;
|
|
165
|
+
this.outcome = { kind: "failed", why };
|
|
166
|
+
const note = `The session re-attached once from role '${this.role}''s saved profile, but ${why}.`;
|
|
167
|
+
this.pending = this.banner(this.lossTail(note)) + divergence;
|
|
168
|
+
this.batchNotes.push(`⚠ ${note}\n`);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
this.streak = 0;
|
|
172
|
+
this.streakTargets = [];
|
|
173
|
+
// The loss was answered: a later batch verdict must not claim this session is dead.
|
|
174
|
+
this.reported = false;
|
|
175
|
+
this.outcome = { kind: "recovered", route: requestedRoute };
|
|
176
|
+
const others = (plan.retry.length === 0 ? "" : ` The other routes that bounced (${plan.retry.join(", ")}) are visited again in this sweep.`) +
|
|
177
|
+
(plan.left.length === 0 ? "" : ` The other routes that bounced before it (${plan.left.join(", ")}) are still NOT covered: visit them again.`);
|
|
178
|
+
const note = `↻ SESSION RE-ATTACHED — its sign-in was lost, so it re-attached once from role '${this.role}''s latest saved profile and ` +
|
|
179
|
+
`is signed in again at ${requestedRoute}.${others} This happens once per session: a second loss is reported, not retried.\n`;
|
|
180
|
+
this.pending = note + divergence;
|
|
181
|
+
this.batchNotes.push(note);
|
|
182
|
+
}
|
|
183
|
+
/** The end of a loss once the one re-attach is spent: what happened, and that it is not retried. */
|
|
184
|
+
lossTail(note) {
|
|
185
|
+
return ` ${note} It is not retried: record the profile again with \`scenescout login\` for role '${this.role}' and re-attach.`;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* One line on what this session's re-attach did, for whoever hands its
|
|
189
|
+
* results on (a lane's report). Empty when it never re-attached.
|
|
190
|
+
*/
|
|
191
|
+
reattachSummary() {
|
|
192
|
+
if (!this.outcome)
|
|
193
|
+
return "";
|
|
194
|
+
if (this.outcome.kind === "recovered")
|
|
195
|
+
return `its sign-in was lost and it re-attached once from role '${this.role}''s saved profile, continuing from ${this.outcome.route}`;
|
|
196
|
+
return `its sign-in was lost and re-attaching from role '${this.role}''s saved profile did not recover it: ${this.outcome.why}`;
|
|
197
|
+
}
|
|
198
|
+
/** Start a batch (crawl): its end-of-batch notes cover only what happened during it. */
|
|
199
|
+
beginBatch() {
|
|
200
|
+
this.batchNotes = [];
|
|
201
|
+
}
|
|
66
202
|
/**
|
|
67
203
|
* The verdict, once the streak says the session is dead.
|
|
68
204
|
*
|
|
@@ -74,15 +210,18 @@ export class AuthLossTracker {
|
|
|
74
210
|
* collapses repeats to "nothing new" — the signal decayed exactly as the
|
|
75
211
|
* problem got worse.
|
|
76
212
|
*/
|
|
77
|
-
banner() {
|
|
213
|
+
banner(tail) {
|
|
78
214
|
if (this.streak < AUTH_LOSS_STREAK)
|
|
79
215
|
return "";
|
|
80
216
|
const first = !this.reported;
|
|
81
217
|
this.reported = true;
|
|
218
|
+
// After the session's one re-attach, a later loss says so rather than implying a retry is still to come.
|
|
219
|
+
const spent = tail ?? (this.outcome !== null ? this.lossTail(`This session already re-attached once from role '${this.role}''s saved profile.`) : "");
|
|
82
220
|
return (`⚠ SESSION AUTH LOST — ${this.streak} consecutive navigations were redirected to a login page. ` +
|
|
83
221
|
`The credentials this session attached with have almost certainly expired. ` +
|
|
84
222
|
`Nothing tested past this point is meaningful: re-attach with a fresh storage state before continuing.` +
|
|
85
223
|
(first ? ` Routes bounced this way are recorded as NOT covered, so the completion contract still sees them as gaps.` : "") +
|
|
224
|
+
spent +
|
|
86
225
|
`\n`);
|
|
87
226
|
}
|
|
88
227
|
/**
|
|
@@ -115,9 +254,11 @@ export class AuthLossTracker {
|
|
|
115
254
|
* whatever the final route did.
|
|
116
255
|
*/
|
|
117
256
|
batchVerdict() {
|
|
257
|
+
const notes = this.batchNotes.join("");
|
|
118
258
|
if (!this.reported)
|
|
119
|
-
return
|
|
120
|
-
return (
|
|
259
|
+
return notes;
|
|
260
|
+
return (notes +
|
|
261
|
+
`⚠ SESSION AUTH LOST during this sweep — navigations were redirected to a login page. ` +
|
|
121
262
|
`The credentials this session attached with have almost certainly expired, so routes crawled after that ` +
|
|
122
263
|
`point tested a logged-out app. They are recorded as NOT covered; re-attach with a fresh storage state ` +
|
|
123
264
|
`and crawl again.\n`);
|
package/dist/engine/brief.js
CHANGED
|
@@ -85,6 +85,12 @@ export function laneName(modules, index) {
|
|
|
85
85
|
const name = modules.length > 1 ? `${base}+${modules.length - 1}` : base || `lane-${index + 1}`;
|
|
86
86
|
return name.slice(0, LANE_NAME_MAX);
|
|
87
87
|
}
|
|
88
|
+
/** How a lane signs in, as scout_attach arguments: by saved role, by a storage-state file, or not at all. */
|
|
89
|
+
function signInArgument(opts) {
|
|
90
|
+
if (!opts.role || opts.role === "anonymous")
|
|
91
|
+
return "";
|
|
92
|
+
return opts.roleProfile ? `, role: "${opts.role}"` : `, storageStatePath: "<${opts.role}>"`;
|
|
93
|
+
}
|
|
88
94
|
/** The lanes to run, each with the objective to attach with. */
|
|
89
95
|
export function planLanes(routes, laneCount, opts = {}) {
|
|
90
96
|
return splitRoutes(routes, laneCount).map((lane, i) => ({
|
|
@@ -124,7 +130,7 @@ export function formatBriefs(briefs, opts = {}) {
|
|
|
124
130
|
`LANE PLAN — ${briefs.length} lane(s) over ${briefs.reduce((n, b) => n + b.routes.length, 0)} route(s).`,
|
|
125
131
|
``,
|
|
126
132
|
`Give each lane its own agent. Every lane attaches with its own session name, so the browsers run genuinely in parallel, and lands on its own first route rather than the home page:`,
|
|
127
|
-
` scout_attach { session: "<lane>", url: "<origin><landing>", projectPath, mode: "${mode}"${opts
|
|
133
|
+
` scout_attach { session: "<lane>", url: "<origin><landing>", projectPath, mode: "${mode}"${signInArgument(opts)}, objective: "<objective>" }`,
|
|
128
134
|
``,
|
|
129
135
|
`Rules to pass on, which a hand-written brief tends to drop:`,
|
|
130
136
|
` · A lane works ITS routes only. Two lanes auditing the same register while a third module is never opened is the failure this plan exists to prevent — and route coverage will look complete either way. A defect on a page it does not own is that page's lane's to file.`,
|