rpc-agent 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/auth.js ADDED
@@ -0,0 +1,416 @@
1
+ // Authenticated captures: cookies, headers, storage state and saved sessions.
2
+ //
3
+ // Everything rpc injects is scoped to the target origin (plus any origins the
4
+ // caller allows explicitly). Secret values never appear in output: summaries
5
+ // carry names only, and error text is scrubbed of every value we injected.
6
+ import { existsSync, readFileSync } from 'node:fs';
7
+ import { chmod, mkdir, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
8
+ import { homedir } from 'node:os';
9
+ import { dirname, join, resolve } from 'node:path';
10
+ export const MASK = '•••';
11
+ // ------------------------------------------------------------------ parsing
12
+ const TOKEN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
13
+ /** `name=value; Domain=example.com; Path=/; Secure; HttpOnly; SameSite=Lax` */
14
+ export function parseCookie(raw) {
15
+ const [pair = '', ...attrs] = raw.split(';');
16
+ const eq = pair.indexOf('=');
17
+ const name = (eq === -1 ? pair : pair.slice(0, eq)).trim();
18
+ if (eq === -1 || !name || !TOKEN.test(name)) {
19
+ throw new Error(`--cookie expects "name=value[; Domain=…; Path=…]", got "${name || '(empty)'}${eq === -1 ? '' : `=${MASK}`}"`);
20
+ }
21
+ const cookie = { name, value: pair.slice(eq + 1).trim() };
22
+ for (const attr of attrs) {
23
+ const i = attr.indexOf('=');
24
+ const key = (i === -1 ? attr : attr.slice(0, i)).trim().toLowerCase();
25
+ const val = i === -1 ? '' : attr.slice(i + 1).trim();
26
+ if (!key)
27
+ continue;
28
+ if (key === 'domain')
29
+ cookie.domain = val;
30
+ else if (key === 'path')
31
+ cookie.path = val || '/';
32
+ else if (key === 'secure')
33
+ cookie.secure = true;
34
+ else if (key === 'httponly')
35
+ cookie.httpOnly = true;
36
+ else if (key === 'samesite') {
37
+ const s = val.toLowerCase();
38
+ if (s !== 'lax' && s !== 'strict' && s !== 'none')
39
+ throw new Error(`Cookie "${name}": SameSite must be Lax, Strict or None`);
40
+ cookie.sameSite = (s[0].toUpperCase() + s.slice(1));
41
+ }
42
+ else if (key === 'expires' || key === 'max-age')
43
+ continue;
44
+ else
45
+ throw new Error(`Cookie "${name}": unknown attribute "${key}" (supported: Domain, Path, Secure, HttpOnly, SameSite)`);
46
+ }
47
+ return cookie;
48
+ }
49
+ /** `Name: value` */
50
+ export function parseHeader(raw) {
51
+ const i = raw.indexOf(':');
52
+ const name = (i === -1 ? raw : raw.slice(0, i)).trim();
53
+ if (i === -1 || !name || !TOKEN.test(name)) {
54
+ throw new Error(`--header expects "Name: value", got "${name || '(empty)'}${i === -1 ? '' : `: ${MASK}`}"`);
55
+ }
56
+ return [name, raw.slice(i + 1).trim()];
57
+ }
58
+ function originOf(url) {
59
+ try {
60
+ const u = new URL(url);
61
+ return u.protocol === 'http:' || u.protocol === 'https:' ? u.origin : undefined;
62
+ }
63
+ catch {
64
+ return undefined;
65
+ }
66
+ }
67
+ /** Accept `localhost:3000`, `https://x.com/path` or a bare host; return its origin. */
68
+ export function toOrigin(input) {
69
+ const raw = input.trim();
70
+ const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw)
71
+ ? raw
72
+ : /^(localhost|127\.|0\.0\.0\.0|\[::1\])/i.test(raw)
73
+ ? `http://${raw}`
74
+ : `https://${raw}`;
75
+ const o = originOf(withScheme);
76
+ if (!o)
77
+ throw new Error(`"${input}" is not an http(s) origin`);
78
+ return o;
79
+ }
80
+ /** Cookie domain-match (RFC 6265 5.1.3), ignoring a leading dot. */
81
+ export function domainMatches(host, domain) {
82
+ const d = domain.replace(/^\./, '').toLowerCase();
83
+ const h = host.toLowerCase();
84
+ return h === d || h.endsWith(`.${d}`);
85
+ }
86
+ function hostOf(origin) {
87
+ return new URL(origin).hostname;
88
+ }
89
+ // ------------------------------------------------------------------ sessions
90
+ /** `$RPC_SESSIONS_DIR`, else `$XDG_CONFIG_HOME/rpc-agent/sessions`, else `~/.config/rpc-agent/sessions`. */
91
+ export function sessionsDir(env = process.env) {
92
+ if (env.RPC_SESSIONS_DIR)
93
+ return resolve(env.RPC_SESSIONS_DIR);
94
+ return join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'rpc-agent', 'sessions');
95
+ }
96
+ /** `http://localhost:3400` -> `http-localhost-3400` */
97
+ export function originSlug(origin) {
98
+ return origin
99
+ .toLowerCase()
100
+ .replace(/[^a-z0-9]+/g, '-')
101
+ .replace(/^-+|-+$/g, '');
102
+ }
103
+ export function sessionPath(origin, dir = sessionsDir()) {
104
+ return join(dir, `${originSlug(origin)}.json`);
105
+ }
106
+ /**
107
+ * Write a storage state readable only by this user: the file is 0600 and,
108
+ * for the default sessions dir, the directory is 0700.
109
+ */
110
+ export async function writeSession(state, meta, file) {
111
+ const target = file ? resolve(file) : sessionPath(meta.origin);
112
+ const dir = dirname(target);
113
+ if (!file) {
114
+ await mkdir(dir, { recursive: true, mode: 0o700 });
115
+ await chmod(dir, 0o700);
116
+ }
117
+ else {
118
+ await mkdir(dir, { recursive: true });
119
+ }
120
+ const body = { cookies: state.cookies, origins: state.origins, rpc: meta };
121
+ const tmp = `${target}.${process.pid}.tmp`;
122
+ await writeFile(tmp, JSON.stringify(body, null, 2) + '\n', { mode: 0o600 });
123
+ await chmod(tmp, 0o600);
124
+ await rename(tmp, target);
125
+ return target;
126
+ }
127
+ function readState(path) {
128
+ let parsed;
129
+ try {
130
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
131
+ }
132
+ catch (err) {
133
+ throw new Error(`Could not read storage state ${path}: ${err.message.split('\n')[0]}`);
134
+ }
135
+ const s = parsed;
136
+ if (!s || typeof s !== 'object' || !Array.isArray(s.cookies ?? []) || !Array.isArray(s.origins ?? [])) {
137
+ throw new Error(`${path} is not a Playwright storageState file (expected { cookies: [], origins: [] })`);
138
+ }
139
+ return { cookies: s.cookies ?? [], origins: s.origins ?? [], ...(s.rpc ? { rpc: s.rpc } : {}) };
140
+ }
141
+ /** Cookies and localStorage that belong to the allowed origins only. */
142
+ export function scopeState(state, allowed) {
143
+ const hosts = allowed.map(hostOf);
144
+ return {
145
+ cookies: state.cookies.filter(c => hosts.some(h => domainMatches(h, c.domain))),
146
+ origins: state.origins.filter(o => allowed.includes(o.origin)),
147
+ };
148
+ }
149
+ /** Latest expiry of the cookies, and whether all of them have passed it. */
150
+ function expiry(cookies, now = Date.now() / 1000) {
151
+ if (cookies.length === 0 || cookies.some(c => !(c.expires > 0)))
152
+ return { expired: false };
153
+ const latest = Math.max(...cookies.map(c => c.expires));
154
+ return { expired: latest < now, expires: new Date(latest * 1000).toISOString() };
155
+ }
156
+ function describeSession(path, file, origin) {
157
+ const own = scopeState(file, [origin]);
158
+ const e = expiry(own.cookies);
159
+ return {
160
+ origin,
161
+ path,
162
+ ...(file.rpc?.savedAt ? { savedAt: file.rpc.savedAt } : {}),
163
+ cookies: own.cookies.length,
164
+ localStorage: own.origins.length,
165
+ ...(e.expired ? { expired: true } : {}),
166
+ ...(e.expires ? { expires: e.expires } : {}),
167
+ };
168
+ }
169
+ export async function listSessions(dir = sessionsDir()) {
170
+ let names;
171
+ try {
172
+ names = (await readdir(dir)).filter(n => n.endsWith('.json')).sort();
173
+ }
174
+ catch {
175
+ return [];
176
+ }
177
+ const out = [];
178
+ for (const n of names) {
179
+ const path = join(dir, n);
180
+ try {
181
+ const file = readState(path);
182
+ const origin = file.rpc?.origin ?? originFromSlug(n);
183
+ if (origin)
184
+ out.push(describeSession(path, file, origin));
185
+ }
186
+ catch {
187
+ // Not ours or unreadable; skip.
188
+ }
189
+ }
190
+ return out;
191
+ }
192
+ function originFromSlug(name) {
193
+ const m = /^(https?)-(.+?)(?:-(\d+))?\.json$/.exec(name);
194
+ return m ? `${m[1]}://${m[2].replace(/-/g, '.')}${m[3] ? `:${m[3]}` : ''}` : undefined;
195
+ }
196
+ /** Delete the saved session for one origin, or all of them. Returns the origins removed. */
197
+ export async function clearSessions(origin, dir = sessionsDir()) {
198
+ const all = await listSessions(dir);
199
+ const target = origin ? toOrigin(origin) : undefined;
200
+ const gone = all.filter(s => !target || s.origin === target);
201
+ for (const s of gone)
202
+ await rm(s.path, { force: true });
203
+ return gone.map(s => s.origin);
204
+ }
205
+ /** File mode bits, for tests and `rpc sessions list`. */
206
+ export async function fileMode(path) {
207
+ return (await stat(path)).mode & 0o777;
208
+ }
209
+ // ------------------------------------------------------------------ resolve
210
+ /**
211
+ * Validate cookies and headers against the target URL and load the storage
212
+ * state or saved session. Throws on bad input (names only in messages).
213
+ */
214
+ export function resolveAuth(url, opts, dir = sessionsDir()) {
215
+ const origin = originOf(url);
216
+ const secrets = [];
217
+ const summary = {};
218
+ const allowed = [];
219
+ if (origin)
220
+ allowed.push(origin);
221
+ for (const o of opts.allowOrigins ?? []) {
222
+ const n = toOrigin(o);
223
+ if (!allowed.includes(n))
224
+ allowed.push(n);
225
+ }
226
+ if (opts.allowOrigins?.length)
227
+ summary.allowOrigins = allowed.slice(1);
228
+ const wantsInjection = Boolean(opts.cookies?.length || (opts.headers && Object.keys(opts.headers).length));
229
+ if (!origin && (wantsInjection || opts.storageState)) {
230
+ throw new Error('Cookies, headers and storage state need an http(s) URL');
231
+ }
232
+ // Cookies: host-only on the target unless a Domain that matches it (or an allowed origin) is given.
233
+ const cookies = [];
234
+ for (const c of opts.cookies ?? []) {
235
+ const host = hostOf(origin);
236
+ let domain = host;
237
+ if (c.domain) {
238
+ const ok = allowed.some(o => domainMatches(hostOf(o), c.domain));
239
+ if (!ok) {
240
+ throw new Error(`Cookie "${c.name}" has Domain=${c.domain}, which doesn't match ${host}. Cookies only go to the target origin; add --allow-origin to widen it.`);
241
+ }
242
+ // A Domain attribute means "this host and its subdomains", as in a browser.
243
+ domain = `.${c.domain.replace(/^\./, '')}`;
244
+ }
245
+ cookies.push({
246
+ name: c.name,
247
+ value: c.value,
248
+ domain,
249
+ path: c.path ?? '/',
250
+ expires: -1,
251
+ httpOnly: c.httpOnly ?? false,
252
+ secure: c.secure ?? false,
253
+ sameSite: c.sameSite ?? 'Lax',
254
+ });
255
+ secrets.push(c.value);
256
+ }
257
+ if (cookies.length) {
258
+ summary.cookies = cookies.map(c => ({ name: c.name, value: MASK, domain: c.domain, path: c.path }));
259
+ }
260
+ const headers = {};
261
+ for (const [k, v] of Object.entries(opts.headers ?? {})) {
262
+ if (!TOKEN.test(k))
263
+ throw new Error(`Invalid header name "${k}"`);
264
+ headers[k] = String(v);
265
+ secrets.push(String(v));
266
+ }
267
+ if (Object.keys(headers).length) {
268
+ summary.headers = Object.fromEntries(Object.keys(headers).map(k => [k, MASK]));
269
+ }
270
+ let state;
271
+ if (opts.storageState) {
272
+ const path = resolve(opts.storageState);
273
+ if (!existsSync(path))
274
+ throw new Error(`Storage state file not found: ${path}`);
275
+ state = scopeState(readState(path), allowed);
276
+ summary.storageState = path;
277
+ }
278
+ else if (origin) {
279
+ const path = sessionPath(origin, dir);
280
+ if (existsSync(path)) {
281
+ const file = readState(path);
282
+ const info = describeSession(path, file, origin);
283
+ const load = opts.useSession !== false && !info.expired;
284
+ const { localStorage: _ls, ...rest } = info;
285
+ summary.session = { ...rest, loaded: load };
286
+ if (load)
287
+ state = scopeState(file, allowed);
288
+ }
289
+ }
290
+ for (const c of state?.cookies ?? [])
291
+ secrets.push(c.value);
292
+ for (const o of state?.origins ?? [])
293
+ for (const kv of o.localStorage)
294
+ secrets.push(kv.value);
295
+ const used = Object.keys(summary).length > 0;
296
+ return {
297
+ ...(origin ? { origin } : {}),
298
+ allowed,
299
+ ...(state ? { state } : {}),
300
+ cookies,
301
+ headers,
302
+ ...(used ? { summary } : {}),
303
+ secrets: [...new Set(secrets.filter(s => s.length >= 4))].sort((a, b) => b.length - a.length),
304
+ };
305
+ }
306
+ /** `2026-10-29 22:55` in the local time zone. */
307
+ export function localTime(iso) {
308
+ if (!iso)
309
+ return '';
310
+ const d = new Date(iso);
311
+ if (Number.isNaN(d.getTime()))
312
+ return '';
313
+ const p = (n) => String(n).padStart(2, '0');
314
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
315
+ }
316
+ /** Replace every secret value in text with •••. */
317
+ export function redact(text, secrets) {
318
+ let out = text;
319
+ for (const s of secrets)
320
+ if (out.includes(s))
321
+ out = out.split(s).join(MASK);
322
+ return out;
323
+ }
324
+ /** One line for plain output: `session http://x (saved …), cookie sid=•••, header Authorization: •••`. */
325
+ export function describeAuth(s) {
326
+ const parts = [];
327
+ if (s.session) {
328
+ const when = s.session.savedAt ? `saved ${localTime(s.session.savedAt)}` : '';
329
+ if (s.session.loaded)
330
+ parts.push(`session ${s.session.origin}${when ? ` (${when})` : ''}`);
331
+ else if (s.session.expired)
332
+ parts.push(`session ${s.session.origin} expired, not loaded`);
333
+ else
334
+ parts.push(`session ${s.session.origin} skipped (--no-session)`);
335
+ }
336
+ if (s.storageState)
337
+ parts.push(`storage state ${s.storageState}`);
338
+ for (const c of s.cookies ?? [])
339
+ parts.push(`cookie ${c.name}=${MASK}`);
340
+ for (const h of Object.keys(s.headers ?? {}))
341
+ parts.push(`header ${h}: ${MASK}`);
342
+ if (s.allowOrigins?.length)
343
+ parts.push(`also ${s.allowOrigins.join(', ')}`);
344
+ return parts.join(', ');
345
+ }
346
+ // ------------------------------------------------------------------ apply
347
+ /**
348
+ * Send the extra headers on requests to allowed origins only. Uses the CDP
349
+ * Fetch domain rather than page.route: route() header overrides follow
350
+ * redirects, so a 302 to another host would carry the header there. Fetch
351
+ * pauses each redirect hop separately, so every hop is checked.
352
+ */
353
+ export async function applyHeaders(context, page, auth) {
354
+ const extra = Object.entries(auth.headers);
355
+ if (extra.length === 0)
356
+ return;
357
+ const lower = new Set(extra.map(([k]) => k.toLowerCase()));
358
+ const cdp = await context.newCDPSession(page);
359
+ cdp.on('Fetch.requestPaused', e => {
360
+ const allowed = auth.allowed.includes(originOf(e.request.url) ?? '');
361
+ const params = { requestId: e.requestId };
362
+ if (allowed) {
363
+ params.headers = [
364
+ ...Object.entries(e.request.headers)
365
+ .filter(([k]) => !lower.has(k.toLowerCase()))
366
+ .map(([name, value]) => ({ name, value: String(value) })),
367
+ ...extra.map(([name, value]) => ({ name, value })),
368
+ ];
369
+ }
370
+ cdp.send('Fetch.continueRequest', params).catch(() => { });
371
+ });
372
+ await cdp.send('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Request' }] });
373
+ }
374
+ // ------------------------------------------------------------------ login pages
375
+ const LOGIN_SEGMENT = /^(log-?in|sign-?in|sign_in|signin|auth|authenticate|oauth2?|sso)(\.\w+)?$/i;
376
+ /** True when a path segment looks like a login page (`/login`, `/sign-in`, `/auth/...`). */
377
+ export function isLoginUrl(url) {
378
+ try {
379
+ return new URL(url).pathname.split('/').some(seg => LOGIN_SEGMENT.test(seg));
380
+ }
381
+ catch {
382
+ return false;
383
+ }
384
+ }
385
+ /** The page ended on a login URL that isn't the one requested. */
386
+ export function redirectedToLogin(requested, final) {
387
+ try {
388
+ const a = new URL(requested);
389
+ const b = new URL(final);
390
+ if (b.protocol !== 'http:' && b.protocol !== 'https:')
391
+ return false;
392
+ return isLoginUrl(final) && a.origin + a.pathname !== b.origin + b.pathname;
393
+ }
394
+ catch {
395
+ return false;
396
+ }
397
+ }
398
+ export function loginHint(origin) {
399
+ return `Session missing or expired: run \`rpc login ${origin}\``;
400
+ }
401
+ export class AuthRequiredError extends Error {
402
+ requested;
403
+ finalUrl;
404
+ status;
405
+ name = 'AuthRequiredError';
406
+ code = 'AUTH_REQUIRED';
407
+ hint;
408
+ constructor(requested, finalUrl, status) {
409
+ const final = new URL(finalUrl);
410
+ super(`Redirected to a login page (${final.origin}${final.pathname}) instead of ${requested}`);
411
+ this.requested = requested;
412
+ this.finalUrl = finalUrl;
413
+ this.status = status;
414
+ this.hint = loginHint(new URL(requested).origin);
415
+ }
416
+ }
package/dist/browser.js CHANGED
@@ -63,17 +63,19 @@ export async function doctor() {
63
63
  }
