@progen-labs/spectra 0.4.1 → 0.6.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/server/cli.js CHANGED
@@ -17,6 +17,7 @@ import { PerfSession, readReport } from './recorder.js';
17
17
  import { REPORTS_DIR, TARGET_NAME } from './config.js';
18
18
  import { intOption, parseArgs } from './args.js';
19
19
  import { loadProjectConfig } from './project-config.js';
20
+ import { SIGN_IN_HELP, logSignIn, signInFromOptions } from './signin.js';
20
21
  import { EXIT, SPECTRA_VERSION, exitAfterFlush, fmt, printBudgets, reportFile, writeResult } from './output.js';
21
22
  import { buildProfileResult } from '../shared/result.js';
22
23
 
@@ -25,7 +26,8 @@ const { opt, flag } = parseArgs(process.argv.slice(2));
25
26
  const url = opt('url');
26
27
  if (!url || flag('help')) {
27
28
  console.error(
28
- 'usage: npm run profile -- --url <page> [--mode live|load] [--runs n] [--duration seconds] [--throttle 4] [--scenario file.mjs] [--json] [--out file.json] [--config spectra.config.json] [--headed] [--save-trace] [--sign-in "http://host/login/?redirect={url}"]',
29
+ 'usage: npm run profile -- --url <page> [--mode live|load] [--runs n] [--duration seconds] [--throttle 4] [--scenario file.mjs] [--json] [--out file.json] [--config spectra.config.json] [--headed] [--save-trace] [sign-in options]\n\n' +
30
+ SIGN_IN_HELP,
29
31
  );
30
32
  process.exit(url ? EXIT.OK : EXIT.ERROR);
31
33
  }
@@ -36,7 +38,7 @@ const out = opt('out');
36
38
  const log = json ? console.error : console.log;
37
39
  const sleep = ms => new Promise(r => setTimeout(r, ms));
38
40
 
