@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/README.md +76 -8
- package/agents/spectra.md +127 -0
- package/bin/spectra.js +19 -4
- package/dist/assets/index-mtYxDBW7.js +11 -0
- package/dist/assets/index-z14kjW8r.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +5 -3
- package/schema/config.schema.json +74 -0
- package/server/agents-cli.js +66 -0
- package/server/agents.js +283 -0
- package/server/analysis/findings.js +18 -9
- package/server/cli.js +10 -4
- package/server/explore/evaluate-cli.js +14 -4
- package/server/explore/evaluate.js +3 -1
- package/server/pdf/template.js +2 -2
- package/server/postinstall.js +20 -0
- package/server/probe.browser.js +85 -14
- package/server/project-config.js +17 -5
- package/server/recorder.js +154 -15
- package/server/signin.js +524 -2
- package/server/test-agents.js +105 -0
- package/server/test-signin.js +179 -0
- package/shared/compare.js +9 -9
- package/shared/snapshot.js +1 -1
- package/src/App.jsx +1 -1
- package/src/components/CompareResults.jsx +1 -1
- package/src/components/ControlBar.jsx +83 -60
- package/src/components/EvaluationPanel.jsx +1 -1
- package/src/components/FilePanel.jsx +2 -2
- package/src/components/PageMap.jsx +26 -22
- package/src/components/ReactPanel.jsx +2 -4
- package/src/components/SignIn.jsx +291 -0
- package/src/components/Vitals.jsx +1 -1
- package/src/framework.js +12 -0
- package/src/heat.js +3 -3
- package/src/styles.css +91 -0
- package/dist/assets/index-DY8VmG9y.js +0 -11
- package/dist/assets/index-Dx1YSFIx.css +0 -1
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] [
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
92
|
+
signIn,
|
|
90
93
|
onProgress: ev => {
|
|
91
|
-
if (ev.phase === '
|
|
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]
|
|
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(() => {}));
|
package/server/pdf/template.js
CHANGED
|
@@ -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
|
|
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('
|
|
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
|
+
}
|
package/server/probe.browser.js
CHANGED
|
@@ -13,13 +13,11 @@
|
|
|
13
13
|
*/
|
|
14
14
|
(function () {
|
|
15
15
|
if (window.__PERF_PROBE__) return;
|
|
16
|
-
//
|
|
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
|
-
//
|
|
653
|
+
// React's hook is ignored by every other framework, so it is always there.
|
|
590
654
|
try {
|
|
591
|
-
|
|
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 >
|
|
832
|
-
},
|
|
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 === '
|
|
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
|
})();
|
package/server/project-config.js
CHANGED
|
@@ -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).
|
|
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
|
-
* {
|
|
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
|
-
|
|
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
|
}
|
package/server/recorder.js
CHANGED
|
@@ -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
|
-
|
|
112
|
-
|
|
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
|
|
221
|
-
*
|
|
222
|
-
*
|
|
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
|
-
|
|
234
|
-
|
|
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(
|
|
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
|
|
465
|
-
*
|
|
466
|
-
*
|
|
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() :
|
|
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?.
|
|
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;
|