@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.
- package/README.md +56 -8
- package/bin/spectra.js +8 -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 +2 -2
- package/schema/config.schema.json +50 -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/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-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/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;
|