39
- let mode, runs, duration, cpuThrottle, config, scenario;
41
+ let mode, runs, duration, cpuThrottle, config, scenario, signIn;
40
42
  try {
41
43
  mode = opt('mode', 'live');
42
44
  if (!['live', 'load'].includes(mode)) throw new Error(`--mode must be live or load (got ${mode}).`);
@@ -44,6 +46,7 @@ try {
44
46
  duration = Number(opt('duration', 8)) * 1000;
45
47
  cpuThrottle = Number(opt('throttle', 1));
46
48
  config = loadProjectConfig(opt('config'));
49
+ signIn = signInFromOptions(opt, config.signIn);
47
50
  const scenarioFile = opt('scenario');
48
51
  if (scenarioFile) scenario = (await import(pathToFileURL(path.resolve(scenarioFile)).href)).default;
49
52
  } catch (err) {
@@ -82,7 +85,7 @@ async function recordOnce(session, index) {
82
85
  if (mode === 'load') return recordLoad(session, startOpts);
83
86
  // live: every run starts from the same freshly loaded page
84
87
  if (index > 0) {
85
- await session.navigate(url, opt('sign-in'));
88
+ await session.navigate(url, signIn);
86
89
  await sleep(800);
87
90
  }
88
91
  await session.start(startOpts);
@@ -128,10 +131,13 @@ function printProfile(result, reports) {
128
131
  }
129
132
 
130
133
  const session = new PerfSession();
134
+ logSignIn(session);
131
135
  const reports = [];
132
136
  let code = EXIT.OK;
133
137
  try {
134
- await session.connect({ mode: 'launch', url, headless: !flag('headed'), signIn: opt('sign-in') });
138
+ // signing in yourself needs the window, so it shows Chrome even without --headed
139
+ await session.connect({ mode: 'launch', url, headless: !flag('headed') && signIn?.method !== 'manual', signIn });
140
+ if (session.signedIn) console.error('spectra: signed in');
135
141
  await sleep(1500);
136
142
  await session.reload(); // make sure the probe was installed before the app loaded
137
143
  await sleep(1000);
@@ -13,6 +13,7 @@ import { readReport } from '../recorder.js';
13
13
  import { REPORTS_DIR, TARGET_NAME } from '../config.js';
14
14
  import { parseArgs } from '../args.js';
15
15
  import { loadProjectConfig } from '../project-config.js';
16
+ import { signInFromOptions } from '../signin.js';
16
17
  import { EXIT, SPECTRA_VERSION, exitAfterFlush, printBudgets, reportFile, writeResult } from '../output.js';
17
18
  import { buildEvaluationResult } from '../../shared/result.js';
18
19
 
@@ -28,9 +29,10 @@ const out = opt('out');
28
29
  // In --json mode stdout carries only the result; people still see the matrix.
29
30
  const log = json ? console.error : console.log;
30
31
 
31
- let config;
32
+ let config, signIn;
32
33
  try {
33
34
  config = loadProjectConfig(opt('config'));
35
+ signIn = signInFromOptions(opt, config.signIn);
34
36
  } catch (err) {
35
37
  console.error(`spectra: ${err.message}`);
36
38
  process.exit(EXIT.ERROR);
@@ -84,11 +86,19 @@ let result;
84
86
  try {
85
87
  result = await runEvaluation({
86
88
  url,
87
- headless: !flag('headed'),
89
+ // signing in yourself needs the window, so it shows Chrome even without --headed
90
+ headless: !flag('headed') && signIn?.method !== 'manual',
88
91
  limit: Number(opt('paths', 12)),
89
- signIn: opt('sign-in'),
92
+ signIn,
90
93
  onProgress: ev => {
91
- if (ev.phase === 'baseline') console.error('spectra: recording baseline page load …');
94
+ if (ev.phase === 'sign-in')
95
+ console.error(
96
+ ev.method === 'manual'
97
+ ? 'spectra: sign in in the Chrome window (any way the app asks); Spectra carries on once you are in …'
98
+ : 'spectra: signing in through the login form …',
99
+ );
100
+ else if (ev.phase === 'signed-in') console.error('spectra: signed in');
101
+ else if (ev.phase === 'baseline') console.error('spectra: recording baseline page load …');
92
102
  else if (ev.phase === 'discover') console.error('spectra: discovering actionable elements …');
93
103
  else if (ev.phase === 'synthesized')
94
104
  console.error(
@@ -65,7 +65,7 @@ function recordBaseline(session) {
65
65
  * @param {boolean} [opts.headless] default true
66
66
  * @param {number} [opts.limit] max golden paths (default 12)
67
67
  * @param {string[]} [opts.allow] selectors of destructive actions to permit
68
- * @param {string} [opts.signIn] sign-in URL template
68
+ * @param {string|object} [opts.signIn] how to sign in (signin.js normalizeSignIn)
69
69
  * @param {(ev:object)=>void} [opts.onProgress]
70
70
  */
71
71
  export async function runEvaluation({
@@ -78,9 +78,11 @@ export async function runEvaluation({
78
78
  } = {}) {
79
79
  const log = onProgress || (() => {});
80
80
  const session = new PerfSession();
81
+ session.on('state', s => s.state === 'signing-in' && log({ phase: 'sign-in', method: s.method }));
81
82
  const results = [];
82
83
  try {
83
84
  await session.connect({ mode: 'launch', url, headless, signIn });
85
+ if (session.signedIn) log({ phase: 'signed-in' });
84
86
  // Auto-dismiss alert/confirm/beforeunload dialogs so a journey can't hang.
85
87
  if (session.page) {
86
88
  session.page.on('dialog', d => d.dismiss().catch(() => {}));
@@ -356,7 +356,7 @@ function methodology() {
356
356
  ).join('');
357
357
  return `<h2>Methodology</h2>
358
358
  <div class="callout">
359
- <p><b>What is compared.</b> Profiler reports recorded in Chrome through the DevTools Protocol (tracing, V8 CPU sampling and an in-page React probe). When a side has several runs, each metric is the <b>median</b> of its runs.</p>
359
+ <p><b>What is compared.</b> Profiler reports recorded in Chrome through the DevTools Protocol (tracing, V8 CPU sampling and an in-page probe for React, Vue and Angular components). When a side has several runs, each metric is the <b>median</b> of its runs.</p>
360
360
  <p><b>What counts as a change.</b> A metric is “improved” or “degraded” only when the difference exceeds <b>all three</b> of: its absolute floor, its relative floor (both in the table below) and the <b>run-to-run spread</b> seen on either side. Everything else is “unchanged”. For every metric listed here <b>lower is better</b>.</p>
361
361
  <p><b>Overall verdict.</b> Decided only by the <i>key</i> metrics: improved if some improved and none degraded; regressed if some degraded and none improved; mixed if both; otherwise no significant change.</p>
362
362
  <p><b>Limits.</b> A single run per side cannot separate a real change from noise: record each side at least three times, on the same page, in the same mode, with the same CPU setting. Interaction recordings are only comparable if the same actions were performed. Dev builds are slower than production: trust direction and ranking more than absolute numbers.</p>
@@ -396,7 +396,7 @@ function detailedBody(cmp) {
396
396
  <h2>Highlights</h2>${headlines(cmp)}
397
397
  <h2>Comparability</h2>${checksList(cmp, { onlyProblems: false })}
398
398
  <h2 class="pagebreak">All metrics</h2>${metricsTable(cmp.metrics, { grouped: true })}
399
- <div class="pagebreak"></div>${entitySection('React components', 'components', cmp, { vendor: false })}
399
+ <div class="pagebreak"></div>${entitySection('Components', 'components', cmp, { vendor: false })}
400
400
  <div class="pagebreak"></div>${entitySection('Files', 'files', cmp, { vendor: false })}
401
401
  <div class="pagebreak"></div>${entitySection('Functions', 'functions', cmp, { vendor: false })}
402
402
  <div class="pagebreak"></div><h2>Problems found</h2>${findingsSection(cmp, { limit: 0 })}
@@ -0,0 +1,20 @@
1
+ /**
2
+ * After `npm install @progen-labs/spectra`: teaches the AI agents this project
3
+ * or machine uses about Spectra (server/agents.js), so they can run its
4
+ * commands when asked in plain words. It never fails an install, never runs in
5
+ * CI, for global installs or for `npx`, and is off with SPECTRA_AGENT_DOCS=0
6
+ * or `"agentDocs": false` in spectra.config.json. `npx spectra agents` does the
7
+ * same by hand (for package managers that skip install scripts), and
8
+ * `npx spectra agents --remove` takes it all back out.
9
+ */
10
+ try {
11
+ const { installAgentDocs, postinstallPlan } = await import('./agents.js');
12
+ const plan = postinstallPlan();
13
+ if (!plan.skip) {
14
+ const changed = installAgentDocs({ root: plan.root, targets: plan.targets }).filter(r => r.result === 'created' || r.result === 'updated');
15
+ if (changed.length)
16
+ console.error(`spectra: taught your AI agents about Spectra (${changed.map(r => r.file).join(', ')}). Undo: npx spectra agents --remove`);
17
+ }
18
+ } catch {
19
+ // an install must never fail because of this
20
+ }
@@ -13,13 +13,11 @@
13
13
  */
14
14
  (function () {
15
15
  if (window.__PERF_PROBE__) return;
16
- // Runtime hint injected by the profiler: { framework, bundler, isSSR }
16
+ // What the project's package.json suggests: { framework, bundler, isSSR }. Only
17
+ // a hint: the page itself decides (every adapter below waits for its own
18
+ // framework to show up), so an Angular app is profiled as Angular even when
19
+ // Spectra runs from another folder, a monorepo root or an editor.
17
20
  var SPECTRA_FRAMEWORK = typeof window.__SPECTRA_FRAMEWORK__ !== 'undefined' ? window.__SPECTRA_FRAMEWORK__ : null;
18
- var WANT_REACT =
19
- !SPECTRA_FRAMEWORK ||
20
- SPECTRA_FRAMEWORK.framework === 'react' ||
21
- SPECTRA_FRAMEWORK.framework === 'next' ||
22
- SPECTRA_FRAMEWORK.framework === 'unknown';
23
21
 
24
22
  var PERFORMED_WORK = 1;
25
23
  var TAG_FUNCTION = 0;
@@ -482,11 +480,77 @@
482
480
  ];
483
481
  }
484
482
 
483
+ // The box an element occupies, clipped to the viewport. A component host that
484
+ // is an inline wrapper (Angular's <app-x>, Vue fragments) can have no box of
485
+ // its own, so it falls back to the union of its children's boxes.
486
+ function elementRect(el, vw, vh, depth) {
487
+ var left = Infinity;
488
+ var top = Infinity;
489
+ var right = -Infinity;
490
+ var bottom = -Infinity;
491
+ var r = el.getBoundingClientRect();
492
+ if (r.width > 0 && r.height > 0) {
493
+ left = r.left;
494
+ top = r.top;
495
+ right = r.right;
496
+ bottom = r.bottom;
497
+ } else if ((depth || 0) < 3) {
498
+ for (var c = el.firstElementChild; c; c = c.nextElementSibling) {
499
+ var cr = elementRect(c, Infinity, Infinity, (depth || 0) + 1);
500
+ if (!cr) continue;
501
+ left = Math.min(left, cr[0]);
502
+ top = Math.min(top, cr[1]);
503
+ right = Math.max(right, cr[0] + cr[2]);
504
+ bottom = Math.max(bottom, cr[1] + cr[3]);
505
+ }
506
+ }
507
+ if (left === Infinity) return null;
508
+ left = Math.max(0, left);
509
+ top = Math.max(0, top);
510
+ right = Math.min(vw, right);
511
+ bottom = Math.min(vh, bottom);
512
+ if (vw !== Infinity && (right - left < 16 || bottom - top < 16)) return null;
513
+ return [Math.round(left), Math.round(top), Math.round(right - left), Math.round(bottom - top)];
514
+ }
515
+
516
+ // Angular (development mode) and Vue: find each component's element in the DOM.
517
+ // Angular: ng.getComponent(host) is the component an element hosts. Vue 3: an
518
+ // element's __vueParentComponent is the component that rendered it, and it is
519
+ // that component's root element when the component's subTree points back at it.
520
+ function frameworkLayout(byId, vw, vh) {
521
+ var ng = window.ng;
522
+ var useNg = probe.ngIds && ng && typeof ng.getComponent === 'function';
523
+ var useVue = !!probe.vueIds;
524
+ if (!useNg && !useVue) return;
525
+ var els = document.querySelectorAll('*');
526
+ for (var i = 0; i < els.length; i++) {
527
+ var el = els[i];
528
+ var id = undefined; // var is function-scoped: reset it for every element
529
+ if (useNg) {
530
+ var inst = null;
531
+ try {
532
+ inst = ng.getComponent(el);
533
+ } catch (e) {}
534
+ if (inst) id = probe.ngIds.get(inst.constructor);
535
+ }
536
+ if (id === undefined && useVue) {
537
+ var vc = el.__vueParentComponent;
538
+ if (vc && ((vc.subTree && vc.subTree.el === el) || (vc.vnode && vc.vnode.el === el))) id = probe.vueIds.get(vc.type);
539
+ }
540
+ if (id === undefined || !probe.stats[id]) continue;
541
+ var rect = elementRect(el, vw, vh);
542
+ if (rect) (byId[id] = byId[id] || []).push(rect);
543
+ }
544
+ }
545
+
485
546
  // Where every component that rendered during the recording sits in the viewport.
486
547
  probe.layout = function () {
487
548
  var vw = window.innerWidth;
488
549
  var vh = window.innerHeight;
489
550
  var byId = {};
551
+ try {
552
+ frameworkLayout(byId, vw, vh);
553
+ } catch (e) {}
490
554
  probe.roots.forEach(function (root) {
491
555
  var stack = [root.current];
492
556
  while (stack.length) {
@@ -586,9 +650,9 @@
586
650
  };
587
651
 
588
652
  window.__PERF_PROBE__ = probe;
589
- // Only attempt to hook into React when the runtime hint allows it.
653
+ // React's hook is ignored by every other framework, so it is always there.
590
654
  try {
591
- if (WANT_REACT) installHook();
655
+ installHook();
592
656
  } catch (e) {
593
657
  // Fail silently: probe should still collect vitals even if hooking fails.
594
658
  }
@@ -600,6 +664,7 @@
600
664
  // from the SFC compiler (type.__file), so no source-map step is needed.
601
665
  function installVueAdapter() {
602
666
  var vueIds = new Map();
667
+ probe.vueIds = vueIds; // for the page map: component type -> stat id
603
668
  var nextId = 0;
604
669
  var perfStart = {};
605
670
 
@@ -718,7 +783,6 @@
718
783
  if (prevEmit) try { prevEmit.apply(null, arguments); } catch (e) {}
719
784
  };
720
785
  window.__VUE_DEVTOOLS_GLOBAL_HOOK__ = hook;
721
- probe.frameworkName = 'vue';
722
786
  }
723
787
 
724
788
  // ------------------------------------------------ Angular adapter
@@ -729,6 +793,7 @@
729
793
  // like React/Vue. The ng global appears only after bootstrap, so we poll.
730
794
  function installAngularAdapter() {
731
795
  var ngIds = new Map();
796
+ probe.ngIds = ngIds; // for the page map: component class -> stat id
732
797
  var nextId = 100000; // keep clear of other adapters' id space
733
798
  var stack = [];
734
799
 
@@ -825,11 +890,12 @@
825
890
  return false;
826
891
  }
827
892
  if (!register()) {
893
+ // poll for up to 30 s, and stop as soon as another framework shows up
828
894
  var tries = 0;
829
895
  var iv = setInterval(function () {
830
896
  tries++;
831
- if (register() || tries > 200) clearInterval(iv); // poll up to ~8s
832
- }, 40);
897
+ if (register() || probe.renderers > 0 || probe.frameworkName === 'vue' || tries > 300) clearInterval(iv);
898
+ }, 100);
833
899
  }
834
900
  }
835
901
 
@@ -931,11 +997,16 @@
931
997
  } catch (e) {}
932
998
  }
933
999
 
1000
+ // Vue and Angular too, whatever the hint says: each wakes up only for its own app
1001
+ try {
1002
+ installVueAdapter();
1003
+ } catch (e) {}
1004
+ try {
1005
+ installAngularAdapter();
1006
+ } catch (e) {}
934
1007
  try {
935
1008
  var fwHint = SPECTRA_FRAMEWORK && SPECTRA_FRAMEWORK.framework;
936
- if (fwHint === 'vue') installVueAdapter();
937
- else if (fwHint === 'angular') installAngularAdapter();
938
- else if (!WANT_REACT) installFrameworkProbes(); // svelte: heuristic for now
1009
+ if (fwHint === 'svelte') installFrameworkProbes(); // svelte: heuristic for now
939
1010
  } catch (e) {}
940
1011
  installObservers();
941
1012
  })();
@@ -1,22 +1,28 @@
1
1
  /**
2
2
  * Per-project settings: spectra.config.json in the profiled project's root
3
- * (or the file passed with --config). Today it holds performance budgets:
3
+ * (or the file passed with --config). It holds performance budgets and how
4
+ * to sign in to the app (never the password or other secrets: those come from
5
+ * the environment, see signin.js):
4
6
  *
5
- * { "budgets": { "tbtMs": 200, "lcpMs": 2500, "cls": 0.1, "criticalFindings": 0 } }
7
+ * {
8
+ * "budgets": { "tbtMs": 200, "lcpMs": 2500, "cls": 0.1, "criticalFindings": 0 },
9
+ * "signIn": { "method": "form", "url": "http://localhost:3000/login", "username": "demo@example.com" }
10
+ * }
6
11
  */
7
12
  import fs from 'node:fs';
8
13
  import path from 'node:path';
9
14
  import { FRONTEND_ROOT } from './config.js';
10
15
  import { validateBudgets } from '../shared/result.js';
16
+ import { validateSignInConfig } from './signin.js';
11
17
 
12
18
  export const CONFIG_FILE = 'spectra.config.json';
13
19
 
14
- /** @returns {{file: string|null, budgets: object}} — throws a readable Error on bad config */
20
+ /** @returns {{file: string|null, budgets: object, signIn: object|null}} — throws a readable Error on bad config */
15
21
  export function loadProjectConfig(explicitFile) {
16
22
  const file = explicitFile ? path.resolve(explicitFile) : path.join(FRONTEND_ROOT, CONFIG_FILE);
17
23
  if (!fs.existsSync(file)) {
18
24
  if (explicitFile) throw new Error(`config file not found: ${file}`);
19
- return { file: null, budgets: {} };
25
+ return { file: null, budgets: {}, signIn: null };
20
26
  }
21
27
  let raw;
22
28
  try {
@@ -26,5 +32,11 @@ export function loadProjectConfig(explicitFile) {
26
32
  }
27
33
  const checked = validateBudgets(raw?.budgets);
28
34
  if (!checked.ok) throw new Error(`${path.basename(file)}: ${checked.error}`);
29
- return { file, budgets: checked.budgets };
35
+ let signIn;
36
+ try {
37
+ signIn = validateSignInConfig(raw?.signIn);
38
+ } catch (err) {
39
+ throw new Error(`${path.basename(file)}: ${err.message}`);
40
+ }
41
+ return { file, budgets: checked.budgets, signIn };
30
42
  }
@@ -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;