@progen-labs/spectra 0.4.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.
@@ -19,7 +19,7 @@ import { FRONTEND_ROOT, REPORTS_DIR, SOURCE_ROOTS, FRAMEWORK } from './config.js
19
19
  import { SourceIndex } from './analysis/locate.js';
20
20
  import { gitInfo } from './git.js';
21
21
  import { REPORT_SCHEMA_VERSION } from '../shared/result.js';
22
- import { buildSignInUrl, looksSignedOut } from './signin.js';
22
+ import { buildSignInUrl, hasLoginForm, looksSignedOut, normalizeSignIn, parsePairs, signInWithForm, waitForManualSignIn } from './signin.js';
23
23
 
24
24
  const here = path.dirname(fileURLToPath(import.meta.url));
25
25
  const PROBE_SOURCE = fs.readFileSync(
@@ -73,6 +73,11 @@ export class PerfSession extends EventEmitter {
73
73
  this.recording = null;
74
74
  this.lastError = '';
75
75
  this.lastTrace = null;
76
+ // the sign-in for this browser: kept in memory only, never saved or reported
77
+ this.signIn = null;
78
+ this.signedIn = false;
79
+ this.prepared = '';
80
+ this.addHeader = null;
76
81
  }
77
82
 
78
83
  setState(state, extra = {}) {
@@ -90,6 +95,11 @@ export class PerfSession extends EventEmitter {
90
95
  signIn,
91
96
  }) {
92
97
  if (this.browser) await this.disconnect();
98
+ this.signIn = normalizeSignIn(signIn);
99
+ this.signedIn = false;
100
+ this.headless = mode !== 'attach' && headless;
101
+ if (this.signIn?.method === 'manual' && this.headless)
102
+ throw new Error('Signing in yourself needs a visible Chrome window: turn off Headless (or run with --headed).');
93
103
  this.browser =
94
104
  mode === 'attach'
95
105
  ? await attachChrome(browserURL || 'http://127.0.0.1:9222')
@@ -108,8 +118,13 @@ export class PerfSession extends EventEmitter {
108
118
  pages[0] ||
109
119
  (await this.browser.newPage());
110
120
  await this.attachPage(page);
111
- if (url) await this.navigate(url, signIn);
112
- this.setState('idle');
121
+ this.setState('connecting');
122
+ try {
123
+ if (url) await this.navigate(url, signIn);
124
+ } finally {
125
+ // connected even when signing in failed: the page stays open to look at and retry
126
+ this.setState('idle');
127
+ }
113
128
  }
114
129
 
115
130
  async disconnect() {
@@ -154,6 +169,8 @@ export class PerfSession extends EventEmitter {
154
169
  this.page = page;
155
170
  this.scripts.clear();
156
171
  this.injected = [];
172
+ this.prepared = '';
173
+ this.addHeader = null;
157
174
  const client = await page.createCDPSession();
158
175
  this.client = client;
159
176
  client.on('Debugger.scriptParsed', e =>
@@ -217,24 +234,142 @@ export class PerfSession extends EventEmitter {
217
234
  }
218
235
 
219
236
  /**
220
- * Opens `url`. If that fails or lands on a login screen (the browser profile
221
- * starts signed out) and a sign-in URL template is given, signs in through it
222
- * and lands on `url` afterwards.
237
+ * Opens `url`, signed in. Chrome starts signed out, so with a sign-in (given
238
+ * here, or kept from connect) it first sets up a browser login prompt, token,
239
+ * cookies or storage entries, then loads the page; if the page still asks for
240
+ * a login, or a login page was given, it signs in (form or link) or waits for
241
+ * you to (manual), and lands on `url` afterwards. Once per browser.
223
242
  */
224
243
  async navigate(url, signIn) {
225
244
  this.requirePage();
245
+ const given = normalizeSignIn(signIn);
246
+ // new details (the dashboard's form changed): sign in again with them
247
+ if (given && JSON.stringify(given) !== JSON.stringify(this.signIn)) {
248
+ this.signIn = given;
249
+ this.signedIn = false;
250
+ }
251
+ const s = this.signIn;
226
252
  const go = target =>
227
253
  this.page.goto(target, {
228
254
  waitUntil: 'domcontentloaded',
229
255
  timeout: 60_000,
230
256
  });
257
+ if (s) await this.prepareSignIn(s, url);
231
258
  this.navigation = null;
232
259
  await go(url);
233
- const signInUrl = buildSignInUrl(signIn, url);
234
- if (signInUrl && looksSignedOut(this.navigation, this.page.url())) {
260
+ if (!s) return;
261
+
262
+ const signedOut = async () =>
263
+ looksSignedOut(this.navigation, this.page.url()) || (await hasLoginForm(this.page));
264
+ if (this.signedIn) {
265
+ // a session can expire (or a journey sign out): sent back to the login page, sign in again
266
+ if (!looksSignedOut(this.navigation, this.page.url())) return;
267
+ this.signedIn = false;
268
+ }
269
+ if (s.method === 'link') {
270
+ if (await signedOut()) {
271
+ this.navigation = null;
272
+ await go(buildSignInUrl(s.url, url));
273
+ }
274
+ return;
275
+ }
276
+ if (s.method === 'http' || s.method === 'token') {
277
+ if (await signedOut())
278
+ throw new Error(`The page still asks for a login after signing in with ${s.method === 'http' ? 'the browser login prompt' : 'the token, cookies or storage entries'}. Check the values.`);
279
+ this.signedIn = true;
280
+ return;
281
+ }
282
+ // form or manual: when the page asks for it, or straight away when a login page was given
283
+ if (!s.url && !(await signedOut())) {
284
+ this.signedIn = true;
285
+ return;
286
+ }
287
+ const loginUrl = s.url ? buildSignInUrl(s.url, url) : '';
288
+ const was = this.state;
289
+ this.setState('signing-in', { method: s.method }); // the dashboard says what is going on
290
+ try {
291
+ if (s.method === 'manual') {
292
+ if (loginUrl) await go(loginUrl);
293
+ await waitForManualSignIn(this.page, { loginUrl, timeoutMs: s.waitMs });
294
+ } else {
295
+ try {
296
+ await signInWithForm(this.page, { ...s, url: loginUrl });
297
+ } catch (err) {
298
+ // a login page that shows no form, on an app that is not asking for one: already signed in
299
+ if (!(loginUrl && /No login form was found/.test(err.message) && !(await signedOut()))) throw err;
300
+ }
301
+ }
302
+ } finally {
303
+ this.setState(was);
304
+ }
305
+ this.signedIn = true;
306
+ // back to the page to profile, unless the login brought us there
307
+ const same = (a, b) => {
308
+ try {
309
+ const x = new URL(a);
310
+ const y = new URL(b);
311
+ return x.origin === y.origin && x.pathname === y.pathname && x.search === y.search;
312
+ } catch {
313
+ return a === b;
314
+ }
315
+ };
316
+ if (!same(this.page.url(), url)) {
235
317
  this.navigation = null;
236
- await go(signInUrl);
318
+ await go(url);
319
+ }
320
+ if (await signedOut())
321
+ throw new Error(`Signed in, but ${new URL(url).pathname} still shows a login. Check the login page URL and the details.`);
322
+ }
323
+
324
+ /**
325
+ * What must be in place before the first request: the answer to the
326
+ * browser's login prompt, or the token's cookies, storage entries and header.
327
+ * Cookies and storage are scoped to the page's origin, and the header is
328
+ * added only to requests to that origin, so it never reaches third parties.
329
+ */
330
+ async prepareSignIn(s, url) {
331
+ let origin;
332
+ try {
333
+ origin = new URL(url).origin;
334
+ } catch {
335
+ return;
336
+ }
337
+ const key = `${origin}|${JSON.stringify(s)}`;
338
+ if (this.prepared === key) return;
339
+ if (s.method === 'http') await this.page.authenticate({ username: s.username, password: s.password });
340
+ if (s.method === 'token') {
341
+ const cookies = parsePairs(s.cookies);
342
+ if (cookies.length) await this.page.setCookie(...cookies.map(([name, value]) => ({ name, value, url: origin })));
343
+ const storage = parsePairs(s.storage);
344
+ if (storage.length)
345
+ await this.inject(
346
+ `(() => { if (location.origin !== ${JSON.stringify(origin)}) return; try { ${storage
347
+ .map(([k, v]) => `localStorage.setItem(${JSON.stringify(k)}, ${JSON.stringify(v)});`)
348
+ .join(' ')} } catch (e) {} })();`,
349
+ { now: false },
350
+ );
351
+ }
352
+ // one header handler at a time: drop the last one (another origin or other details)
353
+ const client = this.client;
354
+ if (this.addHeader) {
355
+ client.off('Fetch.requestPaused', this.addHeader);
356
+ this.addHeader = null;
357
+ await client.send('Fetch.disable').catch(() => {});
358
+ }
359
+ if (s.method === 'token' && s.headerName && s.headerValue) {
360
+ const name = s.headerName;
361
+ const value = s.headerValue;
362
+ this.addHeader = e => {
363
+ const headers = Object.entries(e.request.headers)
364
+ .filter(([n]) => n.toLowerCase() !== name.toLowerCase())
365
+ .map(([n, v]) => ({ name: n, value: v }));
366
+ headers.push({ name, value });
367
+ client.send('Fetch.continueRequest', { requestId: e.requestId, headers }).catch(() => {});
368
+ };
369
+ client.on('Fetch.requestPaused', this.addHeader);
370
+ await client.send('Fetch.enable', { patterns: [{ urlPattern: `${origin}/*`, requestStage: 'Request' }] });
237
371
  }
372
+ this.prepared = key;
238
373
  }
239
374
 
240
375
  async reload() {
@@ -270,6 +405,8 @@ export class PerfSession extends EventEmitter {
270
405
  cpuThrottle: this.recording.cpuThrottle,
271
406
  },
272
407
  error: this.lastError,
408
+ // how this browser signs in, without any of the details
409
+ signIn: this.signIn && { method: this.signIn.method, signedIn: this.signedIn },
273
410
  };
274
411
  }
275
412
 
@@ -461,19 +598,21 @@ export class PerfSession extends EventEmitter {
461
598
  }
462
599
 
463
600
  /**
464
- * What the page looks like right now plus where each React component sits on
465
- * it, so the report can point from a slow component to its place on screen.
466
- * Best effort: any failure just means the report has no page map.
601
+ * What the page looks like right now plus where each component sits on it
602
+ * (React, Vue or Angular), so the report can point from a slow component to
603
+ * its place on screen. The screenshot is taken for every page, with or
604
+ * without components to pin. Best effort: a failure means no page map.
467
605
  */
468
606
  async capturePageView() {
469
607
  try {
470
608
  const { result } = await this.client.send('Runtime.evaluate', {
471
609
  expression:
472
- 'window.__PERF_PROBE__ && __PERF_PROBE__.layout ? __PERF_PROBE__.layout() : null',
610
+ 'window.__PERF_PROBE__ && __PERF_PROBE__.layout ? __PERF_PROBE__.layout() : ' +
611
+ '{ width: innerWidth, height: innerHeight, scrollX: Math.round(scrollX), scrollY: Math.round(scrollY), entries: [] }',
473
612
  returnByValue: true,
474
613
  });
475
614
  const layout = result.value;
476
- if (!layout?.entries?.length) return null;
615
+ if (!layout?.width || !layout?.height) return null;
477
616
  const shot = await this.client.send('Page.captureScreenshot', {
478
617
  format: 'jpeg',
479
618
  quality: 72,
@@ -491,7 +630,7 @@ export class PerfSession extends EventEmitter {
491
630
  }
492
631
  }
493
632
 
494
- /** Function -> source location for each React component seen, via [[FunctionLocation]]. */
633
+ /** Function -> source location for each React component seen, via [[FunctionLocation]] (Vue and Angular carry their own). */
495
634
  async componentLocations(probe) {
496
635
  const out = new Map();
497
636
  if (!probe?.components?.length) return out;