scenescout 3.13.1 → 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.
@@ -0,0 +1,392 @@
1
+ /**
2
+ * Named sign-in profiles: a role's saved browser state, recorded once by
3
+ * `scenescout login <url> --role <name>` and attached by any number of
4
+ * sessions with `scout_attach { role }`.
5
+ *
6
+ * Everything here is Playwright-free so it can be table-tested: where a
7
+ * profile lives, which role names are allowed, how the file is written
8
+ * (owner-only), and how an attach turns `role` into the storage state it
9
+ * loads. The browser half — opening a window and reading its state back — is
10
+ * in login-run.ts.
11
+ *
12
+ * A profile is a Playwright storage state (live session cookies, and
13
+ * localStorage and IndexedDB per origin) for the app it was recorded against,
14
+ * plus the sessionStorage Playwright's storage state leaves out: several
15
+ * single-page-app sign-in libraries keep their tokens there, so without it a
16
+ * restored session looks signed out. It is never printed,
17
+ * never logged, and never leaves the project's .scenescout/ directory, which
18
+ * ignores itself in git.
19
+ */
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+ import { BROWSER_ENGINES } from "../browsers.js";
23
+ import { MEMORY_DIRNAME, writeSelfIgnore } from "./memory.js";
24
+ import { SCRIPT_FLAGS } from "./scripted-login.js";
25
+ /** The directory under .scenescout/ that holds one file per role. */
26
+ export const AUTH_DIRNAME = "auth";
27
+ /** Owner read/write only: a profile is a live session. */
28
+ export const PROFILE_FILE_MODE = 0o600;
29
+ /** Owner only: listing the directory already says which roles have sessions. */
30
+ export const PROFILE_DIR_MODE = 0o700;
31
+ /** Longest role name. It becomes a filename and a label on the live view. */
32
+ export const ROLE_NAME_MAX = 40;
33
+ /**
34
+ * A role name: letters, digits, `-` and `_`, starting with a letter or digit,
35
+ * compared and stored lower-case.
36
+ * Nothing that could be a path (no `/`, `\`, `.`), nothing hidden, nothing a
37
+ * shell would need quoting for.
38
+ */
39
+ const ROLE_NAME_RE = new RegExp(`^[A-Za-z0-9][A-Za-z0-9_-]{0,${ROLE_NAME_MAX - 1}}$`);
40
+ /**
41
+ * Names Windows refuses as a file whatever the extension, compared without case.
42
+ * "anonymous" is what a session with no sign-in is called, so a profile under that
43
+ * name would make two different sessions read alike.
44
+ */
45
+ const RESERVED_ROLE_NAMES = new Set(["con", "prn", "aux", "nul", ...Array.from({ length: 9 }, (_, i) => [`com${i + 1}`, `lpt${i + 1}`]).flat(), "anonymous"]);
46
+ /** Validate a role name at the boundary, before it goes anywhere near a path. */
47
+ export function validateRoleName(raw) {
48
+ if (typeof raw !== "string" || raw.length === 0)
49
+ return { ok: false, error: "give a role name, e.g. --role admin" };
50
+ if (!ROLE_NAME_RE.test(raw)) {
51
+ return {
52
+ ok: false,
53
+ error: `role "${raw.slice(0, 60)}" is not allowed: use up to ${ROLE_NAME_MAX} letters, digits, "-" or "_", starting with a letter or digit`,
54
+ };
55
+ }
56
+ if (RESERVED_ROLE_NAMES.has(raw.toLowerCase()))
57
+ return { ok: false, error: `role "${raw}" is a reserved name; choose another` };
58
+ // Lower-cased: on a case-insensitive filesystem "Admin" and "admin" are one
59
+ // file, and recording one would silently replace the other.
60
+ return { ok: true, role: raw.toLowerCase() };
61
+ }
62
+ /** Where a project's profiles live. */
63
+ export function profileDir(projectDir) {
64
+ return path.join(path.resolve(projectDir), MEMORY_DIRNAME, AUTH_DIRNAME);
65
+ }
66
+ /** Where one role's profile lives. Throws on a name that did not pass validateRoleName: a caller that skipped the check is a bug. */
67
+ export function profilePath(projectDir, role) {
68
+ const checked = validateRoleName(role);
69
+ if (!checked.ok)
70
+ throw new Error(checked.error);
71
+ const dir = profileDir(projectDir);
72
+ const file = path.join(dir, `${checked.role}.json`);
73
+ // Belt and braces: the name rules already make this impossible.
74
+ if (path.dirname(file) !== dir)
75
+ throw new Error(`role "${role}" resolves outside ${dir}`);
76
+ return file;
77
+ }
78
+ /** The roles with a saved profile, sorted. A project with no profile directory has none; any other read error is thrown. */
79
+ export function listProfiles(projectDir, readdir = (d) => fs.readdirSync(d)) {
80
+ let names;
81
+ try {
82
+ names = readdir(profileDir(projectDir));
83
+ }
84
+ catch (err) {
85
+ if (err.code === "ENOENT")
86
+ return [];
87
+ throw err;
88
+ }
89
+ return names
90
+ .filter((n) => n.endsWith(".json"))
91
+ .map((n) => n.slice(0, -".json".length))
92
+ .filter((n) => validateRoleName(n).ok)
93
+ .sort();
94
+ }
95
+ /**
96
+ * The key the restore script leaves in a tab's sessionStorage once it has
97
+ * seeded that origin, so a page reloaded or navigated within the tab keeps
98
+ * what the app has since written (or removed) instead of being seeded again.
99
+ * Never captured into a profile.
100
+ */
101
+ export const SESSION_RESTORED_MARKER = "__scenescout_session_restored";
102
+ /** A real origin a script can match against `location.origin`; opaque ("null"), about:, data: and file: frames have nothing to keep. */
103
+ function isWebOrigin(origin) {
104
+ if (typeof origin !== "string")
105
+ return false;
106
+ try {
107
+ const url = new URL(origin);
108
+ return (url.protocol === "http:" || url.protocol === "https:") && url.origin === origin;
109
+ }
110
+ catch {
111
+ return false;
112
+ }
113
+ }
114
+ /**
115
+ * Merge what each frame of each open tab held in sessionStorage into one list
116
+ * per origin. Two tabs on one origin each have their own sessionStorage; the
117
+ * profile keeps one, so a later read of the same key wins. Opaque origins,
118
+ * non-string values and the restore marker are dropped; an origin left with
119
+ * nothing is left out.
120
+ */
121
+ export function mergeSessionStorage(frames) {
122
+ const byOrigin = new Map();
123
+ for (const frame of frames) {
124
+ if (!isWebOrigin(frame.origin) || !Array.isArray(frame.entries))
125
+ continue;
126
+ const kept = byOrigin.get(frame.origin) ?? new Map();
127
+ for (const entry of frame.entries) {
128
+ if (!Array.isArray(entry) || entry.length !== 2)
129
+ continue;
130
+ const [name, value] = entry;
131
+ if (typeof name !== "string" || typeof value !== "string" || name === SESSION_RESTORED_MARKER)
132
+ continue;
133
+ kept.set(name, value);
134
+ }
135
+ byOrigin.set(frame.origin, kept);
136
+ }
137
+ return [...byOrigin]
138
+ .filter(([, entries]) => entries.size > 0)
139
+ .sort(([a], [b]) => a.localeCompare(b))
140
+ .map(([origin, entries]) => ({ origin, entries: [...entries].map(([name, value]) => ({ name, value })) }));
141
+ }
142
+ /** A storage state with sessionStorage added, when there is any to add. */
143
+ export function withSessionStorage(state, sessionStorage) {
144
+ if (sessionStorage.length === 0 || !state || typeof state !== "object")
145
+ return state;
146
+ return { ...state, sessionStorage };
147
+ }
148
+ /**
149
+ * Split a profile read from disk into what Playwright restores (the storage
150
+ * state, handed to newContext) and the sessionStorage restored by script.
151
+ * A malformed sessionStorage list is refused rather than half-applied: a
152
+ * session that silently loses its token tests a signed-out app.
153
+ */
154
+ export function splitProfile(raw) {
155
+ const checked = summarizeState(raw);
156
+ if (!checked.ok)
157
+ throw new Error(checked.error);
158
+ const { sessionStorage, ...storageState } = raw;
159
+ if (sessionStorage === undefined)
160
+ return { storageState, sessionStorage: [] };
161
+ const valid = Array.isArray(sessionStorage) &&
162
+ sessionStorage.every((o) => o && isWebOrigin(o.origin) && Array.isArray(o.entries) && o.entries.every((e) => e && typeof e.name === "string" && typeof e.value === "string"));
163
+ if (!valid)
164
+ throw new Error("the profile's sessionStorage list is malformed: record it again with `scenescout login`");
165
+ return { storageState, sessionStorage };
166
+ }
167
+ /**
168
+ * The init script that restores sessionStorage. It runs in every frame before
169
+ * the page's own code, and writes only into a frame whose origin is one the
170
+ * profile holds, only once per tab: after that the app's own writes stand.
171
+ * An app that signs out with sessionStorage.clear() would take the marker with
172
+ * it and be signed back in on the next page, so in those frames clear() puts
173
+ * the marker back after clearing. Values go in as JSON, so nothing in them is
174
+ * ever run. No sessionStorage to restore: no script.
175
+ *
176
+ * A re-attach passes a `generation` of its own. Its script is added after the
177
+ * attach's, so it runs after it in every frame: it seeds a tab once more, from
178
+ * the latest profile, when the tab's marker is not yet that generation, and
179
+ * leaves the marker set to it, so from then on the app's own writes stand
180
+ * again. The attach's script, which seeds only a tab with no marker at all,
181
+ * never restores the older sign-in over it.
182
+ */
183
+ export function sessionStorageInitScript(sessionStorage, opts = {}) {
184
+ if (sessionStorage.length === 0)
185
+ return null;
186
+ const seed = {};
187
+ for (const o of sessionStorage)
188
+ seed[o.origin] = o.entries.map((e) => [e.name, e.value]);
189
+ const generation = opts.generation ?? null;
190
+ if (generation === "1")
191
+ throw new Error('a re-attach generation must not be "1", the mark of the first restore');
192
+ return `(() => {
193
+ const seed = ${JSON.stringify(seed)};
194
+ const marker = ${JSON.stringify(SESSION_RESTORED_MARKER)};
195
+ const generation = ${JSON.stringify(generation)};
196
+ const mark = generation ?? "1";
197
+ if (!Object.prototype.hasOwnProperty.call(seed, location.origin)) return;
198
+ const proto = Object.getPrototypeOf(sessionStorage);
199
+ const clear = proto.clear;
200
+ proto.clear = function () {
201
+ clear.call(this);
202
+ if (this === sessionStorage) this.setItem(marker, mark);
203
+ };
204
+ const seen = sessionStorage.getItem(marker);
205
+ if (generation === null ? seen !== null : seen === generation) return;
206
+ for (const [name, value] of seed[location.origin]) sessionStorage.setItem(name, value);
207
+ sessionStorage.setItem(marker, mark);
208
+ })();`;
209
+ }
210
+ /** Check the shape of a storage state before writing it; a profile nobody can load is worse than none. */
211
+ export function summarizeState(state) {
212
+ if (!state || typeof state !== "object")
213
+ return { ok: false, error: "the browser returned no storage state" };
214
+ const s = state;
215
+ if (!Array.isArray(s.cookies) || !Array.isArray(s.origins))
216
+ return { ok: false, error: "the storage state has no cookies or origins list" };
217
+ const indexedDBs = s.origins.reduce((n, o) => {
218
+ const dbs = o && typeof o === "object" ? o.indexedDB : undefined;
219
+ return n + (Array.isArray(dbs) ? dbs.length : 0);
220
+ }, 0);
221
+ const sessionOrigins = Array.isArray(s.sessionStorage) ? s.sessionStorage.length : 0;
222
+ return { ok: true, summary: { cookies: s.cookies.length, origins: s.origins.length, sessionOrigins, indexedDBs } };
223
+ }
224
+ /** The one line printed after saving: where, and how much. Never the contents. */
225
+ export function describeSaved(file, summary) {
226
+ const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
227
+ return (`Saved to ${file} (${plural(summary.cookies, "cookie")}, ${plural(summary.origins, "origin")} with local storage or IndexedDB, ` +
228
+ `${plural(summary.sessionOrigins, "origin")} with session storage, ${plural(summary.indexedDBs, "IndexedDB database")}).`);
229
+ }
230
+ /**
231
+ * Write a profile, owner-only. The directory is created 0700 (and tightened
232
+ * if it already existed looser), the file is written under a temporary name
233
+ * created 0600 and renamed into place, so a half-written profile is never
234
+ * attached and the contents never sit in a file anyone else can read, even
235
+ * for a moment. .scenescout/ gets its self-ignoring .gitignore if it has
236
+ * none, so a profile recorded before the first run is still never staged.
237
+ */
238
+ export function writeProfile(projectDir, role, state) {
239
+ const file = profilePath(projectDir, role);
240
+ const checked = summarizeState(state);
241
+ if (!checked.ok)
242
+ throw new Error(checked.error);
243
+ const dir = path.dirname(file);
244
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
245
+ writeSelfIgnore(path.dirname(dir));
246
+ fs.mkdirSync(dir, { recursive: true, mode: PROFILE_DIR_MODE });
247
+ fs.chmodSync(dir, PROFILE_DIR_MODE);
248
+ const temp = `${file}.${process.pid}.tmp`;
249
+ // A temporary file left by a crashed write may have any mode: remove it, then
250
+ // create afresh ("wx") so the mode given here is the one the file is born with.
251
+ fs.rmSync(temp, { force: true });
252
+ const fd = fs.openSync(temp, "wx", PROFILE_FILE_MODE);
253
+ try {
254
+ fs.writeFileSync(fd, JSON.stringify(state));
255
+ }
256
+ finally {
257
+ fs.closeSync(fd);
258
+ }
259
+ // No chmod needed: a umask can only take bits away from 0600.
260
+ fs.renameSync(temp, file);
261
+ return { path: file, summary: checked.summary };
262
+ }
263
+ /**
264
+ * Whether a file mode lets anyone but the owner read or write it. Windows
265
+ * has no such bits to read, so it is never judged there.
266
+ */
267
+ export function isLooserThanOwner(mode, platform = process.platform) {
268
+ if (platform === "win32")
269
+ return false;
270
+ return (mode & 0o077) !== 0;
271
+ }
272
+ /**
273
+ * What an attach says about a profile others can read — someone loosened it,
274
+ * or copied it in with a wider mode. Said, not refused: the session is still
275
+ * the one the person recorded, and tightening it is one command.
276
+ */
277
+ export function permissionNote(file, mode, platform = process.platform) {
278
+ if (!isLooserThanOwner(mode, platform))
279
+ return null;
280
+ return `The profile at ${file} can be read by other accounts on this machine (mode ${(mode & 0o777).toString(8)}); it holds a live session. Tighten it: chmod 600 "${file}"`;
281
+ }
282
+ /** The command that records a role's profile, with the URL when it is known. */
283
+ export function loginCommand(role, url) {
284
+ return `scenescout login ${url ?? "<url>"} --role ${role}`;
285
+ }
286
+ /**
287
+ * Turn scout_attach's `role` and `storageStatePath` into the one storage state
288
+ * the session loads. Both at once is refused rather than letting one win
289
+ * silently. A missing profile names the command that records it.
290
+ */
291
+ export function resolveAttachAuth(opts, exists = fs.existsSync, listRoles = listProfiles) {
292
+ if (opts.role !== undefined && opts.storageStatePath !== undefined) {
293
+ throw new Error("pass role or storageStatePath, not both: role loads the profile saved by `scenescout login`, storageStatePath loads a file you name");
294
+ }
295
+ if (opts.role !== undefined) {
296
+ const checked = validateRoleName(opts.role);
297
+ if (!checked.ok)
298
+ throw new Error(checked.error);
299
+ const file = profilePath(opts.projectDir, checked.role);
300
+ if (!exists(file)) {
301
+ let saved = [];
302
+ try {
303
+ saved = listRoles(opts.projectDir);
304
+ }
305
+ catch {
306
+ /* the list is a hint in the message; the refusal stands without it */
307
+ }
308
+ throw new Error(`no sign-in is saved for role "${checked.role}" in this project. Run \`${loginCommand(checked.role, opts.url)}\`, sign in in the window it opens, then attach again.` +
309
+ (saved.length > 0 ? ` Saved roles: ${saved.join(", ")}.` : ""));
310
+ }
311
+ return { kind: "role", role: checked.role, storageStatePath: file };
312
+ }
313
+ if (opts.storageStatePath !== undefined)
314
+ return { kind: "file", storageStatePath: opts.storageStatePath };
315
+ return { kind: "none" };
316
+ }
317
+ /** The name a session is shown under: its role, the file's name, or anonymous. */
318
+ export function roleLabel(auth) {
319
+ if (auth.kind === "role")
320
+ return auth.role;
321
+ if (auth.kind === "file")
322
+ return path.basename(auth.storageStatePath).replace(/\.json$/i, "");
323
+ return "anonymous";
324
+ }
325
+ export const LOGIN_OPTION_NAMES = ["role", "project", "browser"];
326
+ /**
327
+ * Parse `scenescout login <url> --role <name> [--project dir] [--browser engine]
328
+ * [--script [--success-url …] [--timeout s] …]`. `--script` takes no value; the
329
+ * flags only it reads are refused without it.
330
+ */
331
+ export function parseLoginArgs(args, cwd) {
332
+ const positional = [];
333
+ const flags = new Map();
334
+ let script = false;
335
+ for (let i = 0; i < args.length; i++) {
336
+ const a = args[i];
337
+ if (!a.startsWith("--")) {
338
+ positional.push(a);
339
+ continue;
340
+ }
341
+ if (a === "--script") {
342
+ script = true;
343
+ continue;
344
+ }
345
+ const eq = a.indexOf("=");
346
+ const name = eq > 0 ? a.slice(2, eq) : a.slice(2);
347
+ const value = eq > 0 ? a.slice(eq + 1) : args[i + 1];
348
+ if (value === undefined || (eq < 0 && value.startsWith("--")))
349
+ return { ok: false, error: `--${name} needs a value` };
350
+ if (eq < 0)
351
+ i += 1;
352
+ flags.set(name, value);
353
+ }
354
+ const known = new Set(LOGIN_OPTION_NAMES);
355
+ const scriptOnly = new Set(SCRIPT_FLAGS);
356
+ for (const name of flags.keys()) {
357
+ if (scriptOnly.has(name) && !script)
358
+ return { ok: false, error: `--${name} only applies with --script` };
359
+ if (!known.has(name) && !scriptOnly.has(name))
360
+ return { ok: false, error: `unknown option --${name}` };
361
+ }
362
+ if (positional.length !== 1)
363
+ return { ok: false, error: "give exactly one URL to sign in at, e.g. scenescout login http://127.0.0.1:3000 --role admin" };
364
+ let url;
365
+ try {
366
+ url = new URL(positional[0]);
367
+ }
368
+ catch {
369
+ return { ok: false, error: `not a URL: ${positional[0]}` };
370
+ }
371
+ if (url.protocol !== "http:" && url.protocol !== "https:")
372
+ return { ok: false, error: `only http and https URLs can be opened (got ${url.protocol})` };
373
+ if (url.username || url.password)
374
+ return { ok: false, error: "put no credentials in the URL: sign in in the window instead" };
375
+ const role = validateRoleName(flags.get("role"));
376
+ if (!role.ok)
377
+ return { ok: false, error: role.error };
378
+ const browser = flags.get("browser");
379
+ if (browser !== undefined && !BROWSER_ENGINES.includes(browser)) {
380
+ return { ok: false, error: `--browser must be one of ${BROWSER_ENGINES.join(", ")}` };
381
+ }
382
+ return {
383
+ ok: true,
384
+ options: {
385
+ url: url.href,
386
+ role: role.role,
387
+ projectDir: path.resolve(cwd, flags.get("project") ?? "."),
388
+ ...(browser ? { browser: browser } : {}),
389
+ ...(script ? { script: new Map([...flags].filter(([name]) => scriptOnly.has(name))) } : {}),
390
+ },
391
+ };
392
+ }