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:
parent
3949ff1531
commit
e1e4e05237
15 changed files with 545 additions and 9 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)'],
|
||||
|
|
|
|||
|
|
@ -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]"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
179
cli/shots/auth.ts
Normal 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 */
|
||||
|
|
@ -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) + ')' : ''}`);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
72
cli/shots/login.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
122
tests/browser/capture-auth.browser.ts
Normal file
122
tests/browser/capture-auth.browser.ts
Normal 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);
|
||||
});
|
||||
86
tests/capture-auth.test.ts
Normal file
86
tests/capture-auth.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue