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.
@@ -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
- if (bounced)
54
- this.streak += 1;
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 (`⚠ SESSION AUTH LOST during this sweep — navigations were redirected to a login page. ` +
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`);
@@ -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.role ? `, storageStatePath: "<${opts.role}>"` : ""}, objective: "<objective>" }`,
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.`,