scenescout 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +429 -0
- package/dist/cli.js +269 -0
- package/dist/engine/authloss.js +125 -0
- package/dist/engine/browser.js +1954 -0
- package/dist/engine/collector.js +266 -0
- package/dist/engine/design.js +716 -0
- package/dist/engine/dispatch.js +100 -0
- package/dist/engine/fingerprint.js +100 -0
- package/dist/engine/fixtures.js +162 -0
- package/dist/engine/journey.js +71 -0
- package/dist/engine/launch.js +25 -0
- package/dist/engine/memory.js +1116 -0
- package/dist/engine/oracles.js +187 -0
- package/dist/engine/ownership.js +223 -0
- package/dist/engine/policy.js +84 -0
- package/dist/engine/probes.js +293 -0
- package/dist/engine/reaper.js +72 -0
- package/dist/engine/report.js +515 -0
- package/dist/engine/uploads.js +74 -0
- package/dist/installer.js +315 -0
- package/dist/mcp-server.js +810 -0
- package/dist/scan.js +335 -0
- package/package.json +86 -0
- package/skills/scenescout/SKILL.md +96 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SceneScout CLI.
|
|
4
|
+
*
|
|
5
|
+
* scenescout scan <projectPath> Print project discovery results
|
|
6
|
+
* scenescout serve Run the MCP server on stdio
|
|
7
|
+
* scenescout install Install the skill, download Chromium, register the MCP server
|
|
8
|
+
* scenescout doctor Check every piece of the setup and say how to fix what is missing
|
|
9
|
+
*/
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import { createRequire } from "node:module";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { diagnose, installSkill, launchCommand, manualRegisterCommand, registerMcp, resolveClaudeDir, spawnRunner } from "./installer.js";
|
|
17
|
+
import { LEGACY_MEMORY_DIRNAME, MEMORY_DIRNAME } from "./engine/memory.js";
|
|
18
|
+
import { formatScan, scanProject } from "./scan.js";
|
|
19
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const packageRoot = path.resolve(here, "..");
|
|
21
|
+
function usage(exitCode = 1) {
|
|
22
|
+
console.log(`SceneScout — AI exploratory UI testing engine (MCP)
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
scenescout scan <projectPath> Discover framework, routes, auth states
|
|
26
|
+
scenescout serve Run the MCP server (stdio)
|
|
27
|
+
scenescout install One-step setup: skill + Chromium + MCP registration
|
|
28
|
+
(--skip-browser, --no-register to opt out of a step;
|
|
29
|
+
--browser-only when the skill and server came from a plugin)
|
|
30
|
+
scenescout doctor Check the setup and print the fix for anything missing
|
|
31
|
+
(--engine: only node, the build and the browser — for plugin
|
|
32
|
+
installs and other MCP clients)
|
|
33
|
+
scenescout status [projectPath] What is the engine doing right now? (live status + recent actions)
|
|
34
|
+
`);
|
|
35
|
+
process.exit(exitCode);
|
|
36
|
+
}
|
|
37
|
+
/** Realtime observability: read the status file + recent action log the running engine maintains. */
|
|
38
|
+
function status(projectPath) {
|
|
39
|
+
// A project last touched before the rename (or one a pre-rename engine is
|
|
40
|
+
// using right now) still keeps its status under the legacy directory.
|
|
41
|
+
const dir = [MEMORY_DIRNAME, LEGACY_MEMORY_DIRNAME]
|
|
42
|
+
.map((name) => path.join(projectPath, name))
|
|
43
|
+
.find((candidate) => fs.existsSync(path.join(candidate, "status.json"))) ?? path.join(projectPath, MEMORY_DIRNAME);
|
|
44
|
+
const statusPath = path.join(dir, "status.json");
|
|
45
|
+
if (!fs.existsSync(statusPath)) {
|
|
46
|
+
console.log(`No status file at ${statusPath} — no SceneScout engine has attached to this project (or it predates v0.8).`);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
let st;
|
|
50
|
+
try {
|
|
51
|
+
st = JSON.parse(fs.readFileSync(statusPath, "utf8"));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// status.json is written fire-and-forget on every tool call, so a process
|
|
55
|
+
// killed mid-write leaves a truncated file. That is a diagnosable state,
|
|
56
|
+
// not a reason for the diagnostic tool itself to crash.
|
|
57
|
+
console.log(`Status file at ${statusPath} is unreadable or truncated — the engine was probably killed mid-write. Re-attach to refresh it.`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
let alive = false;
|
|
61
|
+
if (st.pid) {
|
|
62
|
+
try {
|
|
63
|
+
process.kill(st.pid, 0);
|
|
64
|
+
alive = true;
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
// EPERM means the process EXISTS but belongs to another user — only
|
|
68
|
+
// ESRCH actually means "no such process". Treating both as dead reported
|
|
69
|
+
// a live engine as stale.
|
|
70
|
+
alive = err?.code === "EPERM";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const age = st.at ? Math.round((Date.now() - new Date(st.at).getTime()) / 1000) : null;
|
|
74
|
+
console.log(`Engine pid ${st.pid ?? "?"} — ${alive ? "ALIVE" : "not running (stale status)"}`);
|
|
75
|
+
console.log(`${st.phase === "running" ? "⏳ running" : "· idle after"}: ${st.tool ?? "?"}${age !== null ? ` (as of ${age}s ago)` : ""}`);
|
|
76
|
+
console.log(`Session: ${st.session ?? "?"} (${st.role ?? "?"})${st.sessions && st.sessions.length > 1 ? ` · all sessions: ${st.sessions.join(", ")}` : ""}`);
|
|
77
|
+
if (st.url)
|
|
78
|
+
console.log(`URL: ${st.url}`);
|
|
79
|
+
// Recent actions from the newest session log — the "what has it been doing" trail.
|
|
80
|
+
const logs = fs.existsSync(dir)
|
|
81
|
+
? fs
|
|
82
|
+
.readdirSync(dir)
|
|
83
|
+
.filter((f) => f.startsWith("session-") && f.endsWith(".jsonl"))
|
|
84
|
+
.sort()
|
|
85
|
+
: [];
|
|
86
|
+
const newest = logs[logs.length - 1];
|
|
87
|
+
if (newest) {
|
|
88
|
+
// Tail-read: the action log grows by one line per engine action (MBs on a
|
|
89
|
+
// long run) and status is a poll target — read only the final chunk.
|
|
90
|
+
const logPath = path.join(dir, newest);
|
|
91
|
+
const size = fs.statSync(logPath).size;
|
|
92
|
+
const buf = Buffer.alloc(Math.min(16384, size));
|
|
93
|
+
const fd = fs.openSync(logPath, "r");
|
|
94
|
+
fs.readSync(fd, buf, 0, buf.length, Math.max(0, size - buf.length));
|
|
95
|
+
fs.closeSync(fd);
|
|
96
|
+
const raw = buf.toString("utf8");
|
|
97
|
+
// When the chunk starts mid-file, the first line is probably partial — drop it.
|
|
98
|
+
const text = size > buf.length ? raw.slice(raw.indexOf("\n") + 1) : raw;
|
|
99
|
+
const lines = text.trim().split("\n").slice(-8);
|
|
100
|
+
console.log(`\nRecent actions (${newest}):`);
|
|
101
|
+
for (const line of lines) {
|
|
102
|
+
try {
|
|
103
|
+
const e = JSON.parse(line);
|
|
104
|
+
console.log(` ${e.at.slice(11, 19)} ${e.action}${e.target ? ` ${e.target}` : ""} @ ${e.url}`);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
/* skip malformed line */
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** Where Playwright expects its Chromium build; null when playwright cannot say. */
|
|
113
|
+
async function chromiumPath() {
|
|
114
|
+
try {
|
|
115
|
+
const { chromium } = await import("playwright");
|
|
116
|
+
return chromium.executablePath() || null;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** Download Chromium through the playwright CLI that ships with our own dependency. */
|
|
123
|
+
function downloadChromium() {
|
|
124
|
+
const require = createRequire(import.meta.url);
|
|
125
|
+
const cli = path.join(path.dirname(require.resolve("playwright/package.json")), "cli.js");
|
|
126
|
+
const r = spawnSync(process.execPath, [cli, "install", "chromium"], { stdio: "inherit" });
|
|
127
|
+
return r.status === 0;
|
|
128
|
+
}
|
|
129
|
+
async function install(flags) {
|
|
130
|
+
const serverPath = path.join(packageRoot, "dist", "mcp-server.js");
|
|
131
|
+
if (!fs.existsSync(serverPath)) {
|
|
132
|
+
throw new Error(`${serverPath} is missing — run \`npm run build\` first.`);
|
|
133
|
+
}
|
|
134
|
+
// A step that fails is reported AND fails the command: `setup && next-step`
|
|
135
|
+
// must not carry on past a missing browser or an unregistered server.
|
|
136
|
+
let failed = false;
|
|
137
|
+
// A plugin install already brings the skill and the server registration; the
|
|
138
|
+
// only thing it cannot bring is the browser download.
|
|
139
|
+
const browserOnly = flags.includes("--browser-only");
|
|
140
|
+
if (!browserOnly) {
|
|
141
|
+
const skill = installSkill({ packageRoot, claudeDir: resolveClaudeDir(process.env, os.homedir()) });
|
|
142
|
+
for (const note of skill.notes)
|
|
143
|
+
console.log(`· ${note}`);
|
|
144
|
+
console.log(skill.mode === "symlink"
|
|
145
|
+
? `✓ Skill installed (symlink): ${skill.dest} → ${skill.src}`
|
|
146
|
+
: `✓ Skill installed (copy): ${skill.dest} — re-run install after upgrading SceneScout.`);
|
|
147
|
+
}
|
|
148
|
+
if (flags.includes("--skip-browser")) {
|
|
149
|
+
console.log("· Browser download skipped (--skip-browser).");
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
const existing = await chromiumPath();
|
|
153
|
+
if (existing && fs.existsSync(existing)) {
|
|
154
|
+
console.log(`✓ Chromium already present: ${existing}`);
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
console.log("· Downloading Chromium (one-time, ~150 MB)…");
|
|
158
|
+
if (downloadChromium())
|
|
159
|
+
console.log("✓ Chromium downloaded.");
|
|
160
|
+
else {
|
|
161
|
+
failed = true;
|
|
162
|
+
console.log("✗ Chromium download failed — run `npx playwright install chromium` and check your network/proxy.");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (browserOnly) {
|
|
167
|
+
// nothing to register
|
|
168
|
+
}
|
|
169
|
+
else if (flags.includes("--no-register")) {
|
|
170
|
+
console.log(`· MCP registration skipped (--no-register). To do it by hand:\n\n ${manualRegisterCommand(launchCommand({ packageRoot, nodePath: process.execPath, serverPath }))}\n`);
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
const reg = registerMcp({ launch: launchCommand({ packageRoot, nodePath: process.execPath, serverPath }), serverPath, run: spawnRunner });
|
|
174
|
+
if (reg.status === "registered") {
|
|
175
|
+
console.log(`✓ MCP server ${reg.replaced ? "re-registered (paths refreshed)" : "registered"} with Claude Code at user scope.`);
|
|
176
|
+
for (const name of reg.removedLegacy)
|
|
177
|
+
console.log(`· removed the pre-rename MCP registration "${name}" (it pointed at this same server).`);
|
|
178
|
+
for (const note of reg.notes)
|
|
179
|
+
console.log(`· ${note}`);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
failed = true;
|
|
183
|
+
console.log(reg.status === "claude-missing"
|
|
184
|
+
? "· `claude` is not on this shell's PATH, so the MCP server was not registered."
|
|
185
|
+
: `✗ \`claude mcp add\` failed: ${reg.detail}`);
|
|
186
|
+
console.log(` Run this once from a terminal where \`claude\` works:\n\n ${reg.manual}\n`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (failed) {
|
|
190
|
+
console.log("\nSetup is incomplete — fix the lines marked ✗ or · above, then run: npm run doctor");
|
|
191
|
+
process.exitCode = 1;
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (browserOnly) {
|
|
195
|
+
console.log("\nThe browser is ready — attach again.");
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
console.log("\nStart a FRESH Claude Code session, then in any project run: /scenescout");
|
|
199
|
+
console.log("Something off? Run: npm run doctor");
|
|
200
|
+
}
|
|
201
|
+
async function doctor(flags) {
|
|
202
|
+
const checks = diagnose({
|
|
203
|
+
scope: flags.includes("--engine") ? "engine" : "claude-code",
|
|
204
|
+
packageRoot,
|
|
205
|
+
claudeDir: resolveClaudeDir(process.env, os.homedir()),
|
|
206
|
+
nodeVersion: process.version,
|
|
207
|
+
chromiumPath: await chromiumPath(),
|
|
208
|
+
run: spawnRunner,
|
|
209
|
+
});
|
|
210
|
+
for (const c of checks) {
|
|
211
|
+
console.log(`${c.ok ? "✓" : "✗"} ${c.name} — ${c.detail}`);
|
|
212
|
+
if (!c.ok && c.fix)
|
|
213
|
+
console.log(` fix: ${c.fix}`);
|
|
214
|
+
}
|
|
215
|
+
if (checks.some((c) => !c.ok))
|
|
216
|
+
process.exit(1);
|
|
217
|
+
console.log("\nAll good. In any project, run: /scenescout");
|
|
218
|
+
}
|
|
219
|
+
const [, , command, ...args] = process.argv;
|
|
220
|
+
// A CLI's failure mode should be a sentence, not a stack trace. `scan` on a
|
|
221
|
+
// path that does not exist and `status` on a half-written status.json both
|
|
222
|
+
// throw ordinary Errors; unguarded, they printed a V8 trace that buries the
|
|
223
|
+
// one line the user needs. `serve` is deliberately outside this: it hands off
|
|
224
|
+
// to the MCP server, whose own transport owns error reporting from then on.
|
|
225
|
+
try {
|
|
226
|
+
switch (command) {
|
|
227
|
+
// Asking for help is not an error; scripts and shells treat a non-zero
|
|
228
|
+
// exit as one.
|
|
229
|
+
case "--help":
|
|
230
|
+
case "-h":
|
|
231
|
+
case "help":
|
|
232
|
+
usage(0);
|
|
233
|
+
case "--version":
|
|
234
|
+
case "-v": {
|
|
235
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
|
|
236
|
+
console.log(pkg.version);
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
case "scan": {
|
|
240
|
+
const target = args[0];
|
|
241
|
+
if (!target)
|
|
242
|
+
usage();
|
|
243
|
+
console.log(formatScan(scanProject(target)));
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
case "serve": {
|
|
247
|
+
await import("./mcp-server.js");
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
case "install": {
|
|
251
|
+
await install(args);
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
case "doctor": {
|
|
255
|
+
await doctor(args);
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
case "status": {
|
|
259
|
+
status(path.resolve(args[0] ?? process.cwd()));
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
default:
|
|
263
|
+
usage();
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
catch (err) {
|
|
267
|
+
console.error(`scenescout ${command ?? ""}: ${err instanceof Error ? err.message : String(err)}`);
|
|
268
|
+
process.exit(1);
|
|
269
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth-loss detection: telling "this role may not see that page" apart from
|
|
3
|
+
* "our credentials died and nothing since is meaningful".
|
|
4
|
+
*
|
|
5
|
+
* Extracted from browser.ts because it is a small state machine with real
|
|
6
|
+
* invariants — a streak, a once-only explanatory tail, a single-consumption
|
|
7
|
+
* notice buffer — that was previously inline in a 2300-line class and reachable
|
|
8
|
+
* only by driving a whole browser. Three separate bugs landed in it there
|
|
9
|
+
* (a bounce judged before the page settled, a notice lost whenever the
|
|
10
|
+
* surrounding call threw, and that same notice then leaking onto the NEXT
|
|
11
|
+
* call's result). All three are cheap to pin once the logic stands alone.
|
|
12
|
+
*/
|
|
13
|
+
/** Paths that look like a login/auth screen — the landing place of a dead session. */
|
|
14
|
+
export const LOGIN_ROUTE_RE = /\/(login|signin|sign-in|auth)(\/|$)/;
|
|
15
|
+
/** Consecutive login bounces before we stop assuming it is a permission wall. */
|
|
16
|
+
export const AUTH_LOSS_STREAK = 3;
|
|
17
|
+
export class AuthLossTracker {
|
|
18
|
+
/** Consecutive navigations that ended on a login page. */
|
|
19
|
+
streak = 0;
|
|
20
|
+
/** Set once the verdict has been delivered, so the long tail is added once. */
|
|
21
|
+
reported = false;
|
|
22
|
+
/** Notice for the in-flight call, consumed exactly once by take(). */
|
|
23
|
+
pending = "";
|
|
24
|
+
/**
|
|
25
|
+
* Did a navigation to `requested` end up on a login screen?
|
|
26
|
+
*
|
|
27
|
+
* Normalizes `requested` itself rather than trusting callers: crawl passes
|
|
28
|
+
* raw targets and tolerates slash-less paths, so an explicit crawl of
|
|
29
|
+
* "login" — the anonymous auth-surface pass — was scored as a bounce and fed
|
|
30
|
+
* the streak. Deliberately does not fire when the caller ASKED for the login
|
|
31
|
+
* page, whatever shape they asked in.
|
|
32
|
+
*/
|
|
33
|
+
isLoginRedirect(requested, landedUrl, baseUrl) {
|
|
34
|
+
let landedPath;
|
|
35
|
+
try {
|
|
36
|
+
landedPath = new URL(landedUrl, baseUrl || "http://x").pathname;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
const wanted = requested.startsWith("/") ? requested : `/${requested}`;
|
|
42
|
+
return LOGIN_ROUTE_RE.test(landedPath) && !LOGIN_ROUTE_RE.test(wanted);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Record one navigation's outcome and build the notice for it.
|
|
46
|
+
*
|
|
47
|
+
* `bounced` decides both the streak and whether the route counts as covered;
|
|
48
|
+
* the caller owns the memory writes, because ownership of the store belongs
|
|
49
|
+
* to the engine, not to this tracker.
|
|
50
|
+
*/
|
|
51
|
+
record(opts) {
|
|
52
|
+
const { requestedRoute, landedRoute, bounced, role } = opts;
|
|
53
|
+
if (bounced)
|
|
54
|
+
this.streak += 1;
|
|
55
|
+
else
|
|
56
|
+
this.streak = 0;
|
|
57
|
+
const divergence = landedRoute === requestedRoute
|
|
58
|
+
? ""
|
|
59
|
+
: `⚠ REDIRECTED: asked for ${requestedRoute}, landed on ${landedRoute}` +
|
|
60
|
+
(bounced
|
|
61
|
+
? ` — this is a login page, so the route is NOT counted as covered.`
|
|
62
|
+
: ` — the app redirected; the route counts as covered for role '${role}'.`) +
|
|
63
|
+
`\n`;
|
|
64
|
+
this.pending = this.banner() + divergence;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The verdict, once the streak says the session is dead.
|
|
68
|
+
*
|
|
69
|
+
* One bounce is ordinary (a permission wall, a session that was never
|
|
70
|
+
* authenticated). Three in a row means the credentials this session attached
|
|
71
|
+
* with have expired, and every subsequent "OK" is a lie: the page rendered,
|
|
72
|
+
* the URL changed, and nothing that follows tests the app. This used to be
|
|
73
|
+
* invisible because the passive HTTP oracle rates 401 as `medium` and then
|
|
74
|
+
* collapses repeats to "nothing new" — the signal decayed exactly as the
|
|
75
|
+
* problem got worse.
|
|
76
|
+
*/
|
|
77
|
+
banner() {
|
|
78
|
+
if (this.streak < AUTH_LOSS_STREAK)
|
|
79
|
+
return "";
|
|
80
|
+
const first = !this.reported;
|
|
81
|
+
this.reported = true;
|
|
82
|
+
return (`⚠ SESSION AUTH LOST — ${this.streak} consecutive navigations were redirected to a login page. ` +
|
|
83
|
+
`The credentials this session attached with have almost certainly expired. ` +
|
|
84
|
+
`Nothing tested past this point is meaningful: re-attach with a fresh storage state before continuing.` +
|
|
85
|
+
(first ? ` Routes bounced this way are recorded as NOT covered, so the completion contract still sees them as gaps.` : "") +
|
|
86
|
+
`\n`);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Take the pending notice, clearing it.
|
|
90
|
+
*
|
|
91
|
+
* Single-consumption matters: the notice describes ONE navigation. Left set,
|
|
92
|
+
* it prepended a stale "REDIRECTED: asked for X" to the next, unrelated call
|
|
93
|
+
* — which is what happened whenever the surrounding navigate() threw before
|
|
94
|
+
* reaching its return statement.
|
|
95
|
+
*/
|
|
96
|
+
take() {
|
|
97
|
+
const out = this.pending;
|
|
98
|
+
this.pending = "";
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
/** Discard any un-consumed notice. Called before a navigation starts. */
|
|
102
|
+
clear() {
|
|
103
|
+
this.pending = "";
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The verdict for a BATCH of navigations (a crawl), independent of how the
|
|
107
|
+
* last one happened to land.
|
|
108
|
+
*
|
|
109
|
+
* A crawl records every route in turn, so the per-route notice is overwritten
|
|
110
|
+
* on each iteration and only the final one survives to be taken. That loses
|
|
111
|
+
* the verdict in the ordinary mixed case: the token dies, forty routes bounce,
|
|
112
|
+
* and then the sweep reaches a genuinely public route — which resets the
|
|
113
|
+
* streak, so the last notice is empty and the crawl reports nothing wrong.
|
|
114
|
+
* Once the session has been declared dead, say so at the end of the batch
|
|
115
|
+
* whatever the final route did.
|
|
116
|
+
*/
|
|
117
|
+
batchVerdict() {
|
|
118
|
+
if (!this.reported)
|
|
119
|
+
return "";
|
|
120
|
+
return (`⚠ SESSION AUTH LOST during this sweep — navigations were redirected to a login page. ` +
|
|
121
|
+
`The credentials this session attached with have almost certainly expired, so routes crawled after that ` +
|
|
122
|
+
`point tested a logged-out app. They are recorded as NOT covered; re-attach with a fresh storage state ` +
|
|
123
|
+
`and crawl again.\n`);
|
|
124
|
+
}
|
|
125
|
+
}
|