shots capture: signed-in web apps (shots.capture.auth request with ${cred:}/${env:} references or storageState), shots login saves a demo sign-in to .storebound/auth.json (0600), expired-session warning, secrets and cookies masked

This commit is contained in:
CRTkafa 2026-10-20 12:41:43 +07:00
parent 3949ff1531
commit e1e4e05237
15 changed files with 545 additions and 9 deletions

179
cli/shots/auth.ts Normal file
View file

@ -0,0 +1,179 @@
// Signed-in web capture (shots.capture.auth). Two ways in, applied to every browser context before its first shot:
// request { request: { url, method?, headers?, body? } }: one HTTP call per context through the context's own
// request API, so the cookies it sets stay in that context. Secrets are references only:
// "${cred:NAME}" (.storebound/credentials.json: reviewDemoAccount.username / .password, the same with
// REVIEW_DEMO_USER / REVIEW_DEMO_PASSWORD from the environment, or captureAuth.NAME) or "${env:NAME}".
// A password-like key with a literal value is refused. A non-2xx/3xx status stops the capture with the
// status, never the values.
// storageState { storageState: ".storebound/auth.json" }: Playwright storage state (cookies + localStorage) saved by
// `storebound shots login`, loaded into every context. It must live in .storebound/ (gitignored).
// Resolved values are collected so every log line, error and report text can be masked (maskSecrets).
import { existsSync, readFileSync } from 'node:fs';
import { join, resolve as resolvePath } from 'node:path';
import { reviewDemoAccount } from '../lib/credentials';
export interface AuthRequest {
/** Absolute URL, or a path after shots.capture.baseUrl (e.g. "/api/login"). */
url: string;
/** Default POST. */
method?: string;
headers?: Record<string, string>;
/** An object is sent as JSON, a string as is. Secrets as "${cred:NAME}" / "${env:NAME}" only. */
body?: Record<string, unknown> | string;
}
export interface CaptureAuth {
request?: AuthRequest;
/** Playwright storage state file in .storebound/ (from `storebound shots login`). */
storageState?: string;
/** Pattern (regex source) of the login page URL; a shot that lands there warns. Default /login|signin|sign-in|auth/. */
loginUrl?: string;
}
export const DEFAULT_STATE = '.storebound/auth.json';
const REF = /\$\{(cred|env):([A-Za-z0-9_.-]+)\}/g;
const SECRET_KEY = /pass(word)?|pwd|secret|token|api[-_]?key|authorization|cookie|session|otp|pin$|credential/i;
/** Problems with shots.capture.auth (pure): shape, a gitignored state path, and literal secrets. */
export function validateAuth(a: CaptureAuth | undefined): string[] {
if (!a) return [];
const e: string[] = [];
if (!!a.request === !!a.storageState) e.push('shots.capture.auth: either { request: { url, method, headers, body } } or { storageState: ".storebound/auth.json" }');
if (a.storageState && !/^\.storebound[\\/]/.test(a.storageState)) e.push('shots.capture.auth.storageState: a file in .storebound/ (gitignored), e.g. ".storebound/auth.json"');
if (a.request) {
if (!a.request.url) e.push('shots.capture.auth.request.url: the sign-in endpoint (absolute, or a path after baseUrl)');
for (const k of literalSecrets(a.request)) e.push(`shots.capture.auth.request.${k}: a secret is never written in the config; use "\${cred:NAME}" (.storebound/credentials.json) or "\${env:NAME}"`);
}
if (a.loginUrl) try { new RegExp(a.loginUrl); } catch { e.push('shots.capture.auth.loginUrl: a regular expression'); }
return e;
}
/** Paths (headers.X, body.y) whose key looks like a secret but whose value is not only references. */
export function literalSecrets(r: AuthRequest): string[] {
const out: string[] = [];
const literal = (v: unknown): boolean => typeof v === 'string' ? v.replace(REF, '').replace(/^(Bearer|Basic|Token)\s+/i, '').trim() !== '' : v !== undefined && v !== null && typeof v !== 'object';
const walk = (o: unknown, path: string): void => {
if (!o || typeof o !== 'object') return;
for (const [k, v] of Object.entries(o as Record<string, unknown>)) {
if (v && typeof v === 'object') walk(v, `${path}.${k}`);
else if (SECRET_KEY.test(k) && literal(v)) out.push(`${path}.${k}`);
}
};
walk(r.headers, 'headers');
if (typeof r.body === 'string') {
// a form or JSON string body: check its fields the same way
let parsed: unknown = null;
try { parsed = JSON.parse(r.body); } catch { if (r.body.includes('=')) parsed = Object.fromEntries(new URLSearchParams(r.body)); }
walk(parsed, 'body');
} else walk(r.body, 'body');
return out;
}
/** Where references resolve: credentials.json (reviewDemoAccount, captureAuth) and the environment. */
export function credLookup(root: string, env: Record<string, string | undefined> = process.env): (kind: 'cred' | 'env', name: string) => string | undefined {
let store: Record<string, unknown> | null = null;
const file = (): Record<string, unknown> => {
if (store) return store;
const p = join(root, '.storebound', 'credentials.json');
try { store = existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) as Record<string, unknown> : {}; } catch { store = {}; }
return store;
};
return (kind, name) => {
if (kind === 'env') return env[name] || undefined;
if (name === 'reviewDemoAccount.username' || name === 'reviewDemoAccount.password') {
const eu = env.REVIEW_DEMO_USER, ep = env.REVIEW_DEMO_PASSWORD;
const d = eu && ep ? { username: eu, password: ep } : reviewDemoAccount(root);
return d ? (name.endsWith('username') ? d.username : d.password) : undefined;
}
const ca = file().captureAuth as Record<string, unknown> | undefined;
const v = ca?.[name.replace(/^captureAuth\./, '')];
return typeof v === 'string' && v ? v : undefined;
};
}
/** Replaces every reference; collects the values into `secrets`. A missing one throws with its NAME only. */
export function resolveRefs(text: string, lookup: ReturnType<typeof credLookup>, secrets: Set<string>): string {
return text.replace(REF, (_m, kind: 'cred' | 'env', name: string) => {
const v = lookup(kind, name);
if (v === undefined) throw new Error(kind === 'env' ? `shots.capture.auth: environment variable ${name} is not set` : `shots.capture.auth: \${cred:${name}} is not in .storebound/credentials.json (reviewDemoAccount { username, password } or captureAuth { "${name.replace(/^captureAuth\./, '')}": ... })`);
secrets.add(v);
return v;
});
}
const deepResolve = (v: unknown, f: (s: string) => string): unknown =>
typeof v === 'string' ? f(v) : Array.isArray(v) ? v.map(x => deepResolve(x, f)) : v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, deepResolve(x, f)])) : v;
export interface ResolvedRequest { url: string; method: string; headers: Record<string, string>; body?: string }
/** The sign-in request with references resolved (values only in memory, listed in `secrets`). */
export function resolveRequest(r: AuthRequest, baseUrl: string, lookup: ReturnType<typeof credLookup>, secrets: Set<string>): ResolvedRequest {
const f = (s: string) => resolveRefs(s, lookup, secrets);
const headers = deepResolve(r.headers ?? {}, f) as Record<string, string>;
let body: string | undefined;
if (typeof r.body === 'string') body = f(r.body);
else if (r.body) { body = JSON.stringify(deepResolve(r.body, f)); if (!Object.keys(headers).some(h => h.toLowerCase() === 'content-type')) headers['content-type'] = 'application/json'; }
const url = /^https?:\/\//.test(r.url) ? f(r.url) : baseUrl.replace(/\/$/, '') + f(r.url);
return { url, method: (r.method ?? 'POST').toUpperCase(), headers, ...(body !== undefined ? { body } : {}) };
}
/** Hides every resolved secret (and its URL-encoded form) in a text. */
export function maskSecrets(text: string, secrets: Iterable<string>): string {
let out = text;
for (const s of secrets) if (s.length >= 3) for (const form of new Set([s, encodeURIComponent(s)])) out = out.split(form).join('***');
return out;
}
/** URL for a log line: origin + path only (a query can carry a token). */
export const safeUrl = (u: string): string => { try { const x = new URL(u); return x.origin + x.pathname; } catch { return u.split('?')[0]!; } };
/** A shot that wanted `want` but shows a login page. */
export function landedOnLogin(want: string, got: string, pattern?: string): boolean {
const re = new RegExp(pattern ?? '(^|[/._-])(login|log-in|signin|sign-in|sign_in|auth)([/._?-]|$)', 'i');
try {
const w = new URL(want), g = new URL(got);
if (w.pathname === g.pathname) return false;
return re.test(g.pathname + g.search) && !re.test(w.pathname);
} catch { return false; }
}
export const expiredHint = (url: string): string => `${safeUrl(url)} is a sign-in page: session expired? run storebound shots login again`;
/* eslint-disable @typescript-eslint/no-explicit-any */
export interface AuthPlan {
/** Extra newContext options (storageState). */
contextOptions: Record<string, unknown>;
/** Signs a fresh context in (request auth); a no-op for storageState. */
prepare: (context: any) => Promise<void>;
secrets: Set<string>;
loginUrl?: string;
}
/** Builds the per-context sign-in from shots.capture.auth. Throws before any browser starts when something is missing. */
export function authPlan(a: CaptureAuth, o: { root: string; baseUrl: string; env?: Record<string, string | undefined> }): AuthPlan {
const problems = validateAuth(a);
if (problems.length) throw new Error(problems.join('\n '));
const secrets = new Set<string>();
if (a.storageState) {
const p = resolvePath(o.root, a.storageState);
if (!existsSync(p)) throw new Error(`shots.capture.auth.storageState: ${a.storageState} does not exist; run storebound shots login (sign in to a demo account by hand)`);
let state: { cookies?: { value?: string }[] };
try { state = JSON.parse(readFileSync(p, 'utf8')) as typeof state; } catch { throw new Error(`shots.capture.auth.storageState: ${a.storageState} is not a Playwright storage state; run storebound shots login again`); }
for (const c of state.cookies ?? []) if (c.value && c.value.length >= 6) secrets.add(c.value);
return { contextOptions: { storageState: p }, prepare: async () => {}, secrets, ...(a.loginUrl ? { loginUrl: a.loginUrl } : {}) };
}
const req = resolveRequest(a.request!, o.baseUrl, credLookup(o.root, o.env), secrets);
return {
contextOptions: {}, secrets, ...(a.loginUrl ? { loginUrl: a.loginUrl } : {}),
prepare: async (context: any) => {
let status = 0;
try {
const r = await context.request.fetch(req.url, { method: req.method, headers: req.headers, ...(req.body !== undefined ? { data: req.body } : {}), timeout: 30_000, maxRedirects: 0 });
status = r.status();
await r.dispose?.();
} catch (e) { throw new Error(`sign-in request ${req.method} ${safeUrl(maskSecrets(req.url, secrets))} failed: ${maskSecrets((e as Error).message.split('\n')[0]!, secrets).slice(0, 200)}`); }
if (status < 200 || status >= 400) throw new Error(`sign-in request ${req.method} ${safeUrl(maskSecrets(req.url, secrets))} returned HTTP ${status} (check the demo account in .storebound/credentials.json and shots.capture.auth.request; values not shown)`);
// the session cookies it set are masked like the request values
const cookies = await context.cookies();
for (const c of cookies) if (c.value && c.value.length >= 6) secrets.add(c.value);
},
};
}
/* eslint-enable @typescript-eslint/no-explicit-any */

