@progen-labs/spectra 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +100 -2
- package/bin/spectra.js +215 -0
- package/dist/assets/index-D0Lxrmdt.js +46 -0
- package/dist/assets/index-Da3Le5Px.css +1 -0
- package/dist/index.html +14 -0
- package/index.html +13 -0
- package/package.json +76 -4
- package/server/analysis/cpu.js +369 -0
- package/server/analysis/findings.js +570 -0
- package/server/analysis/locate.js +99 -0
- package/server/analysis/report.js +775 -0
- package/server/analysis/sources.js +249 -0
- package/server/analysis/trace.js +314 -0
- package/server/api.js +292 -0
- package/server/chrome.js +81 -0
- package/server/cli.js +94 -0
- package/server/config.js +59 -0
- package/server/dashboard.js +154 -0
- package/server/explore/boot.js +150 -0
- package/server/explore/classify.js +169 -0
- package/server/explore/discover.js +162 -0
- package/server/explore/evaluate-cli.js +101 -0
- package/server/explore/evaluate.js +165 -0
- package/server/explore/paths.js +105 -0
- package/server/explore/runner.js +94 -0
- package/server/explore/test-explore.js +139 -0
- package/server/explore/test-paths.js +75 -0
- package/server/framework.js +67 -0
- package/server/git.js +59 -0
- package/server/pdf/render.js +60 -0
- package/server/pdf/template.js +419 -0
- package/server/plugin.js +19 -0
- package/server/probe.browser.js +828 -0
- package/server/recorder.js +610 -0
- package/server/signin.js +27 -0
- package/server/test-analysis.js +424 -0
- package/server/test-compare.js +473 -0
- package/server/test-sources.js +136 -0
- package/shared/compare.js +977 -0
- package/shared/snapshot.js +77 -0
- package/src/App.jsx +361 -0
- package/src/api.js +51 -0
- package/src/components/AppearanceMenu.jsx +76 -0
- package/src/components/CodeRef.jsx +48 -0
- package/src/components/ComparePanel.jsx +143 -0
- package/src/components/CompareResults.jsx +473 -0
- package/src/components/ControlBar.jsx +336 -0
- package/src/components/CulpritSphere.jsx +218 -0
- package/src/components/Culprits.jsx +120 -0
- package/src/components/CulpritsHero.jsx +109 -0
- package/src/components/EvaluationPanel.jsx +160 -0
- package/src/components/FilePanel.jsx +225 -0
- package/src/components/FindingsList.jsx +381 -0
- package/src/components/HeatTrees.jsx +222 -0
- package/src/components/Icons.jsx +58 -0
- package/src/components/LongTasks.jsx +199 -0
- package/src/components/NetworkPanel.jsx +98 -0
- package/src/components/PageMap.jsx +329 -0
- package/src/components/Popover.jsx +51 -0
- package/src/components/ReactPanel.jsx +206 -0
- package/src/components/SidePicker.jsx +207 -0
- package/src/components/SnapshotButton.jsx +140 -0
- package/src/components/SpectraCulprits.jsx +39 -0
- package/src/components/TimeBreakdown.jsx +45 -0
- package/src/components/TraceButton.jsx +76 -0
- package/src/components/TreeView.jsx +173 -0
- package/src/components/Vitals.jsx +163 -0
- package/src/culprits.js +127 -0
- package/src/download.js +18 -0
- package/src/format.js +26 -0
- package/src/heat.js +177 -0
- package/src/highlight.js +45 -0
- package/src/hooks/useCompare.js +187 -0
- package/src/hooks/useSession.js +73 -0
- package/src/layout.js +55 -0
- package/src/main.jsx +15 -0
- package/src/styles.css +2478 -0
- package/src/theme.js +75 -0
- package/src/themes.css +286 -0
- package/vite.config.js +8 -0
package/server/api.js
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Spectra backend API, framework-agnostic so it can be mounted by either
|
|
3
|
+
* the Vite dev plugin (server/plugin.js) or the standalone dashboard server
|
|
4
|
+
* (server/dashboard.js). `handle(req, res, next)` is Connect-style and expects
|
|
5
|
+
* `req.url` to be relative to the API root (the `/api` prefix already removed).
|
|
6
|
+
*
|
|
7
|
+
* GET /status connection, tabs, recording state, probe info
|
|
8
|
+
* POST /connect {mode:'launch'|'attach', url?, browserURL?, headless?}
|
|
9
|
+
* POST /disconnect POST /select-page {id}
|
|
10
|
+
* POST /navigate {url} POST /reload
|
|
11
|
+
* POST /record/start {mode, cpuThrottle?, saveTrace?, settleMs?}
|
|
12
|
+
* POST /record/stop
|
|
13
|
+
* GET /reports GET/DELETE /reports/:id
|
|
14
|
+
* POST /reports/:id/trace write the raw trace (GET downloads it)
|
|
15
|
+
* GET /reports/:id/page.jpg screenshot behind the report's page map
|
|
16
|
+
* GET /evaluations GET /evaluations/:id golden-path aggregates
|
|
17
|
+
* GET /source?path= a source file (read-only, inside known roots)
|
|
18
|
+
* GET /git/commit?rev= author/subject/date of HEAD or a commit hash
|
|
19
|
+
* POST /compare/pdf {comparison, kind} -> application/pdf
|
|
20
|
+
* GET /events Server-Sent Events: state + new reports
|
|
21
|
+
*/
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { COMPARE_VERSION } from '../shared/compare.js';
|
|
25
|
+
import {
|
|
26
|
+
DEFAULT_TARGET_URL,
|
|
27
|
+
FRONTEND_ROOT,
|
|
28
|
+
REPORTS_DIR,
|
|
29
|
+
SOURCE_ROOTS,
|
|
30
|
+
TARGET_NAME,
|
|
31
|
+
} from './config.js';
|
|
32
|
+
import { gitInfo, isValidRev } from './git.js';
|
|
33
|
+
import { pdfFileName, renderComparisonPdf } from './pdf/render.js';
|
|
34
|
+
import {
|
|
35
|
+
PerfSession,
|
|
36
|
+
deleteReport,
|
|
37
|
+
listReports,
|
|
38
|
+
pageImageFile,
|
|
39
|
+
readReport,
|
|
40
|
+
} from './recorder.js';
|
|
41
|
+
|
|
42
|
+
const MAX_SOURCE_BYTES = 1024 * 1024;
|
|
43
|
+
|
|
44
|
+
function send(res, status, body) {
|
|
45
|
+
res.statusCode = status;
|
|
46
|
+
res.setHeader('Content-Type', 'application/json');
|
|
47
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
48
|
+
res.end(JSON.stringify(body));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function readBody(req) {
|
|
52
|
+
const chunks = [];
|
|
53
|
+
for await (const c of req) chunks.push(c);
|
|
54
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
55
|
+
return text ? JSON.parse(text) : {};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readSource(rel) {
|
|
59
|
+
if (
|
|
60
|
+
!rel ||
|
|
61
|
+
rel.includes('\0') ||
|
|
62
|
+
path.isAbsolute(rel) ||
|
|
63
|
+
rel.split(/[\\/]/).includes('..')
|
|
64
|
+
)
|
|
65
|
+
return null;
|
|
66
|
+
for (const root of SOURCE_ROOTS) {
|
|
67
|
+
const abs = path.join(root, rel);
|
|
68
|
+
let real;
|
|
69
|
+
try {
|
|
70
|
+
real = fs.realpathSync(abs);
|
|
71
|
+
} catch {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (
|
|
75
|
+
!real.startsWith(fs.realpathSync(root) + path.sep) ||
|
|
76
|
+
!fs.statSync(real).isFile()
|
|
77
|
+
)
|
|
78
|
+
continue;
|
|
79
|
+
const size = fs.statSync(real).size;
|
|
80
|
+
const fd = fs.openSync(real, 'r');
|
|
81
|
+
try {
|
|
82
|
+
const buf = Buffer.alloc(Math.min(size, MAX_SOURCE_BYTES));
|
|
83
|
+
fs.readSync(fd, buf, 0, buf.length, 0);
|
|
84
|
+
return {
|
|
85
|
+
path: rel,
|
|
86
|
+
root,
|
|
87
|
+
absolutePath: real,
|
|
88
|
+
size,
|
|
89
|
+
truncated: size > MAX_SOURCE_BYTES,
|
|
90
|
+
content: buf.toString('utf8'),
|
|
91
|
+
};
|
|
92
|
+
} finally {
|
|
93
|
+
fs.closeSync(fd);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** List saved golden-path evaluation aggregates (newest first). */
|
|
100
|
+
function listEvaluations() {
|
|
101
|
+
let files = [];
|
|
102
|
+
try {
|
|
103
|
+
files = fs.readdirSync(REPORTS_DIR);
|
|
104
|
+
} catch {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
return files
|
|
108
|
+
.filter(f => f.startsWith('evaluation-') && f.endsWith('.json'))
|
|
109
|
+
.sort()
|
|
110
|
+
.reverse()
|
|
111
|
+
.map(f => {
|
|
112
|
+
try {
|
|
113
|
+
const data = JSON.parse(
|
|
114
|
+
fs.readFileSync(path.join(REPORTS_DIR, f), 'utf8'),
|
|
115
|
+
);
|
|
116
|
+
return {
|
|
117
|
+
id: data.id,
|
|
118
|
+
createdAt: data.createdAt,
|
|
119
|
+
target: data.target,
|
|
120
|
+
url: data.url,
|
|
121
|
+
pathCount: Array.isArray(data.paths) ? data.paths.length : 0,
|
|
122
|
+
passed: Array.isArray(data.paths)
|
|
123
|
+
? data.paths.filter(p => p.ok).length
|
|
124
|
+
: 0,
|
|
125
|
+
};
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
.filter(Boolean);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function readEvaluation(id) {
|
|
134
|
+
if (!/^evaluation-[\w-]+$/.test(id)) return null;
|
|
135
|
+
try {
|
|
136
|
+
return JSON.parse(
|
|
137
|
+
fs.readFileSync(path.join(REPORTS_DIR, `${id}.json`), 'utf8'),
|
|
138
|
+
);
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Routes that act on the session and then answer with its fresh status. */
|
|
145
|
+
const ACTIONS = {
|
|
146
|
+
'POST /connect': (session, body) => session.connect(body),
|
|
147
|
+
'POST /disconnect': session => session.disconnect(),
|
|
148
|
+
'POST /select-page': (session, body) => session.selectPage(body.id),
|
|
149
|
+
'POST /navigate': (session, body) => session.navigate(body.url, body.signIn),
|
|
150
|
+
'POST /reload': session => session.reload(),
|
|
151
|
+
'POST /record/start': (session, body) => session.start(body),
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Build a Spectra API instance: its own PerfSession, an SSE client set, a
|
|
156
|
+
* Connect-style `handle`, and a `dispose` for shutdown.
|
|
157
|
+
*/
|
|
158
|
+
export function createApi() {
|
|
159
|
+
const session = new PerfSession();
|
|
160
|
+
const clients = new Set();
|
|
161
|
+
const broadcast = (event, data) => {
|
|
162
|
+
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
163
|
+
for (const res of clients) res.write(payload);
|
|
164
|
+
};
|
|
165
|
+
session.on('state', s => broadcast('state', s));
|
|
166
|
+
session.on('report', r => broadcast('report', r));
|
|
167
|
+
|
|
168
|
+
async function handle(req, res, next) {
|
|
169
|
+
const url = new URL(req.url, 'http://localhost');
|
|
170
|
+
const route = `${req.method} ${url.pathname}`;
|
|
171
|
+
try {
|
|
172
|
+
if (route === 'GET /status')
|
|
173
|
+
return send(res, 200, {
|
|
174
|
+
...(await session.status()),
|
|
175
|
+
defaultUrl: DEFAULT_TARGET_URL,
|
|
176
|
+
defaultName: TARGET_NAME,
|
|
177
|
+
});
|
|
178
|
+
if (route === 'POST /record/stop') {
|
|
179
|
+
const report = await session.stop();
|
|
180
|
+
return send(res, 200, { id: report.id });
|
|
181
|
+
}
|
|
182
|
+
if (route in ACTIONS) {
|
|
183
|
+
await ACTIONS[route](session, await readBody(req));
|
|
184
|
+
return send(res, 200, await session.status());
|
|
185
|
+
}
|
|
186
|
+
if (route === 'GET /reports') return send(res, 200, listReports());
|
|
187
|
+
if (route === 'GET /evaluations')
|
|
188
|
+
return send(res, 200, listEvaluations());
|
|
189
|
+
const evalRoute = /^\/evaluations\/(evaluation-[\w-]+)$/.exec(url.pathname);
|
|
190
|
+
if (evalRoute) {
|
|
191
|
+
const data = readEvaluation(evalRoute[1]);
|
|
192
|
+
return data
|
|
193
|
+
? send(res, 200, data)
|
|
194
|
+
: send(res, 404, { error: 'No such evaluation' });
|
|
195
|
+
}
|
|
196
|
+
const traceRoute = /^\/reports\/([\w-]+)\/trace$/.exec(url.pathname);
|
|
197
|
+
if (traceRoute) {
|
|
198
|
+
const [, id] = traceRoute;
|
|
199
|
+
const { path: file, size } = session.writeTrace(id);
|
|
200
|
+
if (req.method === 'POST')
|
|
201
|
+
return send(res, 200, { ok: true, path: file, size });
|
|
202
|
+
res.writeHead(200, {
|
|
203
|
+
'Content-Type': 'application/json',
|
|
204
|
+
'Content-Length': size,
|
|
205
|
+
'Content-Disposition': `attachment; filename="${id}.trace.json"`,
|
|
206
|
+
});
|
|
207
|
+
return fs.createReadStream(file).pipe(res);
|
|
208
|
+
}
|
|
209
|
+
const pageRoute = /^\/reports\/([\w-]+)\/page\.jpg$/.exec(url.pathname);
|
|
210
|
+
if (pageRoute) {
|
|
211
|
+
const file = pageImageFile(pageRoute[1]);
|
|
212
|
+
if (!fs.existsSync(file))
|
|
213
|
+
return send(res, 404, { error: 'No page image for this report' });
|
|
214
|
+
res.writeHead(200, {
|
|
215
|
+
'Content-Type': 'image/jpeg',
|
|
216
|
+
'Content-Length': fs.statSync(file).size,
|
|
217
|
+
});
|
|
218
|
+
return fs.createReadStream(file).pipe(res);
|
|
219
|
+
}
|
|
220
|
+
if (url.pathname.startsWith('/reports/')) {
|
|
221
|
+
const id = decodeURIComponent(url.pathname.slice('/reports/'.length));
|
|
222
|
+
if (req.method === 'DELETE')
|
|
223
|
+
return send(res, 200, (deleteReport(id), { ok: true }));
|
|
224
|
+
const report = readReport(id);
|
|
225
|
+
return report
|
|
226
|
+
? send(res, 200, report)
|
|
227
|
+
: send(res, 404, { error: 'No such report' });
|
|
228
|
+
}
|
|
229
|
+
if (route === 'GET /git/commit') {
|
|
230
|
+
const rev = url.searchParams.get('rev') || 'HEAD';
|
|
231
|
+
const info = isValidRev(rev) ? gitInfo(FRONTEND_ROOT, rev) : null;
|
|
232
|
+
return info
|
|
233
|
+
? send(res, 200, info)
|
|
234
|
+
: send(res, 404, {
|
|
235
|
+
error: `No commit “${rev}” in this repository (use a commit hash).`,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
if (route === 'POST /compare/pdf') {
|
|
239
|
+
const { comparison, kind } = await readBody(req);
|
|
240
|
+
if (
|
|
241
|
+
comparison?.version !== COMPARE_VERSION ||
|
|
242
|
+
!Array.isArray(comparison.metrics)
|
|
243
|
+
) {
|
|
244
|
+
return send(res, 400, {
|
|
245
|
+
error: 'Not a comparison produced by this tool.',
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
const type = kind === 'detailed' ? 'detailed' : 'summary';
|
|
249
|
+
const pdf = await renderComparisonPdf(comparison, type);
|
|
250
|
+
res.writeHead(200, {
|
|
251
|
+
'Content-Type': 'application/pdf',
|
|
252
|
+
'Content-Length': pdf.length,
|
|
253
|
+
'Content-Disposition': `attachment; filename="${pdfFileName(comparison, type)}"`,
|
|
254
|
+
'Cache-Control': 'no-store',
|
|
255
|
+
});
|
|
256
|
+
return res.end(pdf);
|
|
257
|
+
}
|
|
258
|
+
if (route === 'GET /source') {
|
|
259
|
+
const file = readSource(url.searchParams.get('path'));
|
|
260
|
+
return file
|
|
261
|
+
? send(res, 200, file)
|
|
262
|
+
: send(res, 404, {
|
|
263
|
+
error: 'Source file not found in the known roots',
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
if (route === 'GET /events') {
|
|
267
|
+
res.writeHead(200, {
|
|
268
|
+
'Content-Type': 'text/event-stream',
|
|
269
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
270
|
+
Connection: 'keep-alive',
|
|
271
|
+
});
|
|
272
|
+
res.write(`event: hello\ndata: {}\n\n`);
|
|
273
|
+
clients.add(res);
|
|
274
|
+
const heartbeat = setInterval(() => res.write(': ping\n\n'), 25_000);
|
|
275
|
+
req.on('close', () => {
|
|
276
|
+
clearInterval(heartbeat);
|
|
277
|
+
clients.delete(res);
|
|
278
|
+
});
|
|
279
|
+
return undefined;
|
|
280
|
+
}
|
|
281
|
+
} catch (err) {
|
|
282
|
+
return send(res, 500, { error: err.message });
|
|
283
|
+
}
|
|
284
|
+
return next ? next() : send(res, 404, { error: 'Not found' });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function dispose() {
|
|
288
|
+
await session.disconnect().catch(() => {});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return { session, handle, dispose };
|
|
292
|
+
}
|
package/server/chrome.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import puppeteer from 'puppeteer-core';
|
|
5
|
+
import { CHROME_PROFILE_DIR } from './config.js';
|
|
6
|
+
|
|
7
|
+
const CANDIDATES = {
|
|
8
|
+
darwin: [
|
|
9
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
10
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
11
|
+
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
12
|
+
],
|
|
13
|
+
linux: [
|
|
14
|
+
'/usr/bin/google-chrome',
|
|
15
|
+
'/usr/bin/google-chrome-stable',
|
|
16
|
+
'/usr/bin/chromium',
|
|
17
|
+
'/usr/bin/chromium-browser',
|
|
18
|
+
],
|
|
19
|
+
win32: [
|
|
20
|
+
`${process.env.PROGRAMFILES}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
21
|
+
`${process.env['PROGRAMFILES(X86)']}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
22
|
+
`${process.env.LOCALAPPDATA}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
23
|
+
],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function findChrome() {
|
|
27
|
+
if (process.env.CHROME_PATH) return process.env.CHROME_PATH;
|
|
28
|
+
return (
|
|
29
|
+
(CANDIDATES[os.platform()] || []).find(p => p && fs.existsSync(p)) || null
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Starts a dedicated Chrome. Its profile lives in .chrome-profile/ so logins
|
|
35
|
+
* (session cookies) survive between sessions, and it is separate from
|
|
36
|
+
* your everyday browser profile.
|
|
37
|
+
*/
|
|
38
|
+
export async function launchChrome({ headless = false } = {}) {
|
|
39
|
+
const executablePath = findChrome();
|
|
40
|
+
if (!executablePath) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
'Chrome was not found. Set CHROME_PATH to its executable, or start Chrome with --remote-debugging-port=9222 and use “Attach”.',
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
fs.mkdirSync(CHROME_PROFILE_DIR, { recursive: true });
|
|
46
|
+
const options = {
|
|
47
|
+
executablePath,
|
|
48
|
+
headless: headless ? 'new' : false,
|
|
49
|
+
userDataDir: CHROME_PROFILE_DIR,
|
|
50
|
+
defaultViewport: null,
|
|
51
|
+
ignoreDefaultArgs: ['--enable-automation'],
|
|
52
|
+
args: [
|
|
53
|
+
'--no-first-run',
|
|
54
|
+
'--no-default-browser-check',
|
|
55
|
+
'--window-size=1600,1000',
|
|
56
|
+
'--enable-precise-memory-info',
|
|
57
|
+
'--disable-background-timer-throttling',
|
|
58
|
+
'--disable-renderer-backgrounding',
|
|
59
|
+
...(process.env.PERF_CHROME_ARGS
|
|
60
|
+
? process.env.PERF_CHROME_ARGS.split(' ')
|
|
61
|
+
: []),
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
try {
|
|
65
|
+
return await puppeteer.launch(options);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
// A second Chrome on the same profile (e.g. the UI's window while running the CLI) cannot start.
|
|
68
|
+
const locked = ['SingletonLock', 'lockfile'].some(f =>
|
|
69
|
+
fs.existsSync(path.join(CHROME_PROFILE_DIR, f)),
|
|
70
|
+
);
|
|
71
|
+
throw new Error(
|
|
72
|
+
locked
|
|
73
|
+
? `Chrome could not start because another Chrome is already using the profile ${CHROME_PROFILE_DIR}. Close that window (Close Chrome in the UI), or use a separate profile: PERF_CHROME_PROFILE_DIR=/tmp/perf-chrome-2.`
|
|
74
|
+
: `Chrome failed to start (${err.message}). Check CHROME_PATH (${executablePath}).`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function attachChrome(browserURL) {
|
|
80
|
+
return puppeteer.connect({ browserURL, defaultViewport: null });
|
|
81
|
+
}
|
package/server/cli.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless / scripted profiling:
|
|
3
|
+
*
|
|
4
|
+
* npm run profile -- --url http://localhost:3000/ --mode load
|
|
5
|
+
* npm run profile -- --url http://localhost:5188/ --duration 8 --throttle 4
|
|
6
|
+
* npm run profile -- --url ... --scenario ./scenario.mjs # export default async page => {…}
|
|
7
|
+
*
|
|
8
|
+
* The report is saved in reports/ and can be opened in the UI (npm run dev).
|
|
9
|
+
*/
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { pathToFileURL } from 'node:url';
|
|
12
|
+
import { PerfSession, readReport } from './recorder.js';
|
|
13
|
+
import { REPORTS_DIR } from './config.js';
|
|
14
|
+
|
|
15
|
+
const args = process.argv.slice(2);
|
|
16
|
+
const opt = (name, fallback) => {
|
|
17
|
+
const i = args.indexOf(`--${name}`);
|
|
18
|
+
return i === -1 ? fallback : (args[i + 1] ?? true);
|
|
19
|
+
};
|
|
20
|
+
const flag = name => args.includes(`--${name}`);
|
|
21
|
+
|
|
22
|
+
const url = opt('url');
|
|
23
|
+
if (!url || flag('help')) {
|
|
24
|
+
console.log(
|
|
25
|
+
'usage: npm run profile -- --url <page> [--mode live|load] [--duration seconds] [--throttle 4] [--scenario file.mjs] [--headed] [--save-trace] [--sign-in "http://host/login/?redirect={url}"]',
|
|
26
|
+
);
|
|
27
|
+
process.exit(url ? 0 : 1);
|
|
28
|
+
}
|
|
29
|
+
const mode = opt('mode', 'live');
|
|
30
|
+
const duration = Number(opt('duration', 8)) * 1000;
|
|
31
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
32
|
+
|
|
33
|
+
const session = new PerfSession();
|
|
34
|
+
try {
|
|
35
|
+
await session.connect({
|
|
36
|
+
mode: 'launch',
|
|
37
|
+
url,
|
|
38
|
+
headless: !flag('headed'),
|
|
39
|
+
signIn: opt('sign-in'),
|
|
40
|
+
});
|
|
41
|
+
await sleep(1500);
|
|
42
|
+
await session.reload(); // make sure the probe was installed before the app loaded
|
|
43
|
+
await sleep(1000);
|
|
44
|
+
await session.start({
|
|
45
|
+
mode,
|
|
46
|
+
cpuThrottle: Number(opt('throttle', 1)),
|
|
47
|
+
saveTrace: flag('save-trace'),
|
|
48
|
+
});
|
|
49
|
+
let report;
|
|
50
|
+
if (mode === 'live') {
|
|
51
|
+
const scenario = opt('scenario');
|
|
52
|
+
if (scenario) {
|
|
53
|
+
const mod = await import(pathToFileURL(path.resolve(scenario)).href);
|
|
54
|
+
await mod.default(session.page);
|
|
55
|
+
} else await sleep(duration);
|
|
56
|
+
report = await session.stop();
|
|
57
|
+
} else {
|
|
58
|
+
// Page-load recordings stop themselves shortly after the load event.
|
|
59
|
+
const id = await new Promise((resolve, reject) => {
|
|
60
|
+
session.once('report', r => resolve(r.id));
|
|
61
|
+
session.on('state', s => s.error && reject(new Error(s.error)));
|
|
62
|
+
});
|
|
63
|
+
report = readReport(id);
|
|
64
|
+
}
|
|
65
|
+
console.log(
|
|
66
|
+
`\nReport ${report.id} (${report.meta.attribution}, ${report.meta.sampleCount} samples)`,
|
|
67
|
+
);
|
|
68
|
+
console.log(
|
|
69
|
+
`TBT ${report.vitals.tbtMs}ms · long tasks ${report.vitals.longTaskCount} · CLS ${report.vitals.cls} · FCP ${report.vitals.fcpMs}ms · LCP ${report.vitals.lcpMs}ms · forced layouts ${report.totals.forcedLayoutCount}`,
|
|
70
|
+
);
|
|
71
|
+
console.log('\nCulprits:');
|
|
72
|
+
for (const f of report.findings.slice(0, 15)) {
|
|
73
|
+
console.log(` [${f.severity}] (${f.category}) ${f.title}`);
|
|
74
|
+
for (const c of f.culprits) {
|
|
75
|
+
const line = c.line
|
|
76
|
+
? `:${c.lineKind === 'located' ? '≈' : ''}${c.line}`
|
|
77
|
+
: '';
|
|
78
|
+
console.log(
|
|
79
|
+
` ↳ ${c.fn}() ${c.file || 'unknown'}${line}${c.ms ? ` ${c.ms}ms` : ''}${c.note ? ` (${c.note})` : ''}`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// When launched by the installed CLI the bin sets PERF_REPORTS_DIR; in a dev
|
|
84
|
+
// checkout it is unset. Tailor the "how to view it" hint to each.
|
|
85
|
+
const installed = !!process.env.PERF_REPORTS_DIR;
|
|
86
|
+
console.log(
|
|
87
|
+
`\nSaved ${path.join(REPORTS_DIR, report.id)}.json — view it with: ${
|
|
88
|
+
installed ? 'spectra dashboard' : 'npm run dev'
|
|
89
|
+
}`,
|
|
90
|
+
);
|
|
91
|
+
} finally {
|
|
92
|
+
await session.disconnect();
|
|
93
|
+
}
|
|
94
|
+
process.exit(0);
|
package/server/config.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { detectFramework } from './framework.js';
|
|
7
|
+
|
|
8
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
|
|
10
|
+
export const TOOL_ROOT = path.resolve(here, '..');
|
|
11
|
+
|
|
12
|
+
/** Frontend project root. Prefer `PERF_FRONTEND_DIR`, fall back to legacy var. */
|
|
13
|
+
export const FRONTEND_ROOT = path.resolve(
|
|
14
|
+
process.env.PERF_FRONTEND_DIR || process.env.FRONTEND_DIR || path.join(TOOL_ROOT, '../..'),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
/** Where source files are looked up when showing a file (first match wins). */
|
|
18
|
+
export const SOURCE_ROOTS = [
|
|
19
|
+
FRONTEND_ROOT,
|
|
20
|
+
path.join(TOOL_ROOT, 'fixtures/demo-app'),
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export const REPORTS_DIR =
|
|
24
|
+
process.env.PERF_REPORTS_DIR || path.join(TOOL_ROOT, 'reports');
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Dedicated Chrome profile so logins survive between runs. Keyed per profiled
|
|
28
|
+
* project and kept in the OS temp dir — never inside the installed package
|
|
29
|
+
* (node_modules) or committed into the user's repo.
|
|
30
|
+
*/
|
|
31
|
+
const profileKey = crypto
|
|
32
|
+
.createHash('sha1')
|
|
33
|
+
.update(FRONTEND_ROOT)
|
|
34
|
+
.digest('hex')
|
|
35
|
+
.slice(0, 8);
|
|
36
|
+
export const CHROME_PROFILE_DIR =
|
|
37
|
+
process.env.PERF_CHROME_PROFILE_DIR ||
|
|
38
|
+
path.join(os.tmpdir(), 'spectra-chrome', `${path.basename(FRONTEND_ROOT)}-${profileKey}`);
|
|
39
|
+
|
|
40
|
+
export const DEFAULT_TARGET_URL =
|
|
41
|
+
process.env.PERF_TARGET_URL || 'http://localhost:3000/';
|
|
42
|
+
|
|
43
|
+
/** Human-friendly name for the profiled target (used in the UI and PDF). */
|
|
44
|
+
let inferredName = 'target-frontend';
|
|
45
|
+
try {
|
|
46
|
+
const pkg = JSON.parse(
|
|
47
|
+
fs.readFileSync(path.join(FRONTEND_ROOT, 'package.json'), 'utf8'),
|
|
48
|
+
);
|
|
49
|
+
if (pkg && pkg.name) inferredName = pkg.name;
|
|
50
|
+
} catch {
|
|
51
|
+
// ignore
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const TARGET_NAME =
|
|
55
|
+
process.env.PERF_TARGET_NAME || process.env.FRONTEND_NAME || inferredName;
|
|
56
|
+
|
|
57
|
+
/** Detect framework/bundler info for the frontend root */
|
|
58
|
+
export const FRAMEWORK = detectFramework(FRONTEND_ROOT);
|
|
59
|
+
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone dashboard server for installed Spectra: serves the prebuilt UI
|
|
3
|
+
* (dist/) and mounts the same backend API as the Vite dev plugin, so an npm
|
|
4
|
+
* consumer gets the full dashboard — culprits, PDF export, commit compare,
|
|
5
|
+
* live recording — with no dev toolchain.
|
|
6
|
+
*
|
|
7
|
+
* Run directly (node server/dashboard.js [--port N] [--open]) or import
|
|
8
|
+
* startDashboard().
|
|
9
|
+
*/
|
|
10
|
+
import http from 'node:http';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { spawn } from 'node:child_process';
|
|
14
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
15
|
+
import { createApi } from './api.js';
|
|
16
|
+
|
|
17
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const PKG_ROOT = path.resolve(here, '..');
|
|
19
|
+
const DIST = process.env.SPECTRA_DASHBOARD_DIST || path.join(PKG_ROOT, 'dist');
|
|
20
|
+
|
|
21
|
+
const MIME = {
|
|
22
|
+
'.html': 'text/html; charset=utf-8',
|
|
23
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
24
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
25
|
+
'.css': 'text/css; charset=utf-8',
|
|
26
|
+
'.json': 'application/json',
|
|
27
|
+
'.map': 'application/json',
|
|
28
|
+
'.svg': 'image/svg+xml',
|
|
29
|
+
'.png': 'image/png',
|
|
30
|
+
'.jpg': 'image/jpeg',
|
|
31
|
+
'.jpeg': 'image/jpeg',
|
|
32
|
+
'.gif': 'image/gif',
|
|
33
|
+
'.ico': 'image/x-icon',
|
|
34
|
+
'.webp': 'image/webp',
|
|
35
|
+
'.woff': 'font/woff',
|
|
36
|
+
'.woff2': 'font/woff2',
|
|
37
|
+
'.ttf': 'font/ttf',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function serveStatic(res, pathname) {
|
|
41
|
+
const rel = decodeURIComponent(pathname).replace(/^\/+/, '');
|
|
42
|
+
const safe = path.normalize(rel);
|
|
43
|
+
if (safe.startsWith('..') || path.isAbsolute(safe)) {
|
|
44
|
+
res.statusCode = 403;
|
|
45
|
+
return res.end('Forbidden');
|
|
46
|
+
}
|
|
47
|
+
let file = safe ? path.join(DIST, safe) : path.join(DIST, 'index.html');
|
|
48
|
+
// SPA fallback: unknown path with no file extension serves the app shell.
|
|
49
|
+
if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) {
|
|
50
|
+
file = path.join(DIST, 'index.html');
|
|
51
|
+
}
|
|
52
|
+
if (!fs.existsSync(file)) {
|
|
53
|
+
res.statusCode = 404;
|
|
54
|
+
return res.end('Not found');
|
|
55
|
+
}
|
|
56
|
+
const ext = path.extname(file).toLowerCase();
|
|
57
|
+
res.writeHead(200, {
|
|
58
|
+
'Content-Type': MIME[ext] || 'application/octet-stream',
|
|
59
|
+
'Cache-Control': ext === '.html' ? 'no-store' : 'public, max-age=3600',
|
|
60
|
+
});
|
|
61
|
+
fs.createReadStream(file).pipe(res);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function listen(server, port, host) {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const onError = err => {
|
|
67
|
+
server.removeListener('error', onError);
|
|
68
|
+
reject(err);
|
|
69
|
+
};
|
|
70
|
+
server.once('error', onError);
|
|
71
|
+
server.listen(port, host, () => {
|
|
72
|
+
server.removeListener('error', onError);
|
|
73
|
+
resolve();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Start the dashboard. Tries `port`, then a few above it if busy.
|
|
80
|
+
* @returns {Promise<{ url:string, port:number, close:()=>Promise<void> }>}
|
|
81
|
+
*/
|
|
82
|
+
export async function startDashboard({ port = 4321, host = '127.0.0.1' } = {}) {
|
|
83
|
+
if (!fs.existsSync(path.join(DIST, 'index.html'))) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`dashboard assets not found at ${DIST}. In a dev checkout, build them first with "npm run build".`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
const api = createApi();
|
|
89
|
+
const server = http.createServer((req, res) => {
|
|
90
|
+
const u = new URL(req.url, 'http://localhost');
|
|
91
|
+
if (u.pathname === '/api' || u.pathname.startsWith('/api/')) {
|
|
92
|
+
req.url = req.url.slice('/api'.length) || '/';
|
|
93
|
+
return api.handle(req, res, null);
|
|
94
|
+
}
|
|
95
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
96
|
+
res.statusCode = 405;
|
|
97
|
+
return res.end('Method not allowed');
|
|
98
|
+
}
|
|
99
|
+
serveStatic(res, u.pathname);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
let chosen = port;
|
|
103
|
+
for (let i = 0; i < 10; i++) {
|
|
104
|
+
try {
|
|
105
|
+
await listen(server, chosen, host);
|
|
106
|
+
break;
|
|
107
|
+
} catch (err) {
|
|
108
|
+
if (err.code === 'EADDRINUSE' && i < 9) {
|
|
109
|
+
chosen += 1;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const url = `http://localhost:${chosen}`;
|
|
117
|
+
const close = async () => {
|
|
118
|
+
await api.dispose();
|
|
119
|
+
await new Promise(r => server.close(r));
|
|
120
|
+
};
|
|
121
|
+
return { url, port: chosen, close };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function openBrowser(url) {
|
|
125
|
+
const cmd =
|
|
126
|
+
process.platform === 'darwin'
|
|
127
|
+
? 'open'
|
|
128
|
+
: process.platform === 'win32'
|
|
129
|
+
? 'start'
|
|
130
|
+
: 'xdg-open';
|
|
131
|
+
try {
|
|
132
|
+
spawn(cmd, [url], { stdio: 'ignore', detached: true, shell: process.platform === 'win32' }).unref();
|
|
133
|
+
} catch {
|
|
134
|
+
/* best effort */
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Direct invocation: node server/dashboard.js [--port N] [--open]
|
|
139
|
+
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
|
|
140
|
+
const args = process.argv.slice(2);
|
|
141
|
+
const pi = args.indexOf('--port');
|
|
142
|
+
const port = pi !== -1 ? Number(args[pi + 1]) : Number(process.env.PORT) || 4321;
|
|
143
|
+
const { url, close } = await startDashboard({ port });
|
|
144
|
+
console.log(`\nSpectra dashboard running at ${url}`);
|
|
145
|
+
console.log('Reports and evaluations from this project are listed there.');
|
|
146
|
+
console.log('Press Ctrl+C to stop.\n');
|
|
147
|
+
if (args.includes('--open')) openBrowser(url);
|
|
148
|
+
const shutdown = async () => {
|
|
149
|
+
await close();
|
|
150
|
+
process.exit(0);
|
|
151
|
+
};
|
|
152
|
+
process.on('SIGINT', shutdown);
|
|
153
|
+
process.on('SIGTERM', shutdown);
|
|
154
|
+
}
|