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

View file

@ -2,6 +2,13 @@
## Unreleased
- `shots capture --source web` can sign in first: `shots.capture.auth` is a sign-in request (run once per browser
context, cookies kept; body and headers use `${cred:NAME}` from `.storebound/credentials.json` or `${env:NAME}`,
literal secrets are refused, a failed status stops with the status only) or a `storageState` file. `storebound shots
login` opens a fresh visible browser for a demo sign-in by hand and saves `.storebound/auth.json` (0600, counts
only). A shot that lands on a sign-in page warns "session expired? run storebound shots login again". Secrets and
cookie values are masked in logs and the capture report.
## 0.8.16 · 2026-10-20
- `apps folders add [<folder>]` adds the folder (it listed and added nothing); a template config is added with a note,

View file

@ -92,7 +92,8 @@ export const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
'apple ppo': [() => import('./apple/ppo'), 'Product Page Optimization: status | create --variant b | upload --variant b | start | stop (asks first)'],
'apple testflight': [() => import('./apple/testflight'), 'TestFlight: status | groups | testers (CSV in .storebound/) | what-to-test --build N | info | distribute --build N --group g [--submit-review] | wait --build N | expire --keep N|--older-than D (asks)'],
'icons': [() => import('./icons'), 'App Store / Play / web / adaptive / legacy launcher icons from one artwork'],
'shots capture': [() => import('./shots/capture'), 'raw frames: --source web (playwright) | ios-sim (simctl) | android-emu (adb) | folder; --dry'],
'shots capture': [() => import('./shots/capture'), 'raw frames: --source web (playwright; signed-in apps: shots.capture.auth) | ios-sim (simctl) | android-emu (adb) | folder; --dry'],
'shots login': [() => import('./shots/login'), 'opens a visible browser (fresh profile) at shots.capture.baseUrl or --url; you sign in to a demo account by hand, Enter saves .storebound/auth.json (0600) for shots.capture.auth.storageState; --timeout <s>'],
'shots fonts': [() => import('./shots/fonts'), 'download font subsets for the copy (CJK / Arabic / Cyrillic ...)'],
'shots render': [() => import('./shots/render'), 'store screenshots + feature graphic in every size and language'],
'shots jpeg': [() => import('./shots/jpeg'), 'JPEG q92 copies of the Play renders (4x faster uploads)'],

View file

@ -806,6 +806,12 @@
"variant",
"variants"
],
"shots login": [
"headless",
"out",
"timeout",
"url"
],
"shots record": [
"concurrency",
"device",
@ -983,6 +989,7 @@
"godot",
"group",
"halt",
"headless",
"home",
"hook",
"icon",
@ -1479,6 +1486,9 @@
"shots jpeg": [
"storebound shots jpeg [--q 92] [--lang en,tr] [--variant b | --variants]"
],
"shots login": [
"storebound shots login [--url <url>] [--out .storebound/auth.json] [--timeout 900]"
],
"shots record": [
"storebound shots record [--lang en,tr] [--only intro,combo] [--source web|ios-sim|android-emu|folder] [--dry] [--keep-booted]"
],

View file

@ -200,6 +200,8 @@ export interface ShotSpec {
extras?: Record<string, string | number | boolean>;
}
export type { CaptureAuth } from '../shots/auth';
import type { CaptureAuth } from '../shots/auth';
export interface ShotsCapture {
/** Where frames come from. Default "web". */
source?: 'web' | 'ios-sim' | 'android-emu' | 'folder';
@ -254,6 +256,10 @@ export interface ShotsCapture {
/** Web: files or folders of the app build (e.g. "apps/game/src" or "apps/game/dist") hashed into the capture cache,
* so a code change re-captures. Default: the HTML at baseUrl and the scripts it loads. */
build?: string | string[];
/** Web: sign in before the shots (apps whose key screens are behind a login; use a demo account on a local or demo
* backend with fabricated data). { request: { url, method, headers, body } } with "${cred:NAME}" / "${env:NAME}"
* references (never literal secrets), or { storageState: ".storebound/auth.json" } from `storebound shots login`. */
auth?: CaptureAuth;
shots: Record<string, ShotSpec>;
}

View file

@ -222,7 +222,7 @@ export const TOOLS: ToolDef[] = [
props: { action: { type: 'string', enum: ['on', 'pause'] }, version: { type: 'string' }, platform }, required: ['action'],
args: i => ['--phased', String(i.action), ...opt('version', i.version), ...opt('platform', i.platform)] },
{ name: 'shots_capture', title: 'Capture raw frames', command: ['shots', 'capture'], write: true, network: false, idempotent: true,
description: 'Raw app or game frames per language into shots.frames from a web build (Playwright), the iOS Simulator (simctl), an Android emulator / device (adb) or a folder check. Local files only; a simulator it boots is shut down again, an emulator is never booted. dry_run lists the plan and the simctl / adb commands.',
description: 'Raw app or game frames per language into shots.frames from a web build (Playwright), the iOS Simulator (simctl), an Android emulator / device (adb) or a folder check. Web apps behind a sign-in: shots.capture.auth (a sign-in request with ${cred:NAME} / ${env:NAME} references, or a storageState from the terminal-only `storebound shots login`, where the owner signs in to a demo account by hand); secrets and cookies are masked. Local files only; a simulator it boots is shut down again, an emulator is never booted. dry_run lists the plan and the simctl / adb commands.',
props: { lang, only: { type: 'string', description: 'Comma-separated shot names' }, device: { type: 'string', enum: ['phone', 'tablet'] }, source,
concurrency: { type: 'integer', minimum: 1, maximum: 32, description: 'Web: parallel browser contexts (default from the CPU count)' },
mode: { type: 'string', enum: ['changed', 'only-missing', 'force'], default: 'changed', description: 'Web: skip frames with unchanged inputs (default), only missing ones, or capture all' } },

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);
}
}