64
64
  /**
65
65
  * Run `playwright install --only-shell chromium` with this package's
66
- * Playwright, streaming its output. rpc always launches headless, which uses
66
+ * Playwright, streaming its output. Captures always run headless, which uses
67
67
  * the headless shell, so the full Chrome for Testing build (about 370 MB on
68
- * disk) would never run.
68
+ * disk) is only fetched with `headed` (for `rpc login`'s browser window).
69
69
  */
70
- export function installChromium() {
70
+ export function installChromium(opts = {}) {
71
71
  return new Promise((res, rej) => {
72
72
  // Load the CLI through -e with a plain argv[1]. Under npx the real path is
73
73
  // in the `_npx` cache, and Playwright then prints a box telling the user
74
74
  // to install their project's dependencies, which doesn't apply here.
75
75
  const loader = `process.argv.splice(1, 0, 'playwright'); require(${JSON.stringify(playwrightCli())});`;
76
- const child = spawn(process.execPath, ['-e', loader, 'install', '--only-shell', 'chromium'], { stdio: 'inherit' });
76
+ // --headed: full Chromium as well, for `rpc login`'s visible window.
77
+ const args = opts.headed ? ['install', 'chromium'] : ['install', '--only-shell', 'chromium'];
78
+ const child = spawn(process.execPath, ['-e', loader, ...args], { stdio: 'inherit' });
77
79
  child.on('error', rej);
78
80
  child.on('exit', code => res(code ?? 1));
79
81
  });
package/dist/capture.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { mkdir, readdir, rm, writeFile } from 'node:fs/promises';
2
2
  import { basename, extname, resolve } from 'node:path';
3
+ import { applyHeaders, AuthRequiredError, redact, redirectedToLogin, resolveAuth, } from './auth.js';
3
4
  import { launchBrowser } from './browser.js';
4
5
  import { renderFramed, renderSlices } from './composite.js';
5
6
  import { explainError } from './errors.js';
@@ -11,7 +12,10 @@ export { launchBrowser } from './browser.js';
11
12
  export async function capture(opts, hooks = {}) {
12
13
  const devices = resolveDevices(opts.devices);
13
14
  const outDir = resolve(opts.outDir);
15
+ const auth = hooks.auth ?? resolveAuth(opts.url, opts.auth ?? {});
14
16
  await mkdir(outDir, { recursive: true });
17
+ if (auth.summary)
18
+ hooks.onAuth?.(auth.summary);
15
19
  const browser = hooks.browser ?? (await launchBrowser());
16
20
  try {
17
21
  const shots = [];
@@ -19,28 +23,38 @@ export async function capture(opts, hooks = {}) {
19
23
  let shot;
20
24
  hooks.onDeviceStart?.(device);
21
25
  try {
22
- shot = await captureDevice(browser, device, opts, outDir, hooks);
26
+ shot = await captureDevice(browser, device, opts, outDir, hooks, auth);
23
27
  }
24
28
  catch (err) {
25
- shot = baseShot(device);
26
- const e = explainError(err, { url: opts.url, timeoutMs: opts.timeoutMs, waitFor: opts.waitFor });
27
- shot.error = e.error;
28
- shot.code = e.code;
29
- if (e.hint)
30
- shot.hint = e.hint;
29
+ shot = { ...baseShot(device), ...describeFailure(err, opts, auth) };
30
+ }
31
+ if (shot.consoleErrors?.length && auth.secrets.length) {
32
+ shot.consoleErrors = shot.consoleErrors.map(l => redact(l, auth.secrets));
31
33
  }
32
34
  if (!hooks.keepBuffers)
33
35
  delete shot.buffers;
34
36
  hooks.onDevice?.(shot);
35
37
  shots.push(shot);
36
38
  }
37
- return { url: opts.url, outDir, shots };
39
+ return { url: opts.url, outDir, shots, ...(auth.summary ? { auth: auth.summary } : {}) };
38
40
  }
39
41
  finally {
40
42
  if (!hooks.browser)
41
43
  await browser.close();
42
44
  }
43
45
  }
46
+ /** A device's navigation/capture failure as `{ error, code, hint }`, secrets scrubbed. */
47
+ export function describeFailure(err, opts, auth) {
48
+ if (err instanceof AuthRequiredError) {
49
+ return { status: err.status, finalUrl: err.finalUrl, error: err.message, code: err.code, hint: err.hint };
50
+ }
51
+ const e = explainError(err, { url: opts.url, timeoutMs: opts.timeoutMs, waitFor: opts.waitFor });
52
+ return {
53
+ error: redact(e.error, auth.secrets),
54
+ code: e.code,
55
+ ...(e.hint ? { hint: redact(e.hint, auth.secrets) } : {}),
56
+ };
57
+ }
44
58
  function baseShot(device) {
45
59
  return {
46
60
  device: device.id,
@@ -49,7 +63,8 @@ function baseShot(device) {
49
63
  dpr: device.dpr,
50
64
  };
51
65
  }
52
- async function captureDevice(browser, device, opts, outDir, hooks) {
66
+ /** A fresh context for one device: viewport, DPR, mobile emulation, media features and auth state. */
67
+ export async function newDeviceContext(browser, device, opts, auth) {
53
68
  const emu = opts.emulate ? emulationFor(device) : undefined;
54
69
  const context = await browser.newContext({
55
70
  viewport: { width: device.width, height: device.height },
@@ -60,11 +75,19 @@ async function captureDevice(browser, device, opts, outDir, hooks) {
60
75
  ...(opts.colorScheme ? { colorScheme: opts.colorScheme } : {}),
61
76
  ...(opts.reducedMotion ? { reducedMotion: 'reduce' } : {}),
62
77
  ignoreHTTPSErrors: true, // self-signed local dev certs
78
+ ...(auth.state ? { storageState: auth.state } : {}),
63
79
  });
80
+ if (auth.cookies.length)
81
+ await context.addCookies(auth.cookies);
82
+ return context;
83
+ }
84
+ async function captureDevice(browser, device, opts, outDir, hooks, auth) {
85
+ const context = await newDeviceContext(browser, device, opts, auth);
64
86
  const result = baseShot(device);
65
87
  const errors = new Set();
66
88
  try {
67
89
  const page = await context.newPage();
90
+ await applyHeaders(context, page, auth);
68
91
  page.on('pageerror', e => errors.add(`pageerror: ${e.message}`));
69
92
  page.on('console', m => {
70
93
  if (m.type() === 'error')
@@ -73,8 +96,20 @@ async function captureDevice(browser, device, opts, outDir, hooks) {
73
96
  const response = await page.goto(opts.url, { waitUntil: 'load', timeout: opts.timeoutMs });
74
97
  result.status = response?.status() ?? null;
75
98
  await settle(page, opts.timeoutMs);
99
+ // A login page instead of the requested one: say so rather than capture it.
100
+ const checkLogin = () => {
101
+ if (redirectedToLogin(opts.url, page.url()))
102
+ throw new AuthRequiredError(opts.url, page.url(), result.status ?? null);
103
+ };
104
+ checkLogin();
76
105
  if (opts.waitFor) {
77
- await page.waitForSelector(opts.waitFor, { state: 'visible', timeout: opts.timeoutMs });
106
+ try {
107
+ await page.waitForSelector(opts.waitFor, { state: 'visible', timeout: opts.timeoutMs });
108
+ }
109
+ catch (err) {
110
+ checkLogin();
111
+ throw err;
112
+ }
78
113
  }
79
114
  if (opts.fullPage) {
80
115
  await warmUpScroll(page, opts.maxHeight);
@@ -82,6 +117,7 @@ async function captureDevice(browser, device, opts, outDir, hooks) {
82
117
  }
83
118
  if (opts.waitMs > 0)
84
119
  await page.waitForTimeout(opts.waitMs);
120
+ checkLogin();
85
121
  result.finalUrl = page.url();
86
122
  result.title = await page.title();
87
123
  result.scrollHeight = await page.evaluate(SCROLL_HEIGHT_JS);
@@ -211,7 +247,7 @@ const SCROLL_HEIGHT_JS = `Math.max(document.documentElement.scrollHeight, docume
211
247
  * Network idle (bounded: dev servers with HMR or analytics beacons may never
212
248
  * go idle), web fonts, and pending <img> loads.
213
249
  */
214
- async function settle(page, timeoutMs) {
250
+ export async function settle(page, timeoutMs) {
215
251
  await page
216
252
  .waitForLoadState('networkidle', { timeout: Math.min(timeoutMs, 10_000) })
217
253
  .catch(() => { });