scenescout 3.23.2 → 3.23.3

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 CHANGED
@@ -1,5 +1,12 @@
1
1
  # scenescout
2
2
 
3
+ ## 3.23.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 43012d7: The live board's report panel keeps showing a finding's pictures when sessions in two project folders are attached at once and the session that filed the finding has closed. The report now names the project it describes, and the board loads its pictures from that folder rather than from whichever project is attached first.
8
+ - 1cda780: The live view's run page keeps showing its pictures after a session attaches to another project folder. Its picture addresses now name the project the page was written for, so a page already open in a browser loads them from that folder rather than from whichever project attached last.
9
+
3
10
  ## 3.23.2
4
11
 
5
12
  ### Patch Changes
@@ -297,6 +297,8 @@ export const LIVE_PAGE = `<!doctype html>
297
297
  var reportKey = null;
298
298
  // Per-finding frames from api/report: empty unless the run was recorded.
299
299
  var reportEvidence = [];
300
+ // Where the report's pictures are served from: the project it was rendered from (api/report names it).
301
+ var reportFramePrefix = 'record/';
300
302
  var savedACopy = false;
301
303
  // A result that reads as a failure is shown in red.
302
304
  var BAD_RESULT = /error|fail|refus|block|violation|abandoned/i;
@@ -509,13 +511,13 @@ export const LIVE_PAGE = `<!doctype html>
509
511
  picture = el('figure', 'picture');
510
512
  picture.setAttribute('data-testid', 'live-report-picture-' + id);
511
513
  var link = el('a');
512
- link.href = 'record/' + found.picture.file;
514
+ link.href = reportFramePrefix + found.picture.file;
513
515
  link.target = '_blank';
514
516
  link.rel = 'noreferrer';
515
517
  link.setAttribute('data-testid', 'live-report-picture-open');
516
518
  var pic = el('img');
517
519
  pic.loading = 'lazy';
518
- pic.src = 'record/' + found.picture.file;
520
+ pic.src = reportFramePrefix + found.picture.file;
519
521
  pic.alt = 'What the finding is about, when it was filed';
520
522
  pic.addEventListener('error', function () { picture.classList.add('gone'); });
521
523
  link.appendChild(pic);
@@ -533,7 +535,7 @@ export const LIVE_PAGE = `<!doctype html>
533
535
  var fig = el('figure');
534
536
  var img = el('img');
535
537
  img.loading = 'lazy';
536
- img.src = 'record/' + f.frame;
538
+ img.src = reportFramePrefix + f.frame;
537
539
  img.alt = f.action + ' ' + f.detail;
538
540
  // A recording deleted since the run leaves a broken icon under a finding
539
541
  // that still counts it as evidence. Say which it is.
@@ -607,7 +609,7 @@ export const LIVE_PAGE = `<!doctype html>
607
609
  var fig = el('figure', 'picture');
608
610
  var pic = el('img');
609
611
  pic.loading = 'lazy';
610
- pic.src = 'record/' + m[2];
612
+ pic.src = reportFramePrefix + m[2];
611
613
  pic.alt = m[1];
612
614
  fig.appendChild(pic);
613
615
  container.appendChild(fig);
@@ -680,12 +682,13 @@ export const LIVE_PAGE = `<!doctype html>
680
682
  }
681
683
  reportMarkdown = d.markdown;
682
684
  reportEvidence = d.evidence || [];
685
+ reportFramePrefix = d.framePrefix || 'record/';
683
686
  meta.textContent = (finished ? 'as the run left it at ' : 'as the run stands at ') + clock(d.at) + ' · ' + whereItIs();
684
687
  // The report is re-read every few seconds while the run goes on.
685
688
  // Re-rendering an unchanged document threw away what the reader was
686
689
  // doing with it: an opened accordion shut itself, and the page jumped
687
690
  // back to the top, every five seconds.
688
- var key = d.markdown + '\\u0000' + JSON.stringify(reportEvidence);
691
+ var key = d.markdown + '\\u0000' + JSON.stringify(reportEvidence) + '\\u0000' + reportFramePrefix;
689
692
  if (key === reportKey) return;
690
693
  var open = {};
691
694
  var was = doc.querySelectorAll('details[open][data-testid]');
@@ -18,6 +18,7 @@
18
18
  * Everything here is pure string work — no browser, no filesystem — so the
19
19
  * escaping and the grouping are table-tested.
20
20
  */
21
+ import crypto from "node:crypto";
21
22
  import path from "node:path";
22
23
  import { isSafeRelativePath } from "./plain.js";
23
24
  /** Most frames one recorded session keeps. A long run is thousands of actions, and a project folder is not a video store. */
@@ -59,6 +60,39 @@ export function resolveFrame(root, relPath) {
59
60
  const file = path.resolve(base, inside);
60
61
  return file.startsWith(base + path.sep) ? file : null;
61
62
  }
63
+ /**
64
+ * A short name for a project folder, used in the live view's frame addresses so
65
+ * a page says which project its pictures belong to. One engine can serve runs
66
+ * in several folders in turn, and a page already open in a browser loads its
67
+ * images later, after another folder may have attached. The name is derived
68
+ * from the folder's resolved path and reveals nothing of it.
69
+ */
70
+ export function projectKey(dir) {
71
+ return "p-" + crypto.createHash("sha256").update(path.resolve(dir)).digest("hex").slice(0, 12);
72
+ }
73
+ /** A frame address split into the project it names, when it names one, and the path inside that project's recordings. */
74
+ export function splitProjectFrame(relPath) {
75
+ const m = /^(p-[0-9a-f]{12})\/(.+)$/.exec(relPath);
76
+ return m ? { key: m[1], rest: m[2] } : { rest: relPath };
77
+ }
78
+ /**
79
+ * The project folders this process has rendered a run page or a board report
80
+ * for, by key. Only a folder one was rendered from can be named, so a viewer
81
+ * cannot reach any other folder by guessing a key.
82
+ */
83
+ export class ProjectDirs {
84
+ byKey = new Map();
85
+ /** Remember `dir` and return its key. */
86
+ remember(dir) {
87
+ const key = projectKey(dir);
88
+ this.byKey.set(key, dir);
89
+ return key;
90
+ }
91
+ /** The folder a key names, or undefined when nothing was rendered from one with that key. */
92
+ dir(key) {
93
+ return this.byKey.get(key);
94
+ }
95
+ }
62
96
  /** Text from the app under test reaches this document, so nothing is interpolated unescaped. */
63
97
  export function escapeHtml(text) {
64
98
  return text.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
@@ -4,7 +4,7 @@ import { baseRoute, reachedRoutes, routeIdentity, isEmbedKey, isWorthALook, judg
4
4
  import { sayVerification } from "./verify.js";
5
5
  import { describePicture, readFindingPicture } from "./capture.js";
6
6
  import { feedForSession } from "./live.js";
7
- import { buildReplayHtml, evidenceFor } from "./replay.js";
7
+ import { buildReplayHtml, evidenceFor, projectKey } from "./replay.js";
8
8
  import { calibrate, formatCalibration } from "./calibration.js";
9
9
  import { formatPace, measurePace } from "./pace.js";
10
10
  import { formatNeverSubmittedEmpty } from "./forms.js";
@@ -221,7 +221,8 @@ export function replayDocument(memory, markdown, version = "") {
221
221
  project: projectName(memory.dir),
222
222
  at: new Date().toISOString(),
223
223
  version,
224
- framePrefix: "record/",
224
+ // Keyed by project, so the page's pictures still load after a session attaches to another folder (replay.ts).
225
+ framePrefix: `record/${projectKey(memory.dir)}/`,
225
226
  savedAt: memory.dir,
226
227
  });
227
228
  }
@@ -70,7 +70,7 @@ import { CRITERION_VERDICTS, findCriterion, formatCriteriaForLanes, formatReadin
70
70
  import { ACTION_TIMEOUT_ENV, DEFAULT_ACTION_TIMEOUT_MS, DEFAULT_CRAWL_NAV_TIMEOUT_MS, DEFAULT_NAV_TIMEOUT_MS, LIMIT_BOUNDS, NAV_TIMEOUT_ENV, watchdogFor, } from "./engine/limits.js";
71
71
  import { MAX_READ_POSTS, READ_POSTS_ENV } from "./engine/policy.js";
72
72
  import { chooseProjectFolder, PROJECTS_DIR_ENV, workspaceFromRoots } from "./engine/project-folder.js";
73
- import { RECORD_MAX_FRAMES, resolveFrame } from "./engine/replay.js";
73
+ import { ProjectDirs, RECORD_MAX_FRAMES, resolveFrame, splitProjectFrame } from "./engine/replay.js";
74
74
  import { describePace, normalizePace } from "./engine/settle.js";
75
75
  import { needsTask, taskRefusal, TASK_MAX } from "./engine/task.js";
76
76
  import { formElicitationSupported, INTAKE_FORM_MESSAGE, INTAKE_TOOL, intakeFormSchema, intakeFromForm, intakeReplyText } from "./intake.js";
@@ -149,6 +149,8 @@ let liveError = null;
149
149
  * found — which is the moment somebody most wants to read it.
150
150
  */
151
151
  let lastRun = null;
152
+ /** Every project folder a run page or the board's report has been rendered from, so their pictures resolve against it later (#457, #463). */
153
+ const pageProjects = new ProjectDirs();
152
154
  /** Render the report for a session that is about to close, so the live view keeps it. */
153
155
  function keepReport(eng) {
154
156
  if (!eng.memory)
@@ -167,6 +169,7 @@ function keepReport(eng) {
167
169
  }
168
170
  try {
169
171
  lastRun.evidence = reportEvidence(eng.memory);
172
+ pageProjects.remember(eng.memory.dir);
170
173
  lastRun.replay = replayDocument(eng.memory, lastRun.markdown, PKG_VERSION);
171
174
  }
172
175
  catch (err) {
@@ -185,6 +188,10 @@ function reportFile(dir) {
185
188
  return { path: file, written: false };
186
189
  }
187
190
  }
191
+ /** The address a report's frames start with: keyed by project, and the folder is remembered so the key resolves (#463). */
192
+ function framePrefixFor(dir) {
193
+ return `record/${pageProjects.remember(dir)}/`;
194
+ }
188
195
  const liveProvider = {
189
196
  snapshot: () => ({
190
197
  pid: process.pid,
@@ -204,9 +211,14 @@ const liveProvider = {
204
211
  report: () => {
205
212
  const eng = (lastWriter && engines.get(lastWriter.session)) ?? engines.values().next().value;
206
213
  if (!eng?.memory)
207
- return lastRun ? { markdown: lastRun.markdown, at: lastRun.at, evidence: lastRun.evidence } : null;
214
+ return lastRun ? { markdown: lastRun.markdown, at: lastRun.at, evidence: lastRun.evidence, framePrefix: framePrefixFor(lastRun.dir) } : null;
208
215
  const { markdown } = generateReport(eng.memory, eng.oracleLog.all, reportExtras(eng), { write: false });
209
- return { markdown: redactSecrets(markdown), at: new Date().toISOString(), evidence: reportEvidence(eng.memory) };
216
+ return {
217
+ markdown: redactSecrets(markdown),
218
+ at: new Date().toISOString(),
219
+ evidence: reportEvidence(eng.memory),
220
+ framePrefix: framePrefixFor(eng.memory.dir),
221
+ };
210
222
  },
211
223
  /**
212
224
  * The whole run as one page, served at its own address. The live view sends
@@ -219,6 +231,7 @@ const liveProvider = {
219
231
  return lastRun?.replay || null;
220
232
  try {
221
233
  const { markdown } = generateReport(eng.memory, eng.oracleLog.all, reportExtras(eng), { write: false });
234
+ pageProjects.remember(eng.memory.dir);
222
235
  return replayDocument(eng.memory, redactSecrets(markdown), PKG_VERSION);
223
236
  }
224
237
  catch (err) {
@@ -233,12 +246,19 @@ const liveProvider = {
233
246
  * comes from a viewer, so it is resolved and then required to still be
234
247
  * inside that directory: nothing else in the project is reachable this way.
235
248
  */
236
- frame: async (relPath) => {
237
- // Sessions may hold different project directories, so the frame belongs to
238
- // the session its own path names — not to whichever engine happens to be
239
- // first. Falling back keeps a finished run's frames reachable.
240
- const named = relPath.replace(/^recordings[\\/]/, "").split("/")[0];
241
- const dir = engines.get(named)?.memory?.dir ?? engines.values().next().value?.memory?.dir ?? lastRun?.dir;
249
+ frame: async (address) => {
250
+ // A run page names the project it was rendered from, so its pictures load
251
+ // after a session attaches to another folder. The live board's own frames
252
+ // carry no project: they belong to the session their path names, and
253
+ // falling back keeps a finished run's frames reachable.
254
+ const { key, rest: relPath } = splitProjectFrame(address);
255
+ let dir;
256
+ if (key)
257
+ dir = pageProjects.dir(key);
258
+ else {
259
+ const named = relPath.replace(/^recordings[\\/]/, "").split("/")[0];
260
+ dir = engines.get(named)?.memory?.dir ?? engines.values().next().value?.memory?.dir ?? lastRun?.dir;
261
+ }
242
262
  if (!dir)
243
263
  return null;
244
264
  const file = resolveFrame(path.join(dir, "recordings"), relPath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenescout",
3
- "version": "3.23.2",
3
+ "version": "3.23.3",
4
4
  "description": "SceneScout — exploratory UI testing for AI coding agents. An MCP server that gives any agent (Claude Code, Cursor, VS Code Copilot, Codex, Gemini CLI and others) a structured view of a running web app, always-on oracles, a network-level write policy, memory across runs and a gap-checked report.",
5
5
  "license": "MIT",
6
6
  "author": "brunoboto96",