View file

@ -140,7 +140,8 @@ run in the background with a log (`storebound shots render > .storebound/render.
| command | kind | what |
|---|---|---|
| `shots capture [--source web\|ios-sim\|android-emu\|folder] [--concurrency N] [--only-missing \| --force]` | local | raw frames from your running app or game; web: parallel contexts, optional one boot per language (`capture.session`), skips unchanged frames, timing per step |
| `shots capture [--source web\|ios-sim\|android-emu\|folder] [--concurrency N] [--only-missing \| --force]` | local | raw frames from your running app or game; web: parallel contexts, optional one boot per language (`capture.session`), skips unchanged frames, timing per step; signed-in apps: `capture.auth` (a sign-in request with `${cred:NAME}` / `${env:NAME}` references, or `storageState`) |
| `shots login [--url <url>] [--timeout <s>]` | local | opens a visible browser with a fresh profile; you sign in to a demo account by hand, Enter saves `.storebound/auth.json` (0600) for `capture.auth.storageState`; prints counts only |
| `shots fonts` | network | font subsets for your copy (Noto for CJK / Arabic / Cyrillic / Devanagari ...), your own local font files, and the glyph coverage check per language |
| `shots render [--variant b \| --variants] [--concurrency N]` | local | every store size and language in the game or app style, feature graphic, contact sheet, overflow report |
| `shots jpeg` | local | JPEG q92 copies of the Play renders (about 4x faster uploads) |

View file

@ -30,7 +30,11 @@ languages, every store size, a review gallery and an app preview video. No store
freeze, autoplay) and
let `shots capture` take the frames: Playwright for a web build, `simctl` for anything that runs in the iOS
Simulator (Unity, Godot, Flutter, React Native, native, Capacitor), `adb` for an Android emulator or device, or bring a folder of
your own frames. The status bar is cropped off and redrawn clean (9:41, full battery).
your own frames. The status bar is cropped off and redrawn clean (9:41, full battery). Web apps behind a login sign
in first with `shots.capture.auth`: a sign-in request whose secrets come from `.storebound/credentials.json` or the
environment (`${cred:NAME}`, `${env:NAME}`; literal secrets are refused), or a session saved by `storebound shots
login`, where you sign in to a demo account by hand in a fresh browser. Use a demo backend with fabricated data;
the same frames feed the App Store and Google Play.
2. **Copy.** Per language and scene: eyebrow, two-line title, subtitle, badges. Fonts are subset from Google Fonts to
exactly your characters, so Japanese, Chinese, Korean, Arabic (right to left) and Hindi just work. `shots fonts`
checks every character of every language against the font's cmap (TTF / OTF / WOFF / WOFF2, read in plain

View file

@ -126,7 +126,8 @@ the icon, feature graphic and phone screenshots and prints `tablet screenshots:
`baseUrl`, fonts) via `.storebound/capture-cache.json`; `--only-missing` captures only missing files, `--force`
everything. Each progress line shows n/total, ETA and the time per step (goto, wait, eval, delay, shot); the end
lists the slowest frames and steps, and `.storebound/capture-report.json` keeps them. Put the slow step right
(a long `waitFor` = the game reaching the scene: add a URL switch that jumps there).
(a long `waitFor` = the game reaching the scene: add a URL switch that jumps there). Screens behind a login:
`capture.auth` (see Signed-in apps).
- `ios-sim` (Unity, Godot, Flutter, native, Capacitor: any Simulator build): `ios.device` (simulator name or UDID,
e.g. a 6.9" iPhone), `ios.bundleId`, optional `ios.app` (.app to install), `ios.args`. Per shot: `args` (launch
arguments, read them with UserDefaults / your engine) and / or `url` (deep link). Language goes in as
@ -190,6 +191,30 @@ the icon, feature graphic and phone screenshots and prints `tablet screenshots:
`play images` uploads only the sets that exist (tablet / TV / Wear when rendered) and never deletes a set Play has
but the renders lack: the dry run shows `keeps <type> on Play`; `--prune` (only on the owner's word) deletes them.
## Signed-in apps
Key screens behind a login (`source: 'web'`): capture against a **local or demo backend with fabricated data** and a
demo account. Never real users' data in store screenshots. The same frames feed both stores (App Store and Google
Play), so one sign-in setup covers both. Two ways to sign in, set in `shots.capture.auth`:
- **Sign-in request**: `{ request: { url: '/api/login', method: 'POST', body: { email: '${cred:reviewDemoAccount.username}',
password: '${cred:reviewDemoAccount.password}' } } }`. It runs once per browser context before its first shot, and
the context keeps the cookies. `${cred:NAME}` reads `.storebound/credentials.json` (`reviewDemoAccount.username` /
`.password`, also from REVIEW_DEMO_USER / REVIEW_DEMO_PASSWORD, or any key under `captureAuth`). `${env:NAME}`
reads the environment. A password-, token- or key-like field with a literal value is refused. A status outside
2xx/3xx stops the capture and shows the status, never the values.
- **Saved session**: `{ storageState: '.storebound/auth.json' }`, made by `storebound shots login [--url <url>]`. It
opens a visible browser with a fresh, empty profile. The owner signs in to the demo account by hand (signing in is
the owner's step; the agent never types it). Enter in the terminal (or closing the window) saves the state with
0600 permissions and prints how many cookies and origins it saved. The wait times out after 15 min
(`--timeout <s>`) and logs a heartbeat every 30 s. This one is for SSO, 2FA or captcha logins. MCP has no login
tool, because the owner signs in at a terminal.
If a shot lands on a sign-in page anyway, capture warns `<url> is a sign-in page: session expired? run storebound
shots login again` and fails that shot at once. `auth.loginUrl` (a regex) names the login page when the default
(login / signin / auth in the path) does not match. Resolved values and cookie values are masked in logs and
`.storebound/capture-report.json`; the gallery only shows the renders.
## A/B variants and store experiments
`shots.variants.<id>`: `scenes` (order / subset), `copy` (a JSON merged over the base copy, only what changes),
`palettes` (per scene). `storebound shots render --variant b` (or `--variants` for all) writes

View file

@ -0,0 +1,122 @@
// shots.capture.auth against a tiny local app: POST /api/login sets a session cookie, /app/<scene> needs it (else a
// redirect to /login). Request auth signs every context in; a wrong password stops with the status and no values;
// storageState loads a saved session; an expired one warns; `shots login` (headless here, Enter on stdin) saves the
// state 0600 with counts only. Secrets and cookie values never reach the output or the report. Skips without Chromium.
import { afterAll, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { ShipConfig, ShotsCapture } from '../../cli/lib/config';
import { BASE } from '../helpers/store-mock';
const CLI = join(import.meta.dir, '..', '..', 'cli', 'index.ts');
const PASSWORD = 'demo-pass-7731';
const SESSION = 'sess-value-55aa91';
const server = Bun.serve({
port: 0, hostname: '127.0.0.1',
async fetch(req) {
const u = new URL(req.url);
if (u.pathname === '/api/login' && req.method === 'POST') {
const b = await req.json().catch(() => ({})) as { email?: string; password?: string };
if (b.email !== 'demo@example.com' || b.password !== PASSWORD) return new Response('no', { status: 401 });
return new Response('{}', { headers: { 'set-cookie': `sid=${SESSION}; Path=/; HttpOnly`, 'content-type': 'application/json' } });
}
if (u.pathname === '/login') return new Response('<!doctype html><title>login</title><body>sign in</body>', { headers: { 'content-type': 'text/html', 'set-cookie': 'csrf=login-page-token; Path=/' } });
if (u.pathname.startsWith('/app/')) {
if (!(req.headers.get('cookie') ?? '').includes(`sid=${SESSION}`)) return new Response(null, { status: 302, headers: { location: '/login?next=' + encodeURIComponent(u.pathname) } });
const name = u.pathname.split('/').pop();
return new Response(`<!doctype html><body style="margin:0;background:#1e66f5"><div>${name}</div><script>window.scene=${JSON.stringify(name)};console.error('debug ${PASSWORD}')</script></body>`, { headers: { 'content-type': 'text/html' } });
}
return new Response('not found', { status: 404 });
},
});
afterAll(() => server.stop(true));
const base = `http://127.0.0.1:${server.port}`;
const canLaunch = spawnSync('bun', ['-e', `const { launch } = await import(${JSON.stringify(join(import.meta.dir, '..', '..', 'cli', 'shots', 'browser.ts'))}); await launch(${JSON.stringify(join(import.meta.dir, '..', '..'))}, { headless: true }); process.exit(0);`], { timeout: 60_000 }).status === 0;
if (!canLaunch) console.warn('capture-auth browser tests skipped: no Chromium could launch (bunx playwright install chromium)');
function project(auth: ShotsCapture['auth'], creds: object): string {
const root = mkdtempSync(join(tmpdir(), 'sb-auth-'));
mkdirSync(join(root, '.storebound'), { recursive: true });
mkdirSync(join(root, 'store/shots'), { recursive: true });
writeFileSync(join(root, 'store/shots/copy.json'), '{}');
writeFileSync(join(root, '.storebound/credentials.json'), JSON.stringify(creds));
const shot = (n: string) => ({ path: `/app/${n}`, waitFor: `window.scene === '${n}'`, delayMs: 10 });
const cfg: ShipConfig = { ...BASE, languages: [BASE.languages[0]!], shots: { copy: 'store/shots/copy.json', frames: 'store/shots/frames', out: 'store/renders', scenes: [{ id: 'hero', palette: 'fire', frames: [{ src: 'hero.png' }] }], phone: { w: 120, h: 200, dpr: 1 },
capture: { source: 'web', baseUrl: base, shots: { projects: shot('projects'), settings: shot('settings') }, ...(auth ? { auth } : {}) } } };
writeFileSync(join(root, 'storebound.config.json'), JSON.stringify(cfg));
return root;
}
async function cli(root: string, args: string[], stdin?: (p: Bun.Subprocess<'pipe', 'pipe', 'pipe'>) => Promise<void>): Promise<{ out: string; code: number }> {
const p = Bun.spawn(['bun', CLI, 'shots', ...args], { cwd: root, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', env: { ...process.env, NO_COLOR: '1' } });
const outP = Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()]);
if (stdin) await stdin(p); else p.stdin.end();
const [[o, e], code] = await Promise.all([outP, p.exited]);
return { out: o + e, code };
}
const demo = { reviewDemoAccount: { username: 'demo@example.com', password: PASSWORD } };
const request = { request: { url: '/api/login', body: { email: '${cred:reviewDemoAccount.username}', password: '${cred:reviewDemoAccount.password}' } } };
const frame = (root: string, n: string) => join(root, 'store/shots/frames', BASE.languages[0]!.id, 'phone', `${n}.png`);
describe.skipIf(!canLaunch)('shots capture with shots.capture.auth', () => {
test.concurrent('request auth signs every context in; secrets and the cookie never reach the output or report', async () => {
const root = project(request, demo);
try {
const r = await cli(root, ['capture', '--device', 'phone', '--force', '--concurrency', '2']);
expect(r.code).toBe(0);
expect(r.out).toContain('signing every context in: POST /api/login');
expect(existsSync(frame(root, 'projects')) && existsSync(frame(root, 'settings'))).toBe(true);
const report = readFileSync(join(root, '.storebound/capture-report.json'), 'utf8');
for (const t of [r.out, report]) { expect(t).not.toContain(PASSWORD); expect(t).not.toContain(SESSION); }
expect(r.out).not.toContain('session expired');
} finally { rmSync(root, { recursive: true, force: true }); }
}, 120_000);
test.concurrent('a refused sign-in stops with the status, never the values', async () => {
const root = project(request, { reviewDemoAccount: { username: 'demo@example.com', password: 'wrong-pass-0001' } });
try {
const r = await cli(root, ['capture', '--device', 'phone', '--force']);
expect(r.code).not.toBe(0);
expect(r.out).toContain('returned HTTP 401');
expect(r.out).not.toContain('wrong-pass-0001');
expect(existsSync(frame(root, 'projects'))).toBe(false);
} finally { rmSync(root, { recursive: true, force: true }); }
}, 120_000);
test.concurrent('storageState loads a saved session; an expired one warns to run shots login again', async () => {
const state = (v: string) => JSON.stringify({ cookies: [{ name: 'sid', value: v, domain: '127.0.0.1', path: '/', expires: -1, httpOnly: true, secure: false, sameSite: 'Lax' }], origins: [] });
const root = project({ storageState: '.storebound/auth.json' }, {});
try {
writeFileSync(join(root, '.storebound/auth.json'), state(SESSION));
let r = await cli(root, ['capture', '--device', 'phone', '--force']);
expect(r.code).toBe(0);
expect(r.out).toContain('signed in from .storebound/auth.json');
expect(r.out).not.toContain(SESSION);
writeFileSync(join(root, '.storebound/auth.json'), state('sess-expired-000'));
r = await cli(root, ['capture', '--device', 'phone', '--force']);
expect(r.code).not.toBe(0); // the shots fail at once, no waitFor timeout
expect(r.out).toContain('landed on the sign-in page');
expect(r.out).toContain(`${base}/login is a sign-in page: session expired? run storebound shots login again`);
expect(r.out.match(/session expired/g)?.length).toBe(1); // once per login URL, not per shot
} finally { rmSync(root, { recursive: true, force: true }); }
}, 120_000);
test.concurrent('shots login saves the storage state 0600 after Enter, with counts only', async () => {
const root = project(undefined, {});
try {
const r = await cli(root, ['login', '--url', `${base}/login`, '--headless', '--timeout', '60'], async p => {
// Enter, like the owner after signing in (read once the page is open)
p.stdin.write('\n'); await p.stdin.end();
});
expect(r.code).toBe(0);
const file = join(root, '.storebound/auth.json');
expect(statSync(file).mode & 0o777).toBe(0o600);
expect(r.out).toContain('saved .storebound/auth.json (0600): 1 cookie (127.0.0.1)');
expect(r.out).not.toContain('login-page-token');
expect(JSON.parse(readFileSync(file, 'utf8')).cookies[0].name).toBe('csrf');
} finally { rmSync(root, { recursive: true, force: true }); }
}, 120_000);
});

