scenescout 3.22.0 → 3.23.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 +6 -0
- package/README.md +1 -0
- package/dist/ci-run.js +259 -36
- package/dist/cli.js +1 -1
- package/dist/engine/brief.js +35 -3
- package/dist/engine/ci-lanes.js +28 -12
- package/dist/engine/ci.js +39 -1
- package/dist/engine/from-run.js +649 -0
- package/dist/engine/memory.js +123 -0
- package/dist/engine/report.js +26 -0
- package/dist/mcp-server.js +63 -5
- package/package.json +1 -1
- package/skills/scenescout/SKILL.md +2 -2
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Starting a run from an earlier run's record: `continue` picks up where it
|
|
3
|
+
* left off, `replay` follows its route and step order again.
|
|
4
|
+
*
|
|
5
|
+
* Every run that writes its report leaves a record (RunRecord) in the
|
|
6
|
+
* project's memory, and `scenescout ci` copies it into ci.json: the routes it
|
|
7
|
+
* knew, the routes it worked on and in what order, each session's steps, and
|
|
8
|
+
* what it left on each route it worked on (controls never exercised, forms
|
|
9
|
+
* never submitted, a form filled and never submitted, dropdown options never
|
|
10
|
+
* chosen), with its gap ledger.
|
|
11
|
+
*
|
|
12
|
+
* - `continue` orders the next run's routes in three tiers: routes the record
|
|
13
|
+
* never worked on; then routes it worked on, most work left first, with
|
|
14
|
+
* exactly which controls, forms and options to take first; then the routes
|
|
15
|
+
* it covered, last.
|
|
16
|
+
* - `replay` hands back each session's routes and steps in the order they were
|
|
17
|
+
* taken, to reproduce a run or check a fix.
|
|
18
|
+
*
|
|
19
|
+
* Opt-in: without a record nothing here is used and a run behaves as it always
|
|
20
|
+
* has. Pure, so the precedence of the settings, the record's shape, the order
|
|
21
|
+
* and the replay are table-tested (scripts/brief-test.ts) without a browser;
|
|
22
|
+
* reading a record from disk is memory.ts's loadRunRecord.
|
|
23
|
+
*/
|
|
24
|
+
import { normalizePath } from "./fingerprint.js";
|
|
25
|
+
/** The earlier run to start from, for every run that does not name one itself: a ci.json, or a project directory. */
|
|
26
|
+
export const FROM_RUN_ENV = "SCENESCOUT_FROM_RUN";
|
|
27
|
+
/** How to use it: `continue` (the default) or `replay`. */
|
|
28
|
+
export const FROM_RUN_MODE_ENV = "SCENESCOUT_FROM_RUN_MODE";
|
|
29
|
+
/** How many model turns a continued run budgets for each page it takes on: what sets its page cap (pageCap). */
|
|
30
|
+
export const FROM_RUN_TURNS_PER_PAGE_ENV = "SCENESCOUT_FROM_RUN_TURNS_PER_PAGE";
|
|
31
|
+
/**
|
|
32
|
+
* Turns a continued run spends on one page by default: enough to exercise its
|
|
33
|
+
* controls, submit its forms and try its options, measured against what a run
|
|
34
|
+
* did with 18 turns (two to three pages worked closely). 18 turns take 2
|
|
35
|
+
* pages; a two-lane run of 80 turns takes 5 per lane.
|
|
36
|
+
*/
|
|
37
|
+
export const DEFAULT_TURNS_PER_PAGE = 7;
|
|
38
|
+
/** The bounds of the setting. */
|
|
39
|
+
export const TURNS_PER_PAGE_RANGE = { min: 1, max: 200 };
|
|
40
|
+
/**
|
|
41
|
+
* `continue`: start where the earlier run left off. `replay`: follow its route
|
|
42
|
+
* and step order again.
|
|
43
|
+
*/
|
|
44
|
+
export const FROM_RUN_MODES = ["continue", "replay"];
|
|
45
|
+
export const DEFAULT_FROM_RUN_MODE = "continue";
|
|
46
|
+
/**
|
|
47
|
+
* The earlier run a run starts from: the path option, else the environment
|
|
48
|
+
* variable, else none; the mode option, else its variable, else `continue`.
|
|
49
|
+
* An empty variable is unset. The mode is read only when there is a path, so a
|
|
50
|
+
* stray SCENESCOUT_FROM_RUN_MODE never stops a run that starts fresh; a mode
|
|
51
|
+
* given as an option without a path is refused, since it would do nothing.
|
|
52
|
+
*/
|
|
53
|
+
export function resolveFromRun(option, env, names = { path: "--from-run", mode: "--from-run-mode", turnsPerPage: "--from-run-turns-per-page" }) {
|
|
54
|
+
const perPageName = names.turnsPerPage ?? "--from-run-turns-per-page";
|
|
55
|
+
if (option.path !== undefined && option.path.trim() === "")
|
|
56
|
+
return { ok: false, error: `${names.path} needs a ci.json or a project directory` };
|
|
57
|
+
const fromEnv = (env[FROM_RUN_ENV] ?? "").trim();
|
|
58
|
+
const runPath = option.path?.trim() ?? (fromEnv || undefined);
|
|
59
|
+
if (runPath === undefined) {
|
|
60
|
+
if (option.mode !== undefined)
|
|
61
|
+
return { ok: false, error: `${names.mode} applies to a run started from an earlier one: give ${names.path} as well` };
|
|
62
|
+
if (option.turnsPerPage !== undefined)
|
|
63
|
+
return { ok: false, error: `${perPageName} applies to a run started from an earlier one: give ${names.path} as well` };
|
|
64
|
+
return { ok: true };
|
|
65
|
+
}
|
|
66
|
+
const modeEnv = (env[FROM_RUN_MODE_ENV] ?? "").trim();
|
|
67
|
+
const [raw, source] = option.mode !== undefined ? [option.mode.trim(), names.mode] : modeEnv ? [modeEnv, FROM_RUN_MODE_ENV] : [DEFAULT_FROM_RUN_MODE, ""];
|
|
68
|
+
if (!FROM_RUN_MODES.includes(raw))
|
|
69
|
+
return { ok: false, error: `${source} must be one of ${FROM_RUN_MODES.join(", ")} (got ${JSON.stringify(raw)})` };
|
|
70
|
+
const perPageEnv = (env[FROM_RUN_TURNS_PER_PAGE_ENV] ?? "").trim();
|
|
71
|
+
const [perRaw, perSource] = option.turnsPerPage !== undefined
|
|
72
|
+
? [option.turnsPerPage.trim(), perPageName]
|
|
73
|
+
: perPageEnv
|
|
74
|
+
? [perPageEnv, FROM_RUN_TURNS_PER_PAGE_ENV]
|
|
75
|
+
: [String(DEFAULT_TURNS_PER_PAGE), ""];
|
|
76
|
+
const perPage = Number(perRaw);
|
|
77
|
+
if (!/^\d+$/.test(perRaw) || perPage < TURNS_PER_PAGE_RANGE.min || perPage > TURNS_PER_PAGE_RANGE.max)
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
error: `${perSource} must be a whole number from ${TURNS_PER_PAGE_RANGE.min} to ${TURNS_PER_PAGE_RANGE.max} (got ${JSON.stringify(perRaw)})`,
|
|
81
|
+
};
|
|
82
|
+
return { ok: true, fromRun: { path: runPath, mode: raw, turnsPerPage: perPage } };
|
|
83
|
+
}
|
|
84
|
+
/** How many pages a continued run (or one lane of it) takes on: its turns over the turns per page, at least one. */
|
|
85
|
+
export function pageCap(turns, turnsPerPage) {
|
|
86
|
+
return Math.max(1, Math.floor(turns / Math.max(1, turnsPerPage)));
|
|
87
|
+
}
|
|
88
|
+
/** The most steps a record keeps: a replay of more would not fit a model's first message anyway. */
|
|
89
|
+
export const MAX_RECORD_STEPS = 400;
|
|
90
|
+
/** The most routes a record lists in any one list. */
|
|
91
|
+
export const MAX_RECORD_ROUTES = 300;
|
|
92
|
+
/** The most controls, forms or options a record keeps per route. */
|
|
93
|
+
export const MAX_RECORD_KEYS = 40;
|
|
94
|
+
/** The most records a project's memory keeps; the oldest go first. */
|
|
95
|
+
export const MAX_RECORDS = 20;
|
|
96
|
+
/** A route's identity for matching: two spellings of one page are one route. */
|
|
97
|
+
const identity = (route) => normalizePath(route);
|
|
98
|
+
/**
|
|
99
|
+
* The log's actions that are a session working on a page, as opposed to
|
|
100
|
+
* planning, signing in or bookkeeping. Whole names only: a refused click
|
|
101
|
+
* (`click:refused`) did nothing, and a replay must not repeat it.
|
|
102
|
+
*/
|
|
103
|
+
const STEP_ACTION = /^(?:plan:)?(?:snapshot|navigate|click(?:×\d+)?|type|select|press|hover|upload|back)$/;
|
|
104
|
+
/** A typed value as the action log writes it (`← "…"`): never kept, since it may be a password the redaction cannot recognise. */
|
|
105
|
+
const TYPED_VALUE = /← "(?:[^"\\]|\\.)*"/g;
|
|
106
|
+
const uniqueBy = (items, key) => {
|
|
107
|
+
const seen = new Set();
|
|
108
|
+
return items.filter((item) => {
|
|
109
|
+
const k = key(item);
|
|
110
|
+
if (seen.has(k))
|
|
111
|
+
return false;
|
|
112
|
+
seen.add(k);
|
|
113
|
+
return true;
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* A run's record. Only the routes the run worked on (a step other than the
|
|
118
|
+
* planning crawl landed there, by a session that did more than look) count as
|
|
119
|
+
* visited, so a crawl that opened every page does not make every page read as
|
|
120
|
+
* covered; what is left is kept for those routes alone.
|
|
121
|
+
*/
|
|
122
|
+
export function buildRunRecord(input) {
|
|
123
|
+
const all = [];
|
|
124
|
+
for (const s of input.steps) {
|
|
125
|
+
if (!STEP_ACTION.test(s.action) || !s.url || s.url === "about:blank")
|
|
126
|
+
continue;
|
|
127
|
+
const target = s.target?.replace(TYPED_VALUE, "← (a value)").slice(0, 200);
|
|
128
|
+
all.push({ session: s.session || "default", route: identity(s.url), action: s.action, ...(target ? { target } : {}) });
|
|
129
|
+
}
|
|
130
|
+
// A session that only ever looked (a planner's snapshot before it split the run into lanes) worked on nothing.
|
|
131
|
+
const acted = new Set(all.filter((s) => s.action !== "snapshot").map((s) => s.session));
|
|
132
|
+
const steps = all.filter((s) => acted.has(s.session));
|
|
133
|
+
const lanes = [];
|
|
134
|
+
for (const s of steps) {
|
|
135
|
+
let lane = lanes.find((l) => l.session === s.session);
|
|
136
|
+
if (!lane)
|
|
137
|
+
lanes.push((lane = { session: s.session, routes: [] }));
|
|
138
|
+
if (!lane.routes.includes(s.route))
|
|
139
|
+
lane.routes.push(s.route);
|
|
140
|
+
}
|
|
141
|
+
const visited = uniqueBy(steps.map((s) => s.route), (r) => r);
|
|
142
|
+
const filled = new Set(input.filled.map(identity));
|
|
143
|
+
const left = [];
|
|
144
|
+
for (const route of visited) {
|
|
145
|
+
const on = (r) => identity(r) === route;
|
|
146
|
+
const entry = {
|
|
147
|
+
route,
|
|
148
|
+
unexercised: [...new Set(input.unexercised.filter((u) => on(u.route)).flatMap((u) => u.keys))].slice(0, MAX_RECORD_KEYS),
|
|
149
|
+
forms: [...new Set(input.forms.filter((f) => on(f.route)).map((f) => f.key))].slice(0, MAX_RECORD_KEYS),
|
|
150
|
+
...(filled.has(route) ? { filled: true } : {}),
|
|
151
|
+
unchosen: input.unchosen
|
|
152
|
+
.filter((d) => on(d.route) && d.unchosen.length > 0)
|
|
153
|
+
.map((d) => ({ key: d.key, options: [...d.unchosen].slice(0, MAX_RECORD_KEYS) }))
|
|
154
|
+
.slice(0, MAX_RECORD_KEYS),
|
|
155
|
+
};
|
|
156
|
+
if (workLeft(entry) > 0)
|
|
157
|
+
left.push(entry);
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
version: 1,
|
|
161
|
+
runId: input.runId,
|
|
162
|
+
at: input.at,
|
|
163
|
+
knownRoutes: uniqueBy(input.knownRoutes, identity).slice(0, MAX_RECORD_ROUTES),
|
|
164
|
+
visited: visited.slice(0, MAX_RECORD_ROUTES),
|
|
165
|
+
lanes: lanes.map((l) => ({ session: l.session, routes: l.routes.slice(0, MAX_RECORD_ROUTES) })),
|
|
166
|
+
steps: steps.slice(0, MAX_RECORD_STEPS),
|
|
167
|
+
left: left.slice(0, MAX_RECORD_ROUTES),
|
|
168
|
+
gaps: input.gaps.slice(0, 40),
|
|
169
|
+
...(input.followed ? { followed: input.followed } : {}),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/** How much a run left on a route: every control, form and option it did not get to, and a filled form never submitted. */
|
|
173
|
+
export function workLeft(l) {
|
|
174
|
+
return l.unexercised.length + l.forms.length + (l.filled ? 1 : 0) + l.unchosen.reduce((n, d) => n + d.options.length, 0);
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* A record that carries the one it continued: this run's routes and what it
|
|
178
|
+
* left on them, then the earlier run's visited routes, and what that run left
|
|
179
|
+
* on routes this run did not work on. So a chain of runs, each continuing the
|
|
180
|
+
* last, accumulates what the chain has covered rather than forgetting it. The
|
|
181
|
+
* steps, lanes and gap ledger stay this run's own.
|
|
182
|
+
*/
|
|
183
|
+
export function carryForward(earlier, current) {
|
|
184
|
+
const worked = new Set(current.lanes.flatMap((l) => l.routes).map(identity));
|
|
185
|
+
return {
|
|
186
|
+
...current,
|
|
187
|
+
knownRoutes: uniqueBy([...current.knownRoutes, ...earlier.knownRoutes], identity).slice(0, MAX_RECORD_ROUTES),
|
|
188
|
+
visited: uniqueBy([...current.visited, ...earlier.visited], identity).slice(0, MAX_RECORD_ROUTES),
|
|
189
|
+
left: uniqueBy([...current.left, ...earlier.left.filter((l) => !worked.has(identity(l.route)))], (l) => identity(l.route)).slice(0, MAX_RECORD_ROUTES),
|
|
190
|
+
...(current.assigned || earlier.assigned
|
|
191
|
+
? { assigned: uniqueBy([...(earlier.assigned ?? []), ...(current.assigned ?? [])], identity).slice(0, MAX_RECORD_ROUTES) }
|
|
192
|
+
: {}),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/** A project's records as one, oldest carried into newest: what every run on it has covered and left. Null when there are none. */
|
|
196
|
+
export function foldRecords(records) {
|
|
197
|
+
const sorted = [...records].sort((a, b) => a.at.localeCompare(b.at));
|
|
198
|
+
return sorted.reduce((acc, r) => (acc ? carryForward(acc, r) : r), null);
|
|
199
|
+
}
|
|
200
|
+
const isStrings = (v) => Array.isArray(v) && v.every((x) => typeof x === "string");
|
|
201
|
+
const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
202
|
+
/** A record read back from JSON, checked in full; null when anything about it is not a record this version wrote. */
|
|
203
|
+
export function readRunRecord(raw) {
|
|
204
|
+
if (!isObj(raw) || raw.version !== 1 || typeof raw.runId !== "string" || typeof raw.at !== "string")
|
|
205
|
+
return null;
|
|
206
|
+
if (!isStrings(raw.knownRoutes) || !isStrings(raw.visited) || !isStrings(raw.gaps))
|
|
207
|
+
return null;
|
|
208
|
+
const lanes = raw.lanes;
|
|
209
|
+
if (!Array.isArray(lanes) || !lanes.every((l) => isObj(l) && typeof l.session === "string" && isStrings(l.routes)))
|
|
210
|
+
return null;
|
|
211
|
+
const steps = raw.steps;
|
|
212
|
+
if (!Array.isArray(steps) ||
|
|
213
|
+
!steps.every((s) => isObj(s) &&
|
|
214
|
+
typeof s.session === "string" &&
|
|
215
|
+
typeof s.route === "string" &&
|
|
216
|
+
typeof s.action === "string" &&
|
|
217
|
+
(s.target === undefined || typeof s.target === "string")))
|
|
218
|
+
return null;
|
|
219
|
+
const left = raw.left;
|
|
220
|
+
if (!Array.isArray(left) ||
|
|
221
|
+
!left.every((l) => isObj(l) &&
|
|
222
|
+
typeof l.route === "string" &&
|
|
223
|
+
isStrings(l.unexercised) &&
|
|
224
|
+
isStrings(l.forms) &&
|
|
225
|
+
(l.filled === undefined || l.filled === true) &&
|
|
226
|
+
Array.isArray(l.unchosen) &&
|
|
227
|
+
l.unchosen.every((d) => isObj(d) && typeof d.key === "string" && isStrings(d.options))))
|
|
228
|
+
return null;
|
|
229
|
+
const prefixes = raw.prefixes;
|
|
230
|
+
if (prefixes !== undefined &&
|
|
231
|
+
!(Array.isArray(prefixes) &&
|
|
232
|
+
prefixes.every((n) => isObj(n) &&
|
|
233
|
+
typeof n.session === "string" &&
|
|
234
|
+
typeof n.target === "string" &&
|
|
235
|
+
typeof n.steps === "number" &&
|
|
236
|
+
(n.outcome === "replayed" || n.outcome === "navigated" || n.outcome === "unreached") &&
|
|
237
|
+
(n.why === undefined || typeof n.why === "string"))))
|
|
238
|
+
return null;
|
|
239
|
+
if (raw.continuedFresh !== undefined && raw.continuedFresh !== true)
|
|
240
|
+
return null;
|
|
241
|
+
if (raw.assigned !== undefined && !isStrings(raw.assigned))
|
|
242
|
+
return null;
|
|
243
|
+
const followed = raw.followed;
|
|
244
|
+
if (followed !== undefined &&
|
|
245
|
+
!(isObj(followed) && FROM_RUN_MODES.includes(followed.mode) && typeof followed.runId === "string"))
|
|
246
|
+
return null;
|
|
247
|
+
return raw;
|
|
248
|
+
}
|
|
249
|
+
/** Records from a parsed memory file. Anything malformed is left out. */
|
|
250
|
+
export function readRunRecords(raw) {
|
|
251
|
+
const list = isObj(raw) ? raw.runRecords : undefined;
|
|
252
|
+
if (!Array.isArray(list))
|
|
253
|
+
return [];
|
|
254
|
+
return list.map(readRunRecord).filter((r) => r !== null);
|
|
255
|
+
}
|
|
256
|
+
/** Two lists of records as one: each run once, oldest first, at most MAX_RECORDS. Idempotent. */
|
|
257
|
+
export function unionRecords(a, b) {
|
|
258
|
+
const byRun = new Map();
|
|
259
|
+
for (const r of [...a, ...b]) {
|
|
260
|
+
const had = byRun.get(r.runId);
|
|
261
|
+
// A run that wrote its report twice keeps the later record.
|
|
262
|
+
if (!had || had.at <= r.at)
|
|
263
|
+
byRun.set(r.runId, r);
|
|
264
|
+
}
|
|
265
|
+
return [...byRun.values()].sort((x, y) => x.at.localeCompare(y.at) || x.runId.localeCompare(y.runId)).slice(-MAX_RECORDS);
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* The record a parsed file holds: a ci.json's `record`, a memory file's
|
|
269
|
+
* records folded into one, or a bare record. `what` names the file for the error.
|
|
270
|
+
*/
|
|
271
|
+
export function recordFromJson(raw, what) {
|
|
272
|
+
if (isObj(raw) && raw.command === "ci") {
|
|
273
|
+
const record = readRunRecord(raw.record);
|
|
274
|
+
return record
|
|
275
|
+
? { ok: true, record }
|
|
276
|
+
: { ok: false, error: `${what} holds no run record: its run wrote no report, or was made by a version that kept none` };
|
|
277
|
+
}
|
|
278
|
+
if (isObj(raw) && raw.version === 1 && "states" in raw) {
|
|
279
|
+
const record = foldRecords(readRunRecords(raw));
|
|
280
|
+
return record
|
|
281
|
+
? { ok: true, record }
|
|
282
|
+
: { ok: false, error: `${what} holds no run record: no run on this project has written its report since records were kept` };
|
|
283
|
+
}
|
|
284
|
+
const record = readRunRecord(raw);
|
|
285
|
+
return record ? { ok: true, record } : { ok: false, error: `${what} is not a ci.json, a project's memory or a run record` };
|
|
286
|
+
}
|
|
287
|
+
// ── continue ─────────────────────────────────────────────────────────────────
|
|
288
|
+
/**
|
|
289
|
+
* How much work a page may have left and still count as worked through: none.
|
|
290
|
+
* A visited page is "covered" only when the record says every control on it
|
|
291
|
+
* was exercised, every form submitted and every option chosen; visiting it is
|
|
292
|
+
* not enough.
|
|
293
|
+
*/
|
|
294
|
+
export const EXHAUSTED_AT = 0;
|
|
295
|
+
/**
|
|
296
|
+
* The order a continued run takes its routes in. `routesNow` are the routes
|
|
297
|
+
* this run knows (its planning crawl), in the form it navigates by; the
|
|
298
|
+
* record's known and visited routes join them, so a route the earlier run
|
|
299
|
+
* found and this one has not yet is not lost.
|
|
300
|
+
*
|
|
301
|
+
* 1. Routes the record never worked on, by name.
|
|
302
|
+
* 2. Routes it worked on with work left, the most left first, then by name.
|
|
303
|
+
* 3. Routes it worked on and left nothing on, by name.
|
|
304
|
+
*
|
|
305
|
+
* Deterministic: the same record and routes give the same order, whatever
|
|
306
|
+
* order they came in.
|
|
307
|
+
*/
|
|
308
|
+
export function continuePlan(record, routesNow) {
|
|
309
|
+
const candidates = uniqueBy([...routesNow, ...record.knownRoutes, ...record.visited], identity);
|
|
310
|
+
const visited = new Set(record.visited.map(identity));
|
|
311
|
+
const leftBy = new Map(record.left.map((l) => [identity(l.route), l]));
|
|
312
|
+
const byName = (a, b) => a.route.localeCompare(b.route);
|
|
313
|
+
const items = candidates.map((route) => {
|
|
314
|
+
const id = identity(route);
|
|
315
|
+
if (!visited.has(id))
|
|
316
|
+
return { route, tier: 1 };
|
|
317
|
+
const work = leftBy.get(id);
|
|
318
|
+
const plan = prefixPlan(record, route);
|
|
319
|
+
const path = plan && "steps" in plan && !plan.direct ? { path: pathLine(plan.steps) } : {};
|
|
320
|
+
return work && workLeft(work) > EXHAUSTED_AT ? { route, tier: 2, work, ...path } : { route, tier: 3, ...path };
|
|
321
|
+
});
|
|
322
|
+
// Pages an earlier continued run was already given go after the ones none was, so a chain moves on down the order;
|
|
323
|
+
// once every page with work has been given out, they come round again by the work left.
|
|
324
|
+
const given = new Set((record.assigned ?? []).map(identity));
|
|
325
|
+
for (const i of items)
|
|
326
|
+
if (i.tier !== 3 && given.has(identity(i.route)))
|
|
327
|
+
i.assigned = true;
|
|
328
|
+
const open = (fresh) => [
|
|
329
|
+
...items.filter((i) => i.tier === 1 && !i.assigned === fresh).sort(byName),
|
|
330
|
+
...items.filter((i) => i.tier === 2 && !i.assigned === fresh).sort((a, b) => workLeft(b.work) - workLeft(a.work) || byName(a, b)),
|
|
331
|
+
];
|
|
332
|
+
return [...open(true), ...open(false), ...items.filter((i) => i.tier === 3).sort(byName)];
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* The pages a continued run (or a lane, given `only`) takes on: the first
|
|
336
|
+
* `cap` in the plan's order that have work left or were never worked on.
|
|
337
|
+
* Empty when there are none (continueExhausted).
|
|
338
|
+
*/
|
|
339
|
+
export function takenPages(items, cap, only) {
|
|
340
|
+
const mine = only ? new Set(only.map(identity)) : null;
|
|
341
|
+
return items
|
|
342
|
+
.filter((i) => i.tier !== 3 && (!mine || mine.has(identity(i.route))))
|
|
343
|
+
.slice(0, Math.max(0, cap))
|
|
344
|
+
.map((i) => i.route);
|
|
345
|
+
}
|
|
346
|
+
/** The most routes, and controls per route, a continued run's message spells out. */
|
|
347
|
+
const LINES_MAX = { routes: 30, keys: 8 };
|
|
348
|
+
const listOf = (xs, max) => xs.slice(0, max).join(", ") + (xs.length > max ? ` … +${xs.length - max}` : "");
|
|
349
|
+
/** What to do first on one route an earlier run left work on, as one line. */
|
|
350
|
+
export function workLine(l) {
|
|
351
|
+
const parts = [];
|
|
352
|
+
if (l.forms.length > 0)
|
|
353
|
+
parts.push(`submit the form(s) never submitted: ${listOf(l.forms, LINES_MAX.keys)}`);
|
|
354
|
+
if (l.filled)
|
|
355
|
+
parts.push(`a form was filled in and never submitted: fill it and submit it`);
|
|
356
|
+
if (l.unchosen.length > 0)
|
|
357
|
+
parts.push(`choose the options never chosen: ${l.unchosen
|
|
358
|
+
.slice(0, LINES_MAX.keys)
|
|
359
|
+
.map((d) => `${d.key} → ${d.options.map((o) => JSON.stringify(o)).join(", ")}`)
|
|
360
|
+
.join("; ")}`);
|
|
361
|
+
if (l.unexercised.length > 0)
|
|
362
|
+
parts.push(`controls never exercised: ${listOf(l.unexercised, LINES_MAX.keys)}`);
|
|
363
|
+
return `${l.route}: ${parts.join("; ")}`;
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* The lines a continued run's first message (or a lane's) carries: the three
|
|
367
|
+
* tiers, and on each route with work left exactly what to take first. Given
|
|
368
|
+
* `only`, the routes outside it are left out: a lane is told about its own.
|
|
369
|
+
*/
|
|
370
|
+
/**
|
|
371
|
+
* A capped continued run's lines: only its first `cap` pages with work, each
|
|
372
|
+
* to be worked deeply, and the pages after them in order for when those are
|
|
373
|
+
* done. A run with few turns that spreads over every page files little on any.
|
|
374
|
+
*/
|
|
375
|
+
function cappedLines(mine, from, cap) {
|
|
376
|
+
const open = mine.filter((i) => i.tier !== 3);
|
|
377
|
+
const taken = open.slice(0, Math.max(1, cap));
|
|
378
|
+
const next = open.slice(taken.length);
|
|
379
|
+
return [
|
|
380
|
+
`This run continues an earlier one (${from}). Take on ${taken.length === 1 ? "this page" : `these ${taken.length} pages`}, in order, and work each deeply before anything else: every control never exercised, every form never submitted (fill it and submit it), every option never chosen. Do not spread out over other pages.`,
|
|
381
|
+
...taken.flatMap((i) => [
|
|
382
|
+
i.tier === 1 || !i.work
|
|
383
|
+
? ` ${i.route}: never worked on by it: exercise every control, submit every form, choose every option.`
|
|
384
|
+
: ` ${workLine(i.work)}`,
|
|
385
|
+
...(i.path ? [` (it reached this page by: ${i.path})`] : []),
|
|
386
|
+
]),
|
|
387
|
+
next.length > 0
|
|
388
|
+
? `Only once those are done, with budget left, take the next pages in this order: ${listOf(next.map((i) => i.route), LINES_MAX.routes)}.`
|
|
389
|
+
: `Once those are done, keep exploring them and the pages around them until the budget is spent.`,
|
|
390
|
+
];
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Whether a continued run has nothing to continue: every route the plan holds
|
|
394
|
+
* was worked through. The run then explores as a fresh one would, from the
|
|
395
|
+
* landing page with nothing left out, rather than being told it is done.
|
|
396
|
+
*/
|
|
397
|
+
export function continueExhausted(items) {
|
|
398
|
+
return !items.some((i) => i.tier !== 3);
|
|
399
|
+
}
|
|
400
|
+
/** What a run (or a lane) is told when nothing it was given has recorded work left: explore as a fresh run, and never stop for it. */
|
|
401
|
+
function freshLines(from, lane) {
|
|
402
|
+
return [
|
|
403
|
+
`This run continues an earlier one (${from}), which left no recorded work on ${lane ? "your routes" : "any route"}: every control it saw was exercised, every form submitted, every option chosen.`,
|
|
404
|
+
`That is not a reason to stop. Explore ${lane ? "your routes" : "the app"} as a fresh run would, from ${lane ? "your first route" : "the landing page"}, with nothing left out, and spend the budget.`,
|
|
405
|
+
];
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* The lines a continued run's first message (or a lane's) carries: the three
|
|
409
|
+
* tiers, and on each route with work left exactly what to take first. Given
|
|
410
|
+
* `only`, the routes outside it are left out: a lane is told about its own.
|
|
411
|
+
* When none of its routes has work left, it is told to explore as a fresh run
|
|
412
|
+
* (freshLines); it is never told there is nothing to do.
|
|
413
|
+
*/
|
|
414
|
+
export function continueLines(items, o) {
|
|
415
|
+
const only = o.only ? new Set(o.only.map(identity)) : null;
|
|
416
|
+
const mine = only ? items.filter((i) => only.has(identity(i.route))) : items;
|
|
417
|
+
if (mine.length === 0)
|
|
418
|
+
return [];
|
|
419
|
+
if (continueExhausted(mine))
|
|
420
|
+
return freshLines(o.from, only !== null);
|
|
421
|
+
if (o.cap !== undefined)
|
|
422
|
+
return cappedLines(mine, o.from, o.cap);
|
|
423
|
+
const tier = (t) => mine.filter((i) => i.tier === t);
|
|
424
|
+
const [never, left, covered] = [tier(1), tier(2), tier(3)];
|
|
425
|
+
return [
|
|
426
|
+
`This run continues an earlier one (${o.from}): take the routes in this order, so the run starts where that one left off.`,
|
|
427
|
+
never.length > 0
|
|
428
|
+
? `- First, the routes it never worked on: ${listOf(never.map((i) => i.route), LINES_MAX.routes)}`
|
|
429
|
+
: "",
|
|
430
|
+
...(left.length > 0
|
|
431
|
+
? [
|
|
432
|
+
`- ${never.length > 0 ? "Then" : "First"}, the routes it left work on, the most first. On each, do this before anything else:`,
|
|
433
|
+
...left.slice(0, LINES_MAX.routes).flatMap((i) => [` ${workLine(i.work)}`, ...(i.path ? [` (it reached this page by: ${i.path})`] : [])]),
|
|
434
|
+
...(left.length > LINES_MAX.routes ? [` … and ${left.length - LINES_MAX.routes} more route(s)`] : []),
|
|
435
|
+
]
|
|
436
|
+
: []),
|
|
437
|
+
covered.length > 0
|
|
438
|
+
? `- Last, the routes it worked through (nothing it recorded is left there): ${listOf(covered.map((i) => i.route), LINES_MAX.routes)}`
|
|
439
|
+
: "",
|
|
440
|
+
`This is where to start, not the whole job: when the listed work is done, keep exploring as a fresh run would until the budget is spent.`,
|
|
441
|
+
].filter(Boolean);
|
|
442
|
+
}
|
|
443
|
+
/** The most steps one scout_run_plan call runs, and so the longest path replayed. */
|
|
444
|
+
export const MAX_PREFIX_STEPS = 20;
|
|
445
|
+
/** A route that is a pattern (`/orders/:id`) names no page a browser can open. */
|
|
446
|
+
export const isPattern = (route) => /(^|\/)[:*]|\[[^\]]+\]/.test(route);
|
|
447
|
+
/** A control as the log names it (`button "Save"`) as a plan target: by role and name, quoted so the name survives. */
|
|
448
|
+
function roleTarget(role, name) {
|
|
449
|
+
if (!name.includes('"'))
|
|
450
|
+
return `role=${role}[name="${name}"]`;
|
|
451
|
+
if (!name.includes("'"))
|
|
452
|
+
return `role=${role}[name='${name}']`;
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* A value to type where the earlier run's was not kept (typed values never
|
|
457
|
+
* are): one a field of that name accepts, so a form that needs a value moves
|
|
458
|
+
* on as it did.
|
|
459
|
+
*/
|
|
460
|
+
export function standInValue(fieldName) {
|
|
461
|
+
const n = fieldName.toLowerCase();
|
|
462
|
+
if (/e-?mail/.test(n))
|
|
463
|
+
return "scout@example.com";
|
|
464
|
+
if (/phone|tel/.test(n))
|
|
465
|
+
return "5550100";
|
|
466
|
+
if (/qty|quantity|amount|number|count|price|age/.test(n))
|
|
467
|
+
return "1";
|
|
468
|
+
if (/date/.test(n))
|
|
469
|
+
return "2026-01-01";
|
|
470
|
+
if (/url|website|link/.test(n))
|
|
471
|
+
return "https://example.com";
|
|
472
|
+
return "SceneScout test";
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* One recorded step as a plan step, or a reason it cannot be one. `back`
|
|
476
|
+
* becomes a navigation to the page it landed on, which the path visited.
|
|
477
|
+
*/
|
|
478
|
+
export function planStepOf(step) {
|
|
479
|
+
const action = step.action.replace(/^plan:/, "").replace(/×\d+$/, "");
|
|
480
|
+
const t = step.target ?? "";
|
|
481
|
+
// A plan step's own target is already a semantic one.
|
|
482
|
+
const semantic = /^(testid|text|label|role)=/.test(t);
|
|
483
|
+
const control = /^([a-z]+) "(.*)$/.exec(t);
|
|
484
|
+
const split = (sep) => {
|
|
485
|
+
if (!control)
|
|
486
|
+
return null;
|
|
487
|
+
const at = control[2].indexOf(sep);
|
|
488
|
+
return at < 0 ? null : [control[2].slice(0, at), control[2].slice(at + sep.length)];
|
|
489
|
+
};
|
|
490
|
+
switch (action) {
|
|
491
|
+
case "navigate": {
|
|
492
|
+
if (!t)
|
|
493
|
+
return { cannot: "a navigation with no address" };
|
|
494
|
+
try {
|
|
495
|
+
const u = new URL(t, "http://x");
|
|
496
|
+
return { action: "navigate", target: `${u.pathname}${u.search}` };
|
|
497
|
+
}
|
|
498
|
+
catch {
|
|
499
|
+
return { cannot: `an address that does not parse (${t.slice(0, 60)})` };
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
case "back":
|
|
503
|
+
return isPattern(step.route) ? { cannot: `going back to ${step.route}, a pattern` } : { action: "navigate", target: step.route };
|
|
504
|
+
case "press":
|
|
505
|
+
return t ? { action: "press", value: t } : { cannot: "a key press with no key" };
|
|
506
|
+
case "click":
|
|
507
|
+
case "hover": {
|
|
508
|
+
if (semantic)
|
|
509
|
+
return { action, target: t };
|
|
510
|
+
const name = control && control[2].endsWith('"') ? control[2].slice(0, -1) : null;
|
|
511
|
+
const target = control && name !== null ? roleTarget(control[1], name) : null;
|
|
512
|
+
return target ? { action, target } : { cannot: `a control the log does not name (${t.slice(0, 60)})` };
|
|
513
|
+
}
|
|
514
|
+
case "type": {
|
|
515
|
+
if (semantic)
|
|
516
|
+
return { action: "type", target: t, value: standInValue(t), replace: true };
|
|
517
|
+
const parts = split('" ← ');
|
|
518
|
+
const target = control && parts ? roleTarget(control[1], parts[0]) : null;
|
|
519
|
+
if (!target || !parts)
|
|
520
|
+
return { cannot: `a field the log does not name (${t.slice(0, 60)})` };
|
|
521
|
+
return { action: "type", target, value: standInValue(parts[0]), replace: true, ...(/ \+ Enter\b/.test(parts[1]) ? { pressEnter: true } : {}) };
|
|
522
|
+
}
|
|
523
|
+
case "select": {
|
|
524
|
+
const parts = split('" = ');
|
|
525
|
+
const target = control && parts ? roleTarget(control[1], parts[0]) : null;
|
|
526
|
+
if (!target || !parts)
|
|
527
|
+
return { cannot: `a dropdown the log does not name (${t.slice(0, 60)})` };
|
|
528
|
+
return { action: "select", target, value: parts[1].replace(/ \(matched option .*$/, "") };
|
|
529
|
+
}
|
|
530
|
+
default:
|
|
531
|
+
return { cannot: `a ${action} step, which a plan cannot repeat` };
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* The path the earlier run took to a page, as plan steps: in the session that
|
|
536
|
+
* first reached it, from the last page it opened by address up to the step
|
|
537
|
+
* that landed there, leaving out snapshots. `direct` when the page was itself
|
|
538
|
+
* opened by address, so no step on another page is needed. Null when the
|
|
539
|
+
* record never reached the page; `cannot` when a step of the path cannot be
|
|
540
|
+
* repeated or the path is longer than one plan.
|
|
541
|
+
*/
|
|
542
|
+
export function prefixPlan(record, target) {
|
|
543
|
+
const id = identity(target);
|
|
544
|
+
const first = record.steps.find((s) => s.route === id && s.action !== "snapshot");
|
|
545
|
+
if (!first)
|
|
546
|
+
return null;
|
|
547
|
+
const mine = record.steps.filter((s) => s.session === first.session);
|
|
548
|
+
const k = mine.indexOf(first);
|
|
549
|
+
const isNav = (s) => /^(plan:)?navigate$/.test(s.action);
|
|
550
|
+
let j = k;
|
|
551
|
+
while (j >= 0 && !isNav(mine[j]))
|
|
552
|
+
j -= 1;
|
|
553
|
+
const path = mine.slice(Math.max(j, 0), k + 1).filter((s) => s.action !== "snapshot");
|
|
554
|
+
// A session that never opened a page by address started where it attached: the first page it was on.
|
|
555
|
+
// Only a snapshot says which page that was: any other step's route is where the step landed, not where it began.
|
|
556
|
+
if (j < 0 && mine[0].action !== "snapshot")
|
|
557
|
+
return { cannot: "the session opened no page by its address before reaching it" };
|
|
558
|
+
if (j < 0 && isPattern(mine[0].route))
|
|
559
|
+
return { cannot: `the session started on ${mine[0].route}, a pattern` };
|
|
560
|
+
const start = j < 0 ? [{ session: first.session, route: mine[0].route, action: "navigate", target: mine[0].route }] : [];
|
|
561
|
+
const all = [...start, ...path];
|
|
562
|
+
if (all.length > MAX_PREFIX_STEPS)
|
|
563
|
+
return { cannot: `the path is ${all.length} steps, more than one plan runs (${MAX_PREFIX_STEPS})` };
|
|
564
|
+
const steps = [];
|
|
565
|
+
for (const s of all) {
|
|
566
|
+
const p = planStepOf(s);
|
|
567
|
+
if ("cannot" in p)
|
|
568
|
+
return { cannot: p.cannot };
|
|
569
|
+
steps.push(p);
|
|
570
|
+
}
|
|
571
|
+
return { steps, direct: steps.every((s) => s.action === "navigate"), session: first.session };
|
|
572
|
+
}
|
|
573
|
+
/** A path as one line for a model to follow. */
|
|
574
|
+
export function pathLine(steps) {
|
|
575
|
+
return steps.map((s) => `${s.action}${s.target ? ` ${s.target}` : ""}${s.value && s.action !== "type" ? ` ${JSON.stringify(s.value)}` : ""}`).join(" → ");
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Whether a scout_run_plan reply says the path was taken: every step ran and
|
|
579
|
+
* ended OK, and the last one ended on the target page. A step can end OK and
|
|
580
|
+
* leave the page where it was (a stand-in value the form refuses, a button
|
|
581
|
+
* that now goes elsewhere), so where it ended is checked too.
|
|
582
|
+
*/
|
|
583
|
+
export function prefixOutcome(reply, steps, target) {
|
|
584
|
+
const ran = /^PLAN \((\d+)\/(\d+) steps ran\)/m.exec(reply);
|
|
585
|
+
if (/^ERROR:/.test(reply))
|
|
586
|
+
return {
|
|
587
|
+
ok: false,
|
|
588
|
+
why: reply
|
|
589
|
+
.replace(/^ERROR:\s*/, "")
|
|
590
|
+
.split("\n")[0]
|
|
591
|
+
.slice(0, 200),
|
|
592
|
+
};
|
|
593
|
+
if (!ran)
|
|
594
|
+
return { ok: false, why: "the plan's reply did not say how many steps ran" };
|
|
595
|
+
// A step that ran and did not succeed still counts as run: its own line says how it ended, and only "OK" moved the path on.
|
|
596
|
+
const notOk = reply.split("\n").find((l) => /^\d+\. .* → /.test(l) && !/ → OK\b/.test(l));
|
|
597
|
+
if (/PLAN ABORTED at step/.test(reply) || Number(ran[1]) < steps || notOk)
|
|
598
|
+
return { ok: false, why: `${ran[1]} of ${steps} step(s) ran${notOk ? `: ${notOk.trim().slice(0, 160)}` : ""}` };
|
|
599
|
+
const ends = [...reply.matchAll(/^\d+\. .* → OK \(([^)\s]+)\)/gm)].at(-1)?.[1];
|
|
600
|
+
if (!ends)
|
|
601
|
+
return { ok: false, why: "the plan did not say which page it ended on" };
|
|
602
|
+
if (identity(ends) !== identity(target))
|
|
603
|
+
return { ok: false, why: `every step ran, and it ended on ${identity(ends)}, not ${identity(target)}` };
|
|
604
|
+
return { ok: true };
|
|
605
|
+
}
|
|
606
|
+
/** The recorded run's sessions with their routes and steps, in recorded order; a session that took no step is left out. */
|
|
607
|
+
export function replayPlan(record) {
|
|
608
|
+
return record.lanes
|
|
609
|
+
.map((l) => ({ session: l.session, routes: [...l.routes], steps: record.steps.filter((s) => s.session === l.session) }))
|
|
610
|
+
.filter((l) => l.steps.length > 0);
|
|
611
|
+
}
|
|
612
|
+
/** The most steps a replay's message lists. */
|
|
613
|
+
const REPLAY_STEPS_MAX = 150;
|
|
614
|
+
/** A replayed session's steps, numbered, one per line. */
|
|
615
|
+
export function replayLines(lane, o) {
|
|
616
|
+
const shown = lane.steps.slice(0, REPLAY_STEPS_MAX);
|
|
617
|
+
return [
|
|
618
|
+
`This run replays an earlier one (${o.from}): follow its route and step order exactly, one step at a time, and file what you find as usual. Where a step's control is gone or the page differs, say so in a note and take the next step.`,
|
|
619
|
+
`Routes, in order: ${listOf(lane.routes, LINES_MAX.routes)}`,
|
|
620
|
+
`Steps (${lane.steps.length}):`,
|
|
621
|
+
...shown.map((s, i) => ` ${i + 1}. ${s.action}${s.target ? ` ${s.target}` : ""} → ${s.route}`),
|
|
622
|
+
...(lane.steps.length > shown.length
|
|
623
|
+
? [` … ${lane.steps.length - shown.length} more step(s) not listed: carry on in the same spirit until the budget ends.`]
|
|
624
|
+
: []),
|
|
625
|
+
];
|
|
626
|
+
}
|
|
627
|
+
/** Notes from a parsed memory file. Anything malformed is left out. */
|
|
628
|
+
export function readFromRunNotes(raw) {
|
|
629
|
+
const list = isObj(raw) ? raw.fromRuns : undefined;
|
|
630
|
+
if (!Array.isArray(list))
|
|
631
|
+
return [];
|
|
632
|
+
return list.filter((n) => isObj(n) &&
|
|
633
|
+
typeof n.at === "string" &&
|
|
634
|
+
FROM_RUN_MODES.includes(n.mode) &&
|
|
635
|
+
typeof n.source === "string" &&
|
|
636
|
+
typeof n.runId === "string" &&
|
|
637
|
+
typeof n.recordAt === "string");
|
|
638
|
+
}
|
|
639
|
+
/** Two lists of notes as one: each once, oldest first, at most MAX_RECORDS. Idempotent. */
|
|
640
|
+
export function unionFromRunNotes(a, b) {
|
|
641
|
+
const byKey = new Map();
|
|
642
|
+
for (const n of [...a, ...b])
|
|
643
|
+
byKey.set(`${n.at}\u0000${n.mode}\u0000${n.runId}`, n);
|
|
644
|
+
return [...byKey.values()].sort((x, y) => x.at.localeCompare(y.at)).slice(-MAX_RECORDS);
|
|
645
|
+
}
|
|
646
|
+
/** One line on what a run started from, for a log, a brief, a summary or a report. */
|
|
647
|
+
export function fromRunLine(n) {
|
|
648
|
+
return `${n.mode === "continue" ? "continued from" : "replayed"} the run recorded in ${n.source} (its report of ${n.recordAt})`;
|
|
649
|
+
}
|