View file

@ -11,6 +11,9 @@
// size, session, the copy text of its language, the app build (shots.capture.build files, else
// the HTML and scripts at baseUrl), fonts.css). Unchanged frames that exist are skipped;
// --only-missing only captures frames that do not exist; --force captures everything.
// auth shots.capture.auth (auth.ts): every new context is signed in before its first shot (request
// auth through the context's request API, or a storageState file); a shot that lands on a login
// page warns; resolved secrets and cookie values are masked in every log line and the report.
// timing per frame: goto, waitFor, eval, delay, screenshot, total, in the progress line and in
// .storebound/capture-report.json, with the slowest frames and steps at the end.
import { createHash } from 'node:crypto';
@ -19,6 +22,7 @@ import { availableParallelism, cpus } from 'node:os';
import { join } from 'node:path';
import type { ShotSpec, ShotsCapture } from '../lib/config';
import { log, warn } from '../lib/log';
import { type AuthPlan, expiredHint, landedOnLogin, maskSecrets, safeUrl } from './auth';
import { shotsFor } from './devices';
import type { FrameDims } from './lib';
@ -132,7 +136,10 @@ export interface CaptureReport {
/* eslint-disable @typescript-eslint/no-explicit-any */
/** Captures the jobs in a browser: per-shot contexts, or one page per language + device in per-language sessions. */
export async function captureWeb(browser: any, cap: ShotsCapture, jobs: WebJob[], o: { concurrency: number; dims: Record<Device, FrameDims> }): Promise<CaptureReport> {
export async function captureWeb(browser: any, cap: ShotsCapture, jobs: WebJob[], o: { concurrency: number; dims: Record<Device, FrameDims>; auth?: AuthPlan }): Promise<CaptureReport> {
const auth = o.auth;
const mask = (t: string): string => (auth ? maskSecrets(t, auth.secrets) : t);
const expired = new Set<string>();
const started = Date.now();
const perLang = cap.session === 'per-language';
const report: CaptureReport['frames'] = [];
@ -140,7 +147,9 @@ export async function captureWeb(browser: any, cap: ShotsCapture, jobs: WebJob[]
const total = jobs.length;
const newPage = async (j: WebJob): Promise<{ c: any; page: any; errs: string[] }> => {
const d = o.dims[j.device];
const c = await browser.newContext({ viewport: { width: d.w, height: d.h }, deviceScaleFactor: d.dpr, hasTouch: true, isMobile: true, serviceWorkers: 'block', locale: j.lang });
const c = await browser.newContext({ viewport: { width: d.w, height: d.h }, deviceScaleFactor: d.dpr, hasTouch: true, isMobile: true, serviceWorkers: 'block', locale: j.lang, ...(auth?.contextOptions ?? {}) });
// sign in once per context, cookies kept in it (request auth); a failed sign-in stops the whole capture
if (auth) try { await auth.prepare(c); } catch (e) { await c.close(); throw Object.assign(new Error(mask((e as Error).message)), { authFailed: true }); }
const page = await c.newPage();
const errs: string[] = [];
page.on('pageerror', (e: Error) => errs.push(e.message));
@ -152,6 +161,11 @@ export async function captureWeb(browser: any, cap: ShotsCapture, jobs: WebJob[]
if (how === 'goto') await s.page.goto(j.url, { waitUntil: 'domcontentloaded', timeout: 90000 });
else await s.page.evaluate(`(p => { if (location.pathname + location.search !== p) { history.pushState(null, '', p); dispatchEvent(new PopStateEvent('popstate', { state: null })); } })(${JSON.stringify(j.path)})`);
t.goto = Date.now() - t0; t0 = Date.now();
if (auth && landedOnLogin(j.url, s.page.url(), auth.loginUrl)) {
// a sign-in page is never a store frame: warn once per login URL, fail this shot now (no waitFor timeout)
if (!expired.has(safeUrl(s.page.url()))) { expired.add(safeUrl(s.page.url())); warn('shots', `${j.lang}/${j.device}/${j.name}: ${mask(expiredHint(s.page.url()))}`); }
throw new Error(`landed on the sign-in page ${safeUrl(s.page.url())}`);
}
if (j.spec.waitFor) await s.page.waitForFunction(j.spec.waitFor, null, { timeout: waitTimeout, polling: 50 });
t.waitFor = Date.now() - t0; t0 = Date.now();
for (const js of j.spec.eval ?? []) await s.page.evaluate(js);
@ -166,6 +180,8 @@ export async function captureWeb(browser: any, cap: ShotsCapture, jobs: WebJob[]
};
const record = (j: WebJob, r: { ok: boolean; reused: boolean; timing: Timing; error?: string; errs?: string[] }): void => {
finished++;
if (r.error) r.error = mask(r.error);
if (r.errs) r.errs = r.errs.map(mask);
report.push({ frame: j.frame, ok: r.ok, reused: r.reused, timing: r.timing, ...(r.error ? { error: r.error } : {}) });
const head = `${progressLine(finished, total, started)} ${j.lang}/${j.device}/${j.name}`;
if (r.ok) log('shots', `${head}.png ${r.reused ? '(reused page) ' : ''}${timingText(r.timing)}${r.errs?.length ? ' (page errors: ' + r.errs.join(' | ').slice(0, 200) + ')' : ''}`);

View file

@ -6,7 +6,8 @@
// web headless Chromium (playwright), mobile viewport + touch at phone / tablet size. Each shot:
// { path, waitFor?: JS expression that turns true, eval?: [JS], delayMs?, device? }; URL = baseUrl + path +
// query, "{lang}" replaced. Parallel contexts, per-language page reuse, the input-hash cache and the
// per-frame timing (goto / waitFor / eval / delay / screenshot): capture-web.ts.
// per-frame timing (goto / waitFor / eval / delay / screenshot): capture-web.ts. Signed-in apps:
// shots.capture.auth (a sign-in request or a storageState from `storebound shots login`), auth.ts.
// ios-sim xcrun simctl: boots the simulator (ios.device / ios.devices.tablet), optional install of ios.app, status
// bar 9:41 / full battery / full bars, one warm-up launch after a cold boot (ios.warmupMs), then per shot:
// launch ios.bundleId with -AppleLanguages (xx) -AppleLocale + ios.args + shot.args, open shot.url
@ -37,6 +38,7 @@ import { adbAppLocaleArgs, adbDemoExitArgs, adbDemoModeArgs, adbForceStopArgs, a
import { type CopyFile, copyText, type FrameDims, frameDims } from './lib';
import { captureWeb, defaultConcurrency, hashPaths, hashServed, planWeb, readCache, selectJobs, summaryLines, writeCaptureState } from './capture-web';
import { imageInfo } from './spec';
import { authPlan, safeUrl } from './auth';
import * as rt from '../lib/rt';
type Device = 'phone' | 'tablet';
@ -121,9 +123,12 @@ export async function run(ctx: Ctx): Promise<void> {
if (source === 'web') {
if (dry) {
for (const lang of langs) for (const device of devices) for (const [name, s] of shotsFor(cap, device, only)) progress(`${lang}/${device}/${name}.png <- ${cap.baseUrl}${(s.path ?? '/').replaceAll('{lang}', lang)}`);
if (cap.auth) log('shots', cap.auth.storageState ? `would load the signed-in state from ${cap.auth.storageState} into every context` : `would sign every context in first: ${(cap.auth.request?.method ?? 'POST').toUpperCase()} ${safeUrl(cap.auth.request?.url ?? '')} (values from .storebound/credentials.json / env, never shown)`);
return done('shots', `capture planned (dry run): ${total} frames from web`);
}
need(cap.baseUrl, 'shots.capture.baseUrl');
const auth = cap.auth ? authPlan(cap.auth, { root, baseUrl: cap.baseUrl! }) : undefined;
if (auth) log('shots', cap.auth!.storageState ? `signed in from ${cap.auth!.storageState}` : `signing every context in: ${(cap.auth!.request!.method ?? 'POST').toUpperCase()} ${safeUrl(cap.auth!.request!.url)}`);
const concurrency = a.num('concurrency', cap.concurrency ?? defaultConcurrency('capture'));
const builds = cap.build ? (Array.isArray(cap.build) ? cap.build : [cap.build]).map(p => resolve(p)) : [];
const build = builds.length ? hashPaths(builds) : await hashServed(cap.baseUrl!);
@ -139,7 +144,7 @@ export async function run(ctx: Ctx): Promise<void> {
if (todo.length) {
const browser = await launch(root, { headless: true, args: ['--mute-audio', '--ignore-gpu-blocklist', '--enable-webgl', '--enable-gpu'] });
let report;
try { report = await captureWeb(browser, cap, todo, { concurrency, dims }); } finally { await closeBrowser(browser); } // capped close, then kill: Chrome can hang 10-30 s in its shutdown
try { report = await captureWeb(browser, cap, todo, { concurrency, dims, ...(auth ? { auth } : {}) }); } finally { await closeBrowser(browser); } // capped close, then kill: Chrome can hang 10-30 s in its shutdown
report.skipped = skipped.map(j => j.frame);
writeCaptureState(root, report, cache, todo);
for (const l of summaryLines(report)) log('shots', l);

View file

@ -7,6 +7,7 @@
// exec-out screencap. Emulator or a USB device.
// folder bring your own frames: <frames>/<lang>/<phone|tablet>/<name>.png, checked for size.
import type { ShotsCapture, ShotSpec, VideoRecord } from '../lib/config';
import { validateAuth } from './auth';
export type Source = 'web' | 'ios-sim' | 'android-emu' | 'folder';
export const SOURCES: Source[] = ['web', 'ios-sim', 'android-emu', 'folder'];
@ -29,6 +30,7 @@ export function validateCapture(c: ShotsCapture | undefined): string[] {
}
if (c.ios?.warmupMs !== undefined && !(Number.isInteger(c.ios.warmupMs) && c.ios.warmupMs >= 0)) e.push('shots.capture.ios.warmupMs: milliseconds (integer >= 0)');
if (c.concurrency !== undefined && !(Number.isInteger(c.concurrency) && c.concurrency >= 1 && c.concurrency <= 32)) e.push('shots.capture.concurrency: integer 1-32');
if (c.auth) e.push(...validateAuth(c.auth));
if (c.session !== undefined && !['per-shot', 'per-language'].includes(c.session)) e.push('shots.capture.session: per-shot | per-language');
if (c.reuseTimeoutMs !== undefined && !(Number.isInteger(c.reuseTimeoutMs) && c.reuseTimeoutMs > 0)) e.push('shots.capture.reuseTimeoutMs: milliseconds (integer > 0)');
if (src === 'android-emu' && !c.android?.package) e.push('shots.capture.android.package: application id (source android-emu)');

72
cli/shots/login.ts Normal file
View file

@ -0,0 +1,72 @@
// storebound shots login [--url <url>] [--out .storebound/auth.json] [--timeout 900]
// Opens a visible browser with a fresh, empty profile at shots.capture.baseUrl (or --url) so the owner signs in to a
// DEMO account by hand (storebound never types credentials). Press Enter in the terminal, or close the window, and the
// Playwright storage state (cookies + localStorage) is written to .storebound/auth.json (0600) for
// shots.capture.auth.storageState. Only counts are printed, never cookie values. The wait has a timeout (default
// 15 min) and a heartbeat line every 30 s.
import { chmodSync, mkdirSync, writeFileSync } from 'node:fs';
import { dirname, relative } from 'node:path';
import type { Ctx } from '../lib/ctx';
import { done, log, warn } from '../lib/log';
import { DEFAULT_STATE, safeUrl } from './auth';
import { closeBrowser, launch } from './browser';
export interface StorageState { cookies: { name: string; value: string; domain: string }[]; origins: { origin: string; localStorage: unknown[] }[] }
/** What the confirmation says (counts and domains, never values). */
export function stateSummary(s: StorageState): string {
const domains = [...new Set(s.cookies.map(c => c.domain.replace(/^\./, '')))].sort();
return `${s.cookies.length} cookie${s.cookies.length === 1 ? '' : 's'}${domains.length ? ` (${domains.join(', ')})` : ''}, ${s.origins.length} origin${s.origins.length === 1 ? '' : 's'} with local storage`;
}
export function writeState(file: string, s: StorageState): void {
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, JSON.stringify(s, null, 1) + '\n', { mode: 0o600 });
chmodSync(file, 0o600);
}
export async function run(ctx: Ctx): Promise<void> {
const { config, root, resolve } = ctx.loaded;
const a = ctx.args;
const url = a.get('url') ?? config.shots?.capture?.baseUrl;
if (!url || !/^https?:\/\//.test(url)) throw new Error('shots login: --url <http(s) URL>, or shots.capture.baseUrl in the config');
const outRel = a.get('out') ?? config.shots?.capture?.auth?.storageState ?? DEFAULT_STATE;
if (!/^\.storebound[\\/]/.test(outRel)) throw new Error('shots login --out: a file in .storebound/ (gitignored), e.g. .storebound/auth.json');
const out = resolve(outRel);
const timeoutMs = a.num('timeout', 900) * 1000;
// hidden, for tests: headless (no window) and Enter on stdin still ends it
const headless = a.has('headless');
if (a.has('dry')) return done('shots', `would open ${safeUrl(url)} in a fresh browser profile; after you sign in to a demo account, Enter saves ${outRel} (0600)`);
const browser = await launch(root, { headless, args: ['--mute-audio'] });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let context: any;
let last: StorageState | null = null;
try {
context = await browser.newContext({ viewport: null, serviceWorkers: 'block' });
const page = await context.newPage();
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 }).catch((e: Error) => warn('shots', `could not open ${safeUrl(url)}: ${e.message.split('\n')[0]}`));
log('shots', `opened ${safeUrl(url)} in a fresh browser profile. Sign in to a DEMO account (fabricated data, never a real user's) by hand, then press Enter here (or close the window). Timeout ${Math.round(timeoutMs / 1000)} s.`);
const started = Date.now();
const snapshot = async (): Promise<void> => { try { last = await context.storageState() as StorageState; } catch { /* context gone */ } };
const reason = await new Promise<'enter' | 'closed' | 'timeout'>(res => {
const finish = (r: 'enter' | 'closed' | 'timeout'): void => { clearInterval(beat); clearInterval(snap); clearTimeout(t); process.stdin.off('data', onData); process.stdin.pause(); res(r); };
const onData = (b: Buffer): void => { if (b.toString().includes('\n')) finish('enter'); };
process.stdin.on('data', onData); process.stdin.resume();
const snap = setInterval(() => { void snapshot(); }, 2000);
const beat = setInterval(() => log('shots', `waiting for the sign-in (${Math.round((Date.now() - started) / 1000)} s of ${Math.round(timeoutMs / 1000)} s): press Enter when signed in`), 30_000);
const t = setTimeout(() => finish('timeout'), timeoutMs);
page.on('close', () => { if (!context.pages().length) finish('closed'); });
browser.on('disconnected', () => finish('closed'));
});
if (reason === 'timeout') throw new Error(`shots login: no Enter within ${Math.round(timeoutMs / 1000)} s; nothing saved (--timeout <seconds>)`);
if (reason === 'enter') await snapshot();
const state = last as StorageState | null;
if (!state) throw new Error('shots login: the browser closed before its sign-in state could be read; run it again and press Enter before closing the window');
writeState(out, state);
if (!state.cookies.length && !state.origins.length) warn('shots', 'no cookies and no local storage were saved: was the sign-in finished?');
done('shots', `saved ${relative(root, out) || out} (0600): ${stateSummary(state)}. Set shots.capture.auth = { storageState: "${outRel}" }`);
} finally {
await closeBrowser(browser);
}
}