View file

@ -0,0 +1,86 @@
// shots.capture.auth without a browser: reference resolution (credentials.json, env), refusal of literal secrets,
// masking, the login-page check and the storage state summary of `shots login`.
import { describe, expect, test } from 'bun:test';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { authPlan, credLookup, landedOnLogin, literalSecrets, maskSecrets, resolveRequest, validateAuth } from '../cli/shots/auth';
import { validateCapture } from '../cli/shots/devices';
import { stateSummary } from '../cli/shots/login';
function proj(creds: object): string {
const root = mkdtempSync(join(tmpdir(), 'sb-auth-'));
mkdirSync(join(root, '.storebound'));
writeFileSync(join(root, '.storebound', 'credentials.json'), JSON.stringify(creds));
return root;
}
describe('capture auth references', () => {
test('cred: reviewDemoAccount and captureAuth from credentials.json, env: from the environment', () => {
const root = proj({ reviewDemoAccount: { username: 'demo@example.com', password: 'pw-from-file' }, captureAuth: { apiKey: 'key-123456' } });
try {
const secrets = new Set<string>();
const r = resolveRequest({ url: '/api/login', body: { email: '${cred:reviewDemoAccount.username}', password: '${cred:reviewDemoAccount.password}' },
headers: { 'x-api-key': '${cred:apiKey}', 'x-tenant': '${env:SB_TENANT}' } }, 'http://127.0.0.1:1/', credLookup(root, { SB_TENANT: 'demo-tenant' }), secrets);
expect(r).toEqual({ url: 'http://127.0.0.1:1/api/login', method: 'POST', headers: { 'x-api-key': 'key-123456', 'x-tenant': 'demo-tenant', 'content-type': 'application/json' },
body: JSON.stringify({ email: 'demo@example.com', password: 'pw-from-file' }) });
expect([...secrets].sort()).toEqual(['demo-tenant', 'demo@example.com', 'key-123456', 'pw-from-file']);
// REVIEW_DEMO_USER / REVIEW_DEMO_PASSWORD win over the file, like apple review-details
const env = credLookup(root, { REVIEW_DEMO_USER: 'env@example.com', REVIEW_DEMO_PASSWORD: 'pw-env' });
expect(env('cred', 'reviewDemoAccount.password')).toBe('pw-env');
} finally { rmSync(root, { recursive: true, force: true }); }
});
test('a missing reference names only the reference', () => {
const root = proj({});
try {
expect(() => resolveRequest({ url: '/l', body: { password: '${cred:reviewDemoAccount.password}' } }, 'http://x', credLookup(root, {}), new Set())).toThrow(/reviewDemoAccount\.password\} is not in \.storebound\/credentials\.json/);
expect(() => resolveRequest({ url: '/l', body: 'token=${env:NOPE_TOKEN}' }, 'http://x', credLookup(root, {}), new Set())).toThrow('environment variable NOPE_TOKEN is not set');
} finally { rmSync(root, { recursive: true, force: true }); }
});
});
describe('literal secrets are refused', () => {
test('password-like keys with literal values, in JSON, form strings and headers', () => {
expect(literalSecrets({ url: '/l', body: { email: 'demo@example.com', password: 'hunter22' } })).toEqual(['body.password']);
expect(literalSecrets({ url: '/l', body: 'user=a&pass=hunter22' })).toEqual(['body.pass']);
expect(literalSecrets({ url: '/l', body: '{"auth":{"token":"abc"}}' })).toEqual(['body.auth.token']);
expect(literalSecrets({ url: '/l', headers: { Authorization: 'Bearer abc.def' } })).toEqual(['headers.Authorization']);
expect(literalSecrets({ url: '/l', headers: { Authorization: 'Bearer ${env:TOKEN}' }, body: { password: '${cred:reviewDemoAccount.password}' } })).toEqual([]);
const e = validateCapture({ source: 'web', baseUrl: 'http://x', shots: {}, auth: { request: { url: '/l', body: { password: 'hunter22' } } } });
expect(e.join('\n')).toContain('shots.capture.auth.request.body.password: a secret is never written in the config');
expect(e.join('\n')).not.toContain('hunter22');
});
test('shape and a gitignored storageState path', () => {
expect(validateAuth({})[0]).toContain('either');
expect(validateAuth({ storageState: 'auth.json' })[0]).toContain('a file in .storebound/');
expect(validateAuth({ storageState: '.storebound/auth.json' })).toEqual([]);
});
});
describe('masking and the login-page check', () => {
test('values and their URL-encoded forms are masked', () => {
expect(maskSecrets('POST failed for p@ss word / p%40ss%20word', ['p@ss word'])).toBe('POST failed for *** / ***');
});
test('storageState: a missing file says to run shots login; cookie values become secrets', () => {
const root = proj({});
try {
expect(() => authPlan({ storageState: '.storebound/auth.json' }, { root, baseUrl: 'http://x' })).toThrow('run storebound shots login');
writeFileSync(join(root, '.storebound/auth.json'), JSON.stringify({ cookies: [{ name: 'sid', value: 'cookie-value-1', domain: '127.0.0.1' }], origins: [] }));
const p = authPlan({ storageState: '.storebound/auth.json' }, { root, baseUrl: 'http://x' });
expect(p.contextOptions.storageState).toBe(join(root, '.storebound/auth.json'));
expect([...p.secrets]).toEqual(['cookie-value-1']);
} finally { rmSync(root, { recursive: true, force: true }); }
});
test('a shot redirected to a login page is detected, a login shot itself is not', () => {
expect(landedOnLogin('http://h/app/projects?lang=en', 'http://h/login?next=%2Fapp')).toBe(true);
expect(landedOnLogin('http://h/app/projects', 'http://h/auth/sign-in')).toBe(true);
expect(landedOnLogin('http://h/app/projects', 'http://h/app/projects')).toBe(false);
expect(landedOnLogin('http://h/login', 'http://h/login/step2')).toBe(false);
expect(landedOnLogin('http://h/app', 'http://h/welcome', 'welcome')).toBe(true);
});
test('shots login summary shows counts and domains, never values', () => {
const s = stateSummary({ cookies: [{ name: 'sid', value: 'SECRET-VALUE', domain: '.example.com' }, { name: 'csrf', value: 'X2', domain: 'example.com' }], origins: [{ origin: 'https://example.com', localStorage: [] }] });
expect(s).toBe('2 cookies (example.com), 1 origin with local storage');
expect(s).not.toContain('SECRET');
});
});