@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.
@@ -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: 'React',
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: 'React',
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: 'React',
324
- label: 'React render time',
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: 'React',
333
- label: 'Slowest React commit',
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: 'React render time',
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
- 'React timings missing in some runs',
789
- `${noReact.length} run(s) had no React data (production build, or the probe loaded after React). React metrics are skipped or partial.`,
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));
@@ -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 React and source-file data.',
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, React re-renders, long tasks and network
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>
@@ -399,7 +399,7 @@ function Findings({ findings }) {
399
399
  }
400
400
 
401
401
  const DETAIL_TABS = [
402
- ['components', 'React components'],
402
+ ['components', 'Components'],
403
403
  ['files', 'Files'],
404
404
  ['functions', 'Functions'],
405
405
  ['findings', 'Problems'],
@@ -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
- if (probe.react) {
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="React was detected; per-component render data will be recorded."
30
+ title={`${name || 'A component framework'} was detected; per-component render data will be recorded.`}
29
31
  >
30
- ✓ React {probe.version}
31
- {probe.timings ? '' : ' · no timings (production build)'}
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 warn"
46
- title="The probe must load before React. Use Reload, or “Record page load”."
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
- ! No React seen — reload
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, setSignIn] = useState(readSignIn);
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
- Launch a fresh Chrome window, sign in, and open the page you want to
76
- profile.
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
- onClick={() => go({ mode: 'launch', url, headless, signIn })}
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
- <div className="advanced-row">
111
- <label
112
- className="field"
113
- title="Used automatically when the page fails to load or lands on a login screen. {url} is replaced with the page above. Do not put tokens in it."
114
- >
115
- Sign-in URL <span className="muted">(optional)</span>
116
- <input
117
- value={signIn}
118
- onChange={e => setSignIn(e.target.value)}
119
- placeholder="http://localhost:3000/login/?redirect={url}"
120
- />
121
- </label>
122
- <label className="switch">
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. Press Go to retry (the saved Sign-in URL
284
- is applied automatically), or sign in by hand in the Chrome window.
285
- If no Sign-in URL was saved, close Chrome and launch again with one.
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, readSignIn()))
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, readSignIn()))}
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
- <ProbePill probe={status.probe} />
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
- <RecordRow status={status} run={run} />
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, React re-renders, long tasks, PDF
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="React renders" value={fmtNumber(file.renders)} />
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 React components from this file rendered.
187
+ No components from this file rendered.
188
188
  </p>
189
189
  ) : (
190
190
  <table className="data">