@progen-labs/spectra 0.4.0 → 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 +59 -10
- 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/fix/harness.js +9 -0
- package/server/fix/test-fix.js +18 -2
- 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
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Signing in: the option handling, the authenticator codes, and every kind of
|
|
2
|
+
// login, end to end in Chrome against fixtures/login-demo (skipped without Chrome).
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { after, before, describe, test } from 'node:test';
|
|
8
|
+
import puppeteer from 'puppeteer-core';
|
|
9
|
+
import { normalizeSignIn, resolveSignIn, signInFromOptions, totp, validateSignInConfig, waitForManualSignIn } from './signin.js';
|
|
10
|
+
import { DEMO, startLoginDemo } from '../fixtures/login-demo/server.mjs';
|
|
11
|
+
|
|
12
|
+
// a throwaway Chrome profile, set before the recorder reads it: never the real one
|
|
13
|
+
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'spectra-signin-'));
|
|
14
|
+
process.env.PERF_CHROME_PROFILE_DIR = profile;
|
|
15
|
+
const { PerfSession } = await import('./recorder.js');
|
|
16
|
+
const { findChrome } = await import('./chrome.js');
|
|
17
|
+
|
|
18
|
+
test('totp makes the RFC 6238 codes', () => {
|
|
19
|
+
// RFC 6238 appendix B, SHA-1: the secret is "12345678901234567890"
|
|
20
|
+
const secret = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ';
|
|
21
|
+
assert.equal(totp(secret, 59_000, { digits: 8 }), '94287082');
|
|
22
|
+
assert.equal(totp(secret, 1_111_111_109_000, { digits: 8 }), '07081804');
|
|
23
|
+
assert.equal(totp(secret, 2_000_000_000_000, { digits: 8 }), '69279037');
|
|
24
|
+
assert.match(totp('jbsw y3dp ehpk 3pxp'), /^\d{6}$/); // spaces and lower case are fine
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('normalizeSignIn guesses the method and drops what cannot work', () => {
|
|
28
|
+
assert.deepEqual(normalizeSignIn('http://x/login?r={url}'), { method: 'link', url: 'http://x/login?r={url}' });
|
|
29
|
+
assert.equal(normalizeSignIn(''), null);
|
|
30
|
+
assert.equal(normalizeSignIn({}), null);
|
|
31
|
+
assert.equal(normalizeSignIn({ username: 'a', password: 'b' }).method, 'form');
|
|
32
|
+
assert.equal(normalizeSignIn({ cookies: 'a=1' }).method, 'token');
|
|
33
|
+
assert.equal(normalizeSignIn({ url: 'http://x/sso' }).method, 'link');
|
|
34
|
+
assert.equal(normalizeSignIn({ method: 'manual' }).method, 'manual');
|
|
35
|
+
assert.equal(normalizeSignIn({ method: 'form' }), null); // nothing to type
|
|
36
|
+
assert.equal(normalizeSignIn({ method: 'http', password: 'b' }), null); // a prompt needs a username
|
|
37
|
+
assert.equal(normalizeSignIn({ method: 'token' }), null);
|
|
38
|
+
assert.equal(normalizeSignIn({ method: 'nope', username: 'a' }).method, 'form');
|
|
39
|
+
// a password is used as given; other values are trimmed
|
|
40
|
+
assert.equal(normalizeSignIn({ username: ' a ', password: ' b ' }).password, ' b ');
|
|
41
|
+
assert.equal(normalizeSignIn({ username: ' a ', password: ' b ' }).username, 'a');
|
|
42
|
+
assert.equal(normalizeSignIn({ method: 'manual', waitMs: 1000 }).waitMs, 1000);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('resolveSignIn takes flags, then the config file, then the environment; secrets only from the environment', () => {
|
|
46
|
+
const env = {
|
|
47
|
+
SPECTRA_LOGIN_PASSWORD: 'pw',
|
|
48
|
+
SPECTRA_LOGIN_TOTP_SECRET: 'JBSW Y3DP',
|
|
49
|
+
SPECTRA_LOGIN_USER: 'env-user',
|
|
50
|
+
};
|
|
51
|
+
const s = resolveSignIn({ loginUser: 'flag-user' }, { url: 'http://x/login', username: 'config-user' }, env);
|
|
52
|
+
assert.equal(s.method, 'form');
|
|
53
|
+
assert.equal(s.username, 'flag-user');
|
|
54
|
+
assert.equal(s.password, 'pw');
|
|
55
|
+
assert.equal(s.totpSecret, 'JBSWY3DP');
|
|
56
|
+
assert.equal(s.url, 'http://x/login');
|
|
57
|
+
assert.equal(resolveSignIn({}, { username: 'config-user' }, env).username, 'config-user');
|
|
58
|
+
assert.equal(resolveSignIn({}, {}, env).username, 'env-user');
|
|
59
|
+
// a header from the environment, scoped by the recorder
|
|
60
|
+
const t = resolveSignIn({}, {}, { SPECTRA_LOGIN_HEADER: 'Authorization: Bearer abc:def' });
|
|
61
|
+
assert.equal(t.method, 'token');
|
|
62
|
+
assert.equal(t.headerName, 'Authorization');
|
|
63
|
+
assert.equal(t.headerValue, 'Bearer abc:def');
|
|
64
|
+
// nothing given: no sign-in
|
|
65
|
+
assert.equal(resolveSignIn({}, {}, {}), null);
|
|
66
|
+
// the old --sign-in template still works
|
|
67
|
+
assert.equal(resolveSignIn({ signIn: 'http://x/login/?r={url}' }, {}, {}).method, 'link');
|
|
68
|
+
// the CLI's option names
|
|
69
|
+
const opts = { login: 'http', 'login-user': 'u', 'login-wait': '30' };
|
|
70
|
+
const fromCli = signInFromOptions(name => opts[name], {}, { SPECTRA_LOGIN_PASSWORD: 'pw' });
|
|
71
|
+
assert.equal(fromCli.method, 'http');
|
|
72
|
+
assert.equal(fromCli.username, 'u');
|
|
73
|
+
assert.equal(fromCli.waitMs, 30_000);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('validateSignInConfig keeps secrets out of spectra.config.json', () => {
|
|
77
|
+
assert.equal(validateSignInConfig(undefined), null);
|
|
78
|
+
assert.deepEqual(validateSignInConfig({ method: 'form', url: 'http://x/login', username: 'me' }), {
|
|
79
|
+
method: 'form',
|
|
80
|
+
url: 'http://x/login',
|
|
81
|
+
username: 'me',
|
|
82
|
+
});
|
|
83
|
+
assert.throws(() => validateSignInConfig({ password: 'x' }), /SPECTRA_LOGIN_PASSWORD/);
|
|
84
|
+
assert.throws(() => validateSignInConfig({ totpSecret: 'x' }), /SPECTRA_LOGIN_TOTP_SECRET/);
|
|
85
|
+
assert.throws(() => validateSignInConfig({ cookies: 'a=1' }), /SPECTRA_LOGIN_COOKIES/);
|
|
86
|
+
assert.throws(() => validateSignInConfig({ headerValue: 'x' }), /SPECTRA_LOGIN_HEADER/);
|
|
87
|
+
assert.throws(() => validateSignInConfig({ method: 'sso' }), /must be one of/);
|
|
88
|
+
assert.throws(() => validateSignInConfig({ user: 'x' }), /unknown keys: user/);
|
|
89
|
+
assert.throws(() => validateSignInConfig('http://x'), /must be an object/);
|
|
90
|
+
assert.throws(() => validateSignInConfig({ waitSeconds: -1 }), /positive/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const chrome = findChrome();
|
|
94
|
+
describe('signing in, in Chrome', { skip: !chrome && 'Chrome was not found (set CHROME_PATH)', concurrency: false }, () => {
|
|
95
|
+
let demo;
|
|
96
|
+
const creds = { username: DEMO.username, password: DEMO.password };
|
|
97
|
+
|
|
98
|
+
before(async () => {
|
|
99
|
+
demo = await startLoginDemo();
|
|
100
|
+
});
|
|
101
|
+
after(async () => {
|
|
102
|
+
await demo?.close();
|
|
103
|
+
fs.rmSync(profile, { recursive: true, force: true });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// a fresh Chrome per test, signed out: what one test stored is cleared after it
|
|
107
|
+
async function signIn(pagePath, how) {
|
|
108
|
+
const session = new PerfSession();
|
|
109
|
+
try {
|
|
110
|
+
await session.connect({ mode: 'launch', url: `${demo.url}${pagePath}`, headless: true, signIn: how });
|
|
111
|
+
const welcome = await session.page.$eval('#welcome', el => el.textContent).catch(() => null);
|
|
112
|
+
return { welcome, url: session.page.url(), status: await session.status() };
|
|
113
|
+
} finally {
|
|
114
|
+
await session.client?.send('Storage.clearDataForOrigin', { origin: demo.url, storageTypes: 'all' }).catch(() => {});
|
|
115
|
+
await session.client?.send('Network.clearBrowserCookies').catch(() => {});
|
|
116
|
+
await session.disconnect();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const signedIn = r => {
|
|
120
|
+
assert.equal(r.welcome, 'Welcome, signed in', `still not signed in, on ${r.url}`);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
test('a classic login form, found where the app redirects to', async () => {
|
|
124
|
+
const r = await signIn('/app', creds);
|
|
125
|
+
signedIn(r);
|
|
126
|
+
assert.deepEqual(r.status.signIn, { method: 'form', signedIn: true });
|
|
127
|
+
assert.equal(JSON.stringify(r.status).includes(DEMO.password), false, 'the status never carries the password');
|
|
128
|
+
});
|
|
129
|
+
test('two steps: the username, Next, then the password', async () => signedIn(await signIn('/app', { ...creds, url: `${demo.url}/two-step` })));
|
|
130
|
+
test('a single-page app whose form renders late', async () => signedIn(await signIn('/app', { ...creds, url: `${demo.url}/spa` })));
|
|
131
|
+
test('a login form inside an iframe', async () => signedIn(await signIn('/app', { ...creds, url: `${demo.url}/iframe` })));
|
|
132
|
+
test("a login form inside a web component's shadow DOM", async () => signedIn(await signIn('/app', { ...creds, url: `${demo.url}/shadow` })));
|
|
133
|
+
test('a login behind a "Sign in" button that opens a modal', async () => signedIn(await signIn('/app', { ...creds, url: `${demo.url}/modal` })));
|
|
134
|
+
test('a password, then an authenticator code', async () =>
|
|
135
|
+
signedIn(await signIn('/app', { ...creds, totpSecret: DEMO.totpSecret, url: `${demo.url}/otp` })));
|
|
136
|
+
test('a password, then six one-digit code boxes', async () =>
|
|
137
|
+
signedIn(await signIn('/app', { ...creds, totpSecret: DEMO.totpSecret, url: `${demo.url}/otp-boxes` })));
|
|
138
|
+
test('an authenticator code without the secret says what is missing', async () =>
|
|
139
|
+
assert.rejects(signIn('/app', { ...creds, url: `${demo.url}/otp` }), /one-time code.*TOTP/));
|
|
140
|
+
test('the browser login prompt (HTTP Basic)', async () => signedIn(await signIn('/basic', { method: 'http', ...creds })));
|
|
141
|
+
test('a token header, sent to the app', async () =>
|
|
142
|
+
signedIn(await signIn('/header-app', { method: 'token', headerName: 'X-Demo-Token', headerValue: DEMO.token })));
|
|
143
|
+
test('a cookie', async () => signedIn(await signIn('/cookie-app', { method: 'token', cookies: `demo_token=${DEMO.token}` })));
|
|
144
|
+
test('a localStorage entry, there before the app loads', async () =>
|
|
145
|
+
signedIn(await signIn('/storage-app', { method: 'token', storage: `demo_token=${DEMO.token}` })));
|
|
146
|
+
test('a sign-in link', async () => signedIn(await signIn('/app', `${demo.url}/magic?next={url}`)));
|
|
147
|
+
test('a wrong password fails fast, and the error never contains it', async () => {
|
|
148
|
+
const started = Date.now();
|
|
149
|
+
await assert.rejects(signIn('/app', { username: DEMO.username, password: 'not-the-password' }), err => {
|
|
150
|
+
assert.match(err.message, /did not accept/);
|
|
151
|
+
assert.equal(err.message.includes('not-the-password'), false);
|
|
152
|
+
return true;
|
|
153
|
+
});
|
|
154
|
+
assert.ok(Date.now() - started < 20_000, 'no need to wait for the timeout');
|
|
155
|
+
});
|
|
156
|
+
test('a CAPTCHA is refused before anything is sent', async () =>
|
|
157
|
+
assert.rejects(signIn('/app', { ...creds, url: `${demo.url}/captcha` }), /CAPTCHA.*Sign in myself/));
|
|
158
|
+
test('signing in yourself needs a visible window', async () =>
|
|
159
|
+
assert.rejects(signIn('/app', { method: 'manual' }), /visible Chrome window/));
|
|
160
|
+
|
|
161
|
+
test('waiting for a person to sign in', async () => {
|
|
162
|
+
const browser = await puppeteer.launch({ executablePath: chrome, headless: true, userDataDir: fs.mkdtempSync(path.join(profile, 'm-')) });
|
|
163
|
+
try {
|
|
164
|
+
const page = await browser.newPage();
|
|
165
|
+
await page.goto(`${demo.url}/login?next=/app`);
|
|
166
|
+
const waiting = waitForManualSignIn(page, { timeoutMs: 20_000 });
|
|
167
|
+
// the "person": types the details in a moment
|
|
168
|
+
setTimeout(async () => {
|
|
169
|
+
await page.type('input[type=email]', DEMO.username);
|
|
170
|
+
await page.type('input[type=password]', DEMO.password);
|
|
171
|
+
await page.click('button[type=submit]');
|
|
172
|
+
}, 1500);
|
|
173
|
+
await waiting;
|
|
174
|
+
assert.equal(await page.$eval('#welcome', el => el.textContent), 'Welcome, signed in');
|
|
175
|
+
} finally {
|
|
176
|
+
await browser.close();
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
});
|
package/shared/compare.js
CHANGED
|
@@ -301,7 +301,7 @@ export const METRICS = [
|
|
|
301
301
|
},
|
|
302
302
|
{
|
|
303
303
|
id: 'renders',
|
|
304
|
-
group: '
|
|
304
|
+
group: 'Components',
|
|
305
305
|
label: 'Component renders',
|
|
306
306
|
unit: 'count',
|
|
307
307
|
get: r => reactSum(r, 'renders'),
|
|
@@ -310,7 +310,7 @@ export const METRICS = [
|
|
|
310
310
|
},
|
|
311
311
|
{
|
|
312
312
|
id: 'wasted',
|
|
313
|
-
group: '
|
|
313
|
+
group: 'Components',
|
|
314
314
|
label: 'Wasted renders',
|
|
315
315
|
unit: 'count',
|
|
316
316
|
get: r => reactSum(r, 'wasted'),
|
|
@@ -320,8 +320,8 @@ export const METRICS = [
|
|
|
320
320
|
},
|
|
321
321
|
{
|
|
322
322
|
id: 'renderMs',
|
|
323
|
-
group: '
|
|
324
|
-
label: '
|
|
323
|
+
group: 'Components',
|
|
324
|
+
label: 'Component render time',
|
|
325
325
|
unit: 'ms',
|
|
326
326
|
get: r => reactOnly(r, x => x.commits.totalMs),
|
|
327
327
|
minAbs: 25,
|
|
@@ -329,8 +329,8 @@ export const METRICS = [
|
|
|
329
329
|
},
|
|
330
330
|
{
|
|
331
331
|
id: 'maxCommit',
|
|
332
|
-
group: '
|
|
333
|
-
label: 'Slowest
|
|
332
|
+
group: 'Components',
|
|
333
|
+
label: 'Slowest component update',
|
|
334
334
|
unit: 'ms',
|
|
335
335
|
get: r => reactOnly(r, x => x.commits.maxMs),
|
|
336
336
|
minAbs: 10,
|
|
@@ -509,7 +509,7 @@ const SUBS = {
|
|
|
509
509
|
},
|
|
510
510
|
{
|
|
511
511
|
id: 'reactMs',
|
|
512
|
-
label: '
|
|
512
|
+
label: 'Component render time',
|
|
513
513
|
unit: 'ms',
|
|
514
514
|
minAbs: 5,
|
|
515
515
|
minRel: 0.2,
|
|
@@ -785,8 +785,8 @@ function comparability(before, after) {
|
|
|
785
785
|
add(
|
|
786
786
|
'warn',
|
|
787
787
|
'react',
|
|
788
|
-
'
|
|
789
|
-
`${noReact.length} run(s) had no
|
|
788
|
+
'Component timings missing in some runs',
|
|
789
|
+
`${noReact.length} run(s) had no component data (a production build, or the probe loaded after the framework). Component metrics are skipped or partial.`,
|
|
790
790
|
);
|
|
791
791
|
|
|
792
792
|
const attributions = unique(all.map(r => r.meta.attribution));
|
package/shared/snapshot.js
CHANGED
|
@@ -11,7 +11,7 @@ export function validateReport(obj) {
|
|
|
11
11
|
return {
|
|
12
12
|
ok: false,
|
|
13
13
|
reason:
|
|
14
|
-
'This is a raw Chrome trace (.trace.json). Comparison needs the profiler report: use “Snapshot for comparison” on the report instead — the trace lacks the
|
|
14
|
+
'This is a raw Chrome trace (.trace.json). Comparison needs the profiler report: use “Snapshot for comparison” on the report instead — the trace lacks the component and source-file data.',
|
|
15
15
|
};
|
|
16
16
|
}
|
|
17
17
|
const ok =
|
package/src/App.jsx
CHANGED
|
@@ -101,7 +101,7 @@ function Welcome({ onCompare }) {
|
|
|
101
101
|
<div>
|
|
102
102
|
<h2>Find what makes the frontend slow</h2>
|
|
103
103
|
<p className="welcome-lead">
|
|
104
|
-
Ranked culprits, heat trees,
|
|
104
|
+
Ranked culprits, heat trees, component re-renders, long tasks and network
|
|
105
105
|
findings from a single recording.
|
|
106
106
|
</p>
|
|
107
107
|
</div>
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
+
import { frameworkName } from '../framework.js';
|
|
1
2
|
import { useEffect, useState } from 'react';
|
|
2
3
|
import { api } from '../api.js';
|
|
3
4
|
import { ChevronIcon, PageLoadIcon, RecordIcon, StopIcon } from './Icons.jsx';
|
|
4
5
|
import Popover from './Popover.jsx';
|
|
6
|
+
import SignInPanel, { methodTitle, missing, toSignIn, useSignIn } from './SignIn.jsx';
|
|
5
7
|
|
|
6
8
|
const URL_KEY = 'perf-profiler:url';
|
|
7
|
-
const SIGNIN_KEY = 'perf-profiler:sign-in';
|
|
8
|
-
const readSignIn = () => localStorage.getItem(SIGNIN_KEY) || '';
|
|
9
9
|
|
|
10
10
|
function Elapsed({ startedAt }) {
|
|
11
11
|
const [now, setNow] = useState(Date.now());
|
|
@@ -21,31 +21,26 @@ function Elapsed({ startedAt }) {
|
|
|
21
21
|
|
|
22
22
|
function ProbePill({ probe }) {
|
|
23
23
|
if (!probe) return null;
|
|
24
|
-
|
|
24
|
+
// the probe names the framework it hooked: 'react', 'vue', 'angular', …
|
|
25
|
+
const name = frameworkName(probe.framework || (probe.react ? 'react' : ''));
|
|
26
|
+
if (probe.react || probe.detected) {
|
|
25
27
|
return (
|
|
26
28
|
<span
|
|
27
29
|
className="pill ok"
|
|
28
|
-
title=
|
|
30
|
+
title={`${name || 'A component framework'} was detected; per-component render data will be recorded.`}
|
|
29
31
|
>
|
|
30
|
-
✓
|
|
31
|
-
{probe.timings ? '
|
|
32
|
-
</span>
|
|
33
|
-
);
|
|
34
|
-
}
|
|
35
|
-
// If a non-React framework was detected, show that instead of the reload hint
|
|
36
|
-
if (probe.framework && probe.framework.name) {
|
|
37
|
-
return (
|
|
38
|
-
<span className="pill ok" title={`Detected ${probe.framework.name}`}>
|
|
39
|
-
✓ {probe.framework.name} {probe.framework.version || ''}
|
|
32
|
+
✓ {name || 'Components'} {probe.version}
|
|
33
|
+
{probe.react && !probe.timings ? ' · no timings (production build)' : ''}
|
|
40
34
|
</span>
|
|
41
35
|
);
|
|
42
36
|
}
|
|
37
|
+
// not a problem: any app is profiled down to its files and functions
|
|
43
38
|
return (
|
|
44
39
|
<span
|
|
45
|
-
className="pill
|
|
46
|
-
title="
|
|
40
|
+
className="pill"
|
|
41
|
+
title="Files and functions are profiled for any app. If this one uses React, Vue or Angular, the probe must load before the framework does: use Reload, or “Record page load”."
|
|
47
42
|
>
|
|
48
|
-
|
|
43
|
+
No component framework seen
|
|
49
44
|
</span>
|
|
50
45
|
);
|
|
51
46
|
}
|
|
@@ -57,13 +52,15 @@ function Connect({ status, run, isCompact }) {
|
|
|
57
52
|
const [headless, setHeadless] = useState(false);
|
|
58
53
|
const [browserURL, setBrowserURL] = useState('http://127.0.0.1:9222');
|
|
59
54
|
const [busy, setBusy] = useState(false);
|
|
60
|
-
const [signIn,
|
|
55
|
+
const [signIn, updateSignIn] = useSignIn();
|
|
61
56
|
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
|
|
57
|
+
const gap = missing(signIn);
|
|
58
|
+
// signing in yourself needs the window
|
|
59
|
+
const manual = signIn.enabled && signIn.method === 'manual';
|
|
62
60
|
const go = async options => {
|
|
63
61
|
if (options.url) localStorage.setItem(URL_KEY, options.url);
|
|
64
|
-
localStorage.setItem(SIGNIN_KEY, signIn);
|
|
65
62
|
setBusy(true);
|
|
66
|
-
await run(() => api.connect(options));
|
|
63
|
+
await run(() => api.connect({ ...options, signIn: toSignIn(signIn) }));
|
|
67
64
|
setBusy(false);
|
|
68
65
|
};
|
|
69
66
|
return (
|
|
@@ -72,8 +69,8 @@ function Connect({ status, run, isCompact }) {
|
|
|
72
69
|
<div className="connect-head">
|
|
73
70
|
<h2>Connect to a browser</h2>
|
|
74
71
|
<p className="muted">
|
|
75
|
-
|
|
76
|
-
|
|
72
|
+
Spectra opens a fresh Chrome window on the page you want to profile,
|
|
73
|
+
signing in first when your app needs it.
|
|
77
74
|
</p>
|
|
78
75
|
</div>
|
|
79
76
|
)}
|
|
@@ -90,10 +87,11 @@ function Connect({ status, run, isCompact }) {
|
|
|
90
87
|
<button
|
|
91
88
|
type="button"
|
|
92
89
|
className="primary"
|
|
93
|
-
disabled={busy}
|
|
94
|
-
|
|
90
|
+
disabled={busy || Boolean(gap)}
|
|
91
|
+
title={gap || undefined}
|
|
92
|
+
onClick={() => go({ mode: 'launch', url, headless: headless && !manual })}
|
|
95
93
|
>
|
|
96
|
-
{busy ? 'Starting Chrome…' : 'Launch Chrome'}
|
|
94
|
+
{busy ? (signIn.enabled ? 'Opening and signing in…' : 'Starting Chrome…') : 'Launch Chrome'}
|
|
97
95
|
</button>
|
|
98
96
|
<button
|
|
99
97
|
type="button"
|
|
@@ -104,35 +102,23 @@ function Connect({ status, run, isCompact }) {
|
|
|
104
102
|
Advanced <ChevronIcon isUp={isAdvancedOpen} />
|
|
105
103
|
</button>
|
|
106
104
|
</div>
|
|
105
|
+
<SignInPanel value={signIn} update={updateSignIn} />
|
|
107
106
|
{isAdvancedOpen && (
|
|
108
107
|
<div className="advanced">
|
|
109
108
|
<div className="advanced-section">
|
|
110
|
-
<
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
</
|
|
122
|
-
|
|
123
|
-
<input
|
|
124
|
-
type="checkbox"
|
|
125
|
-
role="switch"
|
|
126
|
-
checked={headless}
|
|
127
|
-
onChange={e => setHeadless(e.target.checked)}
|
|
128
|
-
/>
|
|
129
|
-
Headless
|
|
130
|
-
</label>
|
|
131
|
-
</div>
|
|
132
|
-
<span className="muted">
|
|
133
|
-
The Chrome window starts signed out every time, so give it your
|
|
134
|
-
app’s login URL once; {'{url}'} is where the page to profile goes.
|
|
135
|
-
</span>
|
|
109
|
+
<label
|
|
110
|
+
className="switch"
|
|
111
|
+
title={manual ? 'Signing in yourself needs the Chrome window.' : undefined}
|
|
112
|
+
>
|
|
113
|
+
<input
|
|
114
|
+
type="checkbox"
|
|
115
|
+
role="switch"
|
|
116
|
+
checked={headless && !manual}
|
|
117
|
+
disabled={manual}
|
|
118
|
+
onChange={e => setHeadless(e.target.checked)}
|
|
119
|
+
/>
|
|
120
|
+
Headless <span className="muted">(no Chrome window)</span>
|
|
121
|
+
</label>
|
|
136
122
|
</div>
|
|
137
123
|
<div className="advanced-section">
|
|
138
124
|
<div className="advanced-row">
|
|
@@ -145,14 +131,15 @@ function Connect({ status, run, isCompact }) {
|
|
|
145
131
|
</label>
|
|
146
132
|
<button
|
|
147
133
|
type="button"
|
|
148
|
-
disabled={busy}
|
|
134
|
+
disabled={busy || Boolean(gap)}
|
|
149
135
|
onClick={() => go({ mode: 'attach', browserURL })}
|
|
150
136
|
>
|
|
151
137
|
Attach
|
|
152
138
|
</button>
|
|
153
139
|
</div>
|
|
154
140
|
<span className="muted">
|
|
155
|
-
Start it with <code>--remote-debugging-port=9222</code
|
|
141
|
+
Start it with <code>--remote-debugging-port=9222</code>. A Chrome
|
|
142
|
+
you already use is usually signed in already.
|
|
156
143
|
</span>
|
|
157
144
|
</div>
|
|
158
145
|
</div>
|
|
@@ -161,6 +148,27 @@ function Connect({ status, run, isCompact }) {
|
|
|
161
148
|
);
|
|
162
149
|
}
|
|
163
150
|
|
|
151
|
+
function SignInProgress({ method, signingIn }) {
|
|
152
|
+
return (
|
|
153
|
+
<div className="rec-strip">
|
|
154
|
+
<span className="rec">
|
|
155
|
+
<i className="spin" />{' '}
|
|
156
|
+
{!signingIn
|
|
157
|
+
? 'Opening the page…'
|
|
158
|
+
: method === 'manual'
|
|
159
|
+
? 'Waiting for you to sign in'
|
|
160
|
+
: `Signing in (${methodTitle(method).toLowerCase()})…`}
|
|
161
|
+
</span>
|
|
162
|
+
{signingIn && method === 'manual' && (
|
|
163
|
+
<span className="muted">
|
|
164
|
+
Sign in in the Chrome window, any way your app asks: codes, passkeys,
|
|
165
|
+
CAPTCHAs. Spectra carries on by itself once you are in.
|
|
166
|
+
</span>
|
|
167
|
+
)}
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
164
172
|
function RecordingOptions({ throttle, setThrottle, saveTrace, setSaveTrace }) {
|
|
165
173
|
return (
|
|
166
174
|
<Popover label="Options" align="left">
|
|
@@ -274,15 +282,18 @@ export default function ControlBar({ status, run, isCompact }) {
|
|
|
274
282
|
? status.navigation
|
|
275
283
|
: null;
|
|
276
284
|
const idle = status.state === 'idle';
|
|
285
|
+
const signingIn = status.state === 'signing-in';
|
|
286
|
+
const opening = status.state === 'connecting';
|
|
277
287
|
return (
|
|
278
288
|
<section className="session" aria-label="Browser session">
|
|
279
289
|
{failed && (
|
|
280
290
|
<div className="error" role="alert">
|
|
281
291
|
<span>
|
|
282
292
|
The page answered <b>HTTP {failed.status}</b> ({failed.url}). You
|
|
283
|
-
are probably not signed in.
|
|
284
|
-
|
|
285
|
-
|
|
293
|
+
are probably not signed in.{' '}
|
|
294
|
+
{status.signIn
|
|
295
|
+
? 'Press Go to sign in again, or sign in by hand in the Chrome window.'
|
|
296
|
+
: 'Sign in by hand in the Chrome window, or close Chrome and launch again with “My app needs a sign-in” turned on.'}
|
|
286
297
|
</span>
|
|
287
298
|
</div>
|
|
288
299
|
)}
|
|
@@ -306,20 +317,28 @@ export default function ControlBar({ status, run, isCompact }) {
|
|
|
306
317
|
disabled={!idle}
|
|
307
318
|
onChange={e => setAddress(e.target.value)}
|
|
308
319
|
onKeyDown={e =>
|
|
309
|
-
e.key === 'Enter' && run(() => api.navigate(address
|
|
320
|
+
e.key === 'Enter' && run(() => api.navigate(address))
|
|
310
321
|
}
|
|
311
322
|
/>
|
|
312
323
|
<button
|
|
313
324
|
type="button"
|
|
314
325
|
disabled={!idle}
|
|
315
|
-
onClick={() => run(() => api.navigate(address
|
|
326
|
+
onClick={() => run(() => api.navigate(address))}
|
|
316
327
|
>
|
|
317
328
|
Go
|
|
318
329
|
</button>
|
|
319
330
|
<button type="button" disabled={!idle} onClick={() => run(api.reload)}>
|
|
320
331
|
Reload
|
|
321
332
|
</button>
|
|
322
|
-
|
|
333
|
+
{status.signIn?.signedIn && (
|
|
334
|
+
<span
|
|
335
|
+
className="pill ok"
|
|
336
|
+
title={`Signed in with: ${methodTitle(status.signIn.method)}. Spectra signs in again if the session ends.`}
|
|
337
|
+
>
|
|
338
|
+
✓ Signed in
|
|
339
|
+
</span>
|
|
340
|
+
)}
|
|
341
|
+
<ProbePill probe={status.probe} />
|
|
323
342
|
<button
|
|
324
343
|
type="button"
|
|
325
344
|
className="ghost destructive"
|
|
@@ -329,7 +348,11 @@ export default function ControlBar({ status, run, isCompact }) {
|
|
|
329
348
|
</button>
|
|
330
349
|
</div>
|
|
331
350
|
<div className="session-row">
|
|
332
|
-
|
|
351
|
+
{signingIn || opening ? (
|
|
352
|
+
<SignInProgress method={status.signIn?.method} signingIn={signingIn} />
|
|
353
|
+
) : (
|
|
354
|
+
<RecordRow status={status} run={run} />
|
|
355
|
+
)}
|
|
333
356
|
</div>
|
|
334
357
|
</section>
|
|
335
358
|
);
|
|
@@ -150,7 +150,7 @@ export default function EvaluationPanel({ evaluations, onOpenReport }) {
|
|
|
150
150
|
</div>
|
|
151
151
|
<p className="muted eval-foot">
|
|
152
152
|
Automation: {passed}/{detail.paths.length} golden paths passed. Open any
|
|
153
|
-
row for its full report — culprits,
|
|
153
|
+
row for its full report — culprits, component re-renders, long tasks, PDF
|
|
154
154
|
export and commit compare.
|
|
155
155
|
</p>
|
|
156
156
|
</>
|
|
@@ -101,7 +101,7 @@ export default function FilePanel({ report, path, onClose }) {
|
|
|
101
101
|
value={fmtMs(file.blockMs)}
|
|
102
102
|
warn={file.blockMs > 0}
|
|
103
103
|
/>
|
|
104
|
-
<Metric label="
|
|
104
|
+
<Metric label="Component renders" value={fmtNumber(file.renders)} />
|
|
105
105
|
<Metric
|
|
106
106
|
label="Wasted"
|
|
107
107
|
value={fmtNumber(file.wasted)}
|
|
@@ -184,7 +184,7 @@ export default function FilePanel({ report, path, onClose }) {
|
|
|
184
184
|
{tab === 'components' &&
|
|
185
185
|
((file?.components.length || 0) === 0 ? (
|
|
186
186
|
<p className="muted pad">
|
|
187
|
-
No
|
|
187
|
+
No components from this file rendered.
|
|
188
188
|
</p>
|
|
189
189
|
) : (
|
|
190
190
|
<table className="data">
|