@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
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-boot: start the target project's own dev server so `spectra performance`
|
|
3
|
+
* can profile it with no --url. The server's URL is read from its stdout (every
|
|
4
|
+
* common dev server — Vite, Next, CRA, Angular, Vue CLI, SvelteKit — prints a
|
|
5
|
+
* localhost URL when ready), then polled until it actually responds.
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
const ANSI = /\x1b\[[0-9;]*m/g;
|
|
12
|
+
const URL_RE =
|
|
13
|
+
/(https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(?::\d+)?[^\s)'"<]*)/i;
|
|
14
|
+
|
|
15
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
16
|
+
|
|
17
|
+
/** Choose a dev command from the project's package.json scripts. */
|
|
18
|
+
export function pickDevCommand(root) {
|
|
19
|
+
let scripts = {};
|
|
20
|
+
try {
|
|
21
|
+
scripts = JSON.parse(
|
|
22
|
+
fs.readFileSync(path.join(root, 'package.json'), 'utf8'),
|
|
23
|
+
).scripts || {};
|
|
24
|
+
} catch {
|
|
25
|
+
// no package.json / unreadable — fall through
|
|
26
|
+
}
|
|
27
|
+
for (const name of ['dev', 'start', 'serve', 'develop']) {
|
|
28
|
+
if (scripts[name]) return { cmd: 'npm', args: ['run', name], label: `npm run ${name}` };
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizeUrl(u) {
|
|
34
|
+
return u.replace('://0.0.0.0', '://localhost').replace(/\/$/, '');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Poll a URL until it responds (any HTTP status) or the deadline passes. */
|
|
38
|
+
async function waitForServer(url, timeoutMs) {
|
|
39
|
+
const deadline = Date.now() + timeoutMs;
|
|
40
|
+
while (Date.now() < deadline) {
|
|
41
|
+
try {
|
|
42
|
+
const ctrl = AbortSignal.timeout(2000);
|
|
43
|
+
const res = await fetch(url, { signal: ctrl, redirect: 'manual' });
|
|
44
|
+
if (res.status > 0) return true;
|
|
45
|
+
} catch {
|
|
46
|
+
// not up yet
|
|
47
|
+
}
|
|
48
|
+
await sleep(300);
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Start the dev server and resolve once it is reachable.
|
|
55
|
+
*
|
|
56
|
+
* @param {object} opts
|
|
57
|
+
* @param {string} [opts.root] project directory (default cwd)
|
|
58
|
+
* @param {string} [opts.command] explicit command, e.g. "npm run dev"
|
|
59
|
+
* @param {number} [opts.timeoutMs] how long to wait for the URL + readiness
|
|
60
|
+
* @param {(line:string)=>void} [opts.onLog] receives de-ANSI'd server output
|
|
61
|
+
* @returns {Promise<{ url:string, proc:import('child_process').ChildProcess, stop:()=>Promise<void> }>}
|
|
62
|
+
*/
|
|
63
|
+
export async function bootDevServer({
|
|
64
|
+
root = process.cwd(),
|
|
65
|
+
command,
|
|
66
|
+
timeoutMs = 60_000,
|
|
67
|
+
onLog,
|
|
68
|
+
} = {}) {
|
|
69
|
+
const chosen = command
|
|
70
|
+
? { cmd: command.split(' ')[0], args: command.split(' ').slice(1), label: command }
|
|
71
|
+
: pickDevCommand(root);
|
|
72
|
+
if (!chosen) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
'no dev script found in package.json (looked for dev/start/serve/develop). ' +
|
|
75
|
+
'Pass --url to profile an already-running server, or --command "<cmd>".',
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const proc = spawn(chosen.cmd, chosen.args, {
|
|
80
|
+
cwd: root,
|
|
81
|
+
env: { ...process.env },
|
|
82
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
let detectedUrl = null;
|
|
86
|
+
let resolveUrl;
|
|
87
|
+
const urlPromise = new Promise(r => (resolveUrl = r));
|
|
88
|
+
const scan = buf => {
|
|
89
|
+
const text = buf.toString().replace(ANSI, '');
|
|
90
|
+
if (onLog) onLog(text);
|
|
91
|
+
if (!detectedUrl) {
|
|
92
|
+
const m = text.match(URL_RE);
|
|
93
|
+
if (m) {
|
|
94
|
+
detectedUrl = normalizeUrl(m[1]);
|
|
95
|
+
resolveUrl(detectedUrl);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
proc.stdout.on('data', scan);
|
|
100
|
+
proc.stderr.on('data', scan);
|
|
101
|
+
|
|
102
|
+
let exited = false;
|
|
103
|
+
let exitErr = null;
|
|
104
|
+
proc.on('exit', code => {
|
|
105
|
+
exited = true;
|
|
106
|
+
if (code) exitErr = new Error(`dev server (${chosen.label}) exited early with code ${code}`);
|
|
107
|
+
resolveUrl(null);
|
|
108
|
+
});
|
|
109
|
+
proc.on('error', err => {
|
|
110
|
+
exited = true;
|
|
111
|
+
exitErr = new Error(`could not start dev server (${chosen.label}): ${err.message}`);
|
|
112
|
+
resolveUrl(null);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const stop = async () => {
|
|
116
|
+
if (exited) return;
|
|
117
|
+
try {
|
|
118
|
+
proc.kill('SIGTERM');
|
|
119
|
+
} catch {
|
|
120
|
+
/* already gone */
|
|
121
|
+
}
|
|
122
|
+
for (let i = 0; i < 10 && !exited; i++) await sleep(150);
|
|
123
|
+
if (!exited) {
|
|
124
|
+
try {
|
|
125
|
+
proc.kill('SIGKILL');
|
|
126
|
+
} catch {
|
|
127
|
+
/* ignore */
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const url = await Promise.race([urlPromise, sleep(timeoutMs).then(() => null)]);
|
|
133
|
+
if (exitErr) throw exitErr;
|
|
134
|
+
if (!url) {
|
|
135
|
+
await stop();
|
|
136
|
+
throw new Error(
|
|
137
|
+
'could not detect the dev server URL from its output in time. Pass --url explicitly.',
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const ok = await waitForServer(url, Math.max(5000, Math.floor(timeoutMs / 2)));
|
|
142
|
+
if (!ok) {
|
|
143
|
+
await stop();
|
|
144
|
+
throw new Error(
|
|
145
|
+
`dev server reported ${url} but it never became reachable. Pass --url explicitly.`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { url, proc, stop };
|
|
150
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure classification, destructive-action detection and ranking for the
|
|
3
|
+
* actionable elements discovered on a page. Kept free of any browser/puppeteer
|
|
4
|
+
* dependency so it can be unit-tested over plain descriptor objects.
|
|
5
|
+
*
|
|
6
|
+
* A "descriptor" is the raw shape produced by the in-page extractor in
|
|
7
|
+
* discover.js:
|
|
8
|
+
* {
|
|
9
|
+
* tag, type, role, label, href, name, id, selector,
|
|
10
|
+
* inForm, formMethod, formAction, disabled, visible, rect: [x,y,w,h]
|
|
11
|
+
* }
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Words that mark an action as state-changing / risky to trigger blindly. */
|
|
15
|
+
const DESTRUCTIVE_LABEL =
|
|
16
|
+
/\b(delete|remove|destroy|drop|erase|clear|reset|wipe|pay|buy|purchase|checkout|order|subscribe|donate|confirm|submit order|place order|deactivate|disable|archive|unsubscribe|sign\s?out|log\s?out|close account|cancel (?:account|subscription|plan))\b/i;
|
|
17
|
+
|
|
18
|
+
const DESTRUCTIVE_HREF = /(logout|signout|sign-out|log-out|\/delete|\/remove)/i;
|
|
19
|
+
|
|
20
|
+
/** Labels that usually denote primary navigation / high-value entry points. */
|
|
21
|
+
const PRIMARY_LABEL =
|
|
22
|
+
/\b(home|dashboard|login|log\s?in|sign\s?in|sign\s?up|register|get started|start|search|products?|pricing|settings|profile|account|cart|checkout|menu|explore|browse)\b/i;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Map a raw descriptor to a stable `kind` and the `actionType` the runner would
|
|
26
|
+
* use to drive it.
|
|
27
|
+
*/
|
|
28
|
+
export function classifyElement(d) {
|
|
29
|
+
const tag = (d.tag || '').toLowerCase();
|
|
30
|
+
const type = (d.type || '').toLowerCase();
|
|
31
|
+
const role = (d.role || '').toLowerCase();
|
|
32
|
+
|
|
33
|
+
if (tag === 'a' && d.href) {
|
|
34
|
+
const internalNav = isInternalHref(d.href);
|
|
35
|
+
return {
|
|
36
|
+
kind: internalNav ? 'nav-link' : 'link',
|
|
37
|
+
actionType: 'navigate',
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (tag === 'select') return { kind: 'select', actionType: 'choose' };
|
|
41
|
+
if (tag === 'textarea') return { kind: 'text-input', actionType: 'fill' };
|
|
42
|
+
|
|
43
|
+
if (tag === 'input') {
|
|
44
|
+
if (type === 'submit' || type === 'image')
|
|
45
|
+
return { kind: 'submit', actionType: 'submit' };
|
|
46
|
+
if (type === 'button' || type === 'reset')
|
|
47
|
+
return { kind: 'button', actionType: 'click' };
|
|
48
|
+
if (type === 'checkbox' || type === 'radio')
|
|
49
|
+
return { kind: 'toggle', actionType: 'choose' };
|
|
50
|
+
if (['hidden'].includes(type)) return { kind: 'unknown', actionType: null };
|
|
51
|
+
// text, email, password, search, tel, url, number, date, ...
|
|
52
|
+
return { kind: 'text-input', actionType: 'fill' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (tag === 'button' || role === 'button') {
|
|
56
|
+
if (type === 'submit') return { kind: 'submit', actionType: 'submit' };
|
|
57
|
+
return { kind: 'button', actionType: 'click' };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (role === 'link') return { kind: 'link', actionType: 'navigate' };
|
|
61
|
+
if (role === 'tab' || role === 'menuitem')
|
|
62
|
+
return { kind: 'button', actionType: 'click' };
|
|
63
|
+
if (['checkbox', 'switch', 'radio'].includes(role))
|
|
64
|
+
return { kind: 'toggle', actionType: 'choose' };
|
|
65
|
+
|
|
66
|
+
return { kind: 'unknown', actionType: null };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** True when an href points inside the same app rather than off to the web. */
|
|
70
|
+
export function isInternalHref(href) {
|
|
71
|
+
if (!href) return false;
|
|
72
|
+
const h = String(href).trim();
|
|
73
|
+
if (h.startsWith('#')) return false; // in-page anchor, not a navigation
|
|
74
|
+
if (/^(mailto:|tel:|sms:|javascript:|data:)/i.test(h)) return false;
|
|
75
|
+
if (/^https?:\/\//i.test(h)) return false; // absolute external (best-effort)
|
|
76
|
+
return true; // relative path, "/foo", "./foo", "foo"
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Decide whether triggering this element could change server/app state. Used by
|
|
81
|
+
* the safe-by-default runner to skip anything not explicitly allowlisted.
|
|
82
|
+
*/
|
|
83
|
+
export function isDestructive(d) {
|
|
84
|
+
const label = d.label || '';
|
|
85
|
+
if (DESTRUCTIVE_LABEL.test(label)) return true;
|
|
86
|
+
if (DESTRUCTIVE_HREF.test(d.href || '')) return true;
|
|
87
|
+
// A real form submit via POST/PUT/DELETE mutates by definition.
|
|
88
|
+
const { kind } = classifyElement(d);
|
|
89
|
+
if (kind === 'submit') {
|
|
90
|
+
const method = (d.formMethod || 'get').toLowerCase();
|
|
91
|
+
if (method && method !== 'get') return true;
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A short, human-friendly name for a descriptor, for reports and path names. */
|
|
97
|
+
export function labelOf(d) {
|
|
98
|
+
const raw = (d.label || '').replace(/\s+/g, ' ').trim();
|
|
99
|
+
if (raw) return raw.length > 60 ? raw.slice(0, 57) + '…' : raw;
|
|
100
|
+
if (d.name) return d.name;
|
|
101
|
+
if (d.href) return d.href;
|
|
102
|
+
const { kind } = classifyElement(d);
|
|
103
|
+
return kind === 'unknown' ? d.tag || 'element' : kind;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Score an element for how worthwhile it is to include in a golden path.
|
|
108
|
+
* Higher is better. Invisible, disabled, unlabeled and unknown elements sink.
|
|
109
|
+
*/
|
|
110
|
+
export function scoreElement(d) {
|
|
111
|
+
const { kind, actionType } = classifyElement(d);
|
|
112
|
+
if (actionType === null) return -Infinity;
|
|
113
|
+
let score = 0;
|
|
114
|
+
if (d.visible) score += 10;
|
|
115
|
+
else score -= 20;
|
|
116
|
+
if (d.disabled) score -= 15;
|
|
117
|
+
if ((d.label || '').trim()) score += 4;
|
|
118
|
+
if (PRIMARY_LABEL.test(d.label || '')) score += 6;
|
|
119
|
+
|
|
120
|
+
// Prefer entry points and clicks over raw text fields in isolation.
|
|
121
|
+
score += { 'nav-link': 5, button: 4, submit: 3, select: 2, 'text-input': 1, toggle: 1, link: 1, unknown: -10 }[kind] ?? 0;
|
|
122
|
+
|
|
123
|
+
// A stable id/data-test selector is more reliable to drive.
|
|
124
|
+
if (/#|\[data-test/.test(d.selector || '')) score += 3;
|
|
125
|
+
|
|
126
|
+
// Larger, on-screen targets are usually more meaningful than tiny icons.
|
|
127
|
+
const area = d.rect ? d.rect[2] * d.rect[3] : 0;
|
|
128
|
+
if (area > 0 && area < 8) score -= 4;
|
|
129
|
+
|
|
130
|
+
return score;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Turn a raw descriptor list into ranked, classified, de-duplicated candidates.
|
|
135
|
+
* Returns objects enriched with { kind, actionType, destructive, score, name }.
|
|
136
|
+
*/
|
|
137
|
+
export function rankElements(descriptors, { limit = 50 } = {}) {
|
|
138
|
+
const seen = new Set();
|
|
139
|
+
const out = [];
|
|
140
|
+
for (const d of descriptors || []) {
|
|
141
|
+
const cls = classifyElement(d);
|
|
142
|
+
if (cls.actionType === null) continue;
|
|
143
|
+
const key = d.selector || `${d.tag}:${d.label}:${d.href || ''}`;
|
|
144
|
+
if (seen.has(key)) continue;
|
|
145
|
+
seen.add(key);
|
|
146
|
+
out.push({
|
|
147
|
+
...d,
|
|
148
|
+
kind: cls.kind,
|
|
149
|
+
actionType: cls.actionType,
|
|
150
|
+
destructive: isDestructive(d),
|
|
151
|
+
name: labelOf(d),
|
|
152
|
+
score: scoreElement(d),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
out.sort((a, b) => b.score - a.score);
|
|
156
|
+
return out.slice(0, limit);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Split ranked candidates into the ones a safe run may drive, and the rest. */
|
|
160
|
+
export function partitionSafe(ranked, { allow = [] } = {}) {
|
|
161
|
+
const allowSet = new Set(allow);
|
|
162
|
+
const safe = [];
|
|
163
|
+
const skipped = [];
|
|
164
|
+
for (const el of ranked) {
|
|
165
|
+
if (el.destructive && !allowSet.has(el.selector)) skipped.push(el);
|
|
166
|
+
else safe.push(el);
|
|
167
|
+
}
|
|
168
|
+
return { safe, skipped };
|
|
169
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discovery: given a live puppeteer Page, enumerate the actionable elements on
|
|
3
|
+
* the current view and return a ranked, classified inventory.
|
|
4
|
+
*
|
|
5
|
+
* The live-DOM extraction (browser-side) is deliberately separated from the
|
|
6
|
+
* classification/ranking (pure Node, see classify.js) so the latter can be
|
|
7
|
+
* unit-tested without a browser.
|
|
8
|
+
*/
|
|
9
|
+
import { rankElements, partitionSafe } from './classify.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Runs in the page context. Returns an array of raw element descriptors. Must
|
|
13
|
+
* be self-contained — it is serialized and evaluated inside Chrome.
|
|
14
|
+
*/
|
|
15
|
+
function extractActionables() {
|
|
16
|
+
const SELECTOR = [
|
|
17
|
+
'a[href]',
|
|
18
|
+
'button',
|
|
19
|
+
'input',
|
|
20
|
+
'select',
|
|
21
|
+
'textarea',
|
|
22
|
+
'[role="button"]',
|
|
23
|
+
'[role="link"]',
|
|
24
|
+
'[role="tab"]',
|
|
25
|
+
'[role="menuitem"]',
|
|
26
|
+
'[role="checkbox"]',
|
|
27
|
+
'[role="switch"]',
|
|
28
|
+
'[role="radio"]',
|
|
29
|
+
'[onclick]',
|
|
30
|
+
].join(',');
|
|
31
|
+
|
|
32
|
+
function nthOfType(n) {
|
|
33
|
+
// 1-based index among same-tag element siblings, for :nth-of-type().
|
|
34
|
+
let i = 1;
|
|
35
|
+
for (let p = n.previousElementSibling; p; p = p.previousElementSibling) {
|
|
36
|
+
if (p.tagName === n.tagName) i++;
|
|
37
|
+
}
|
|
38
|
+
return i;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function selectorOf(el) {
|
|
42
|
+
if (!el || el.nodeType !== 1) return '';
|
|
43
|
+
const parts = [];
|
|
44
|
+
for (let n = el, i = 0; n && n.nodeType === 1 && i < 5; n = n.parentElement, i++) {
|
|
45
|
+
let s = n.tagName.toLowerCase();
|
|
46
|
+
if (n.id) {
|
|
47
|
+
parts.unshift(s + '#' + CSS.escape(n.id));
|
|
48
|
+
break; // an id is unique on its own — stop climbing
|
|
49
|
+
}
|
|
50
|
+
const testId =
|
|
51
|
+
n.getAttribute && (n.getAttribute('data-test') || n.getAttribute('data-testid'));
|
|
52
|
+
if (testId) {
|
|
53
|
+
s += '[data-test' + (n.getAttribute('data-testid') ? 'id' : '') + '="' + testId + '"]';
|
|
54
|
+
} else {
|
|
55
|
+
if (n.classList && n.classList.length) {
|
|
56
|
+
s += '.' + Array.prototype.slice.call(n.classList, 0, 2).map(c => CSS.escape(c)).join('.');
|
|
57
|
+
}
|
|
58
|
+
// Disambiguate from same-tag siblings so sibling links/buttons don't
|
|
59
|
+
// collapse to one selector (and so the selector actually resolves to a
|
|
60
|
+
// single element for the runner).
|
|
61
|
+
if (n.parentElement) s += ':nth-of-type(' + nthOfType(n) + ')';
|
|
62
|
+
}
|
|
63
|
+
parts.unshift(s);
|
|
64
|
+
}
|
|
65
|
+
return parts.join(' > ');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function accessibleName(el) {
|
|
69
|
+
const aria = el.getAttribute('aria-label');
|
|
70
|
+
if (aria && aria.trim()) return aria.trim();
|
|
71
|
+
const labelledby = el.getAttribute('aria-labelledby');
|
|
72
|
+
if (labelledby) {
|
|
73
|
+
const text = labelledby
|
|
74
|
+
.split(/\s+/)
|
|
75
|
+
.map(id => {
|
|
76
|
+
const n = document.getElementById(id);
|
|
77
|
+
return n ? n.textContent : '';
|
|
78
|
+
})
|
|
79
|
+
.join(' ')
|
|
80
|
+
.trim();
|
|
81
|
+
if (text) return text;
|
|
82
|
+
}
|
|
83
|
+
const tag = el.tagName.toLowerCase();
|
|
84
|
+
if (tag === 'input' || tag === 'textarea' || tag === 'select') {
|
|
85
|
+
if (el.id) {
|
|
86
|
+
const lab = document.querySelector('label[for="' + CSS.escape(el.id) + '"]');
|
|
87
|
+
if (lab && lab.textContent.trim()) return lab.textContent.trim();
|
|
88
|
+
}
|
|
89
|
+
const wrap = el.closest('label');
|
|
90
|
+
if (wrap && wrap.textContent.trim()) return wrap.textContent.trim();
|
|
91
|
+
if (el.placeholder) return el.placeholder;
|
|
92
|
+
if (el.getAttribute('name')) return el.getAttribute('name');
|
|
93
|
+
if (el.value && el.type === 'submit') return el.value;
|
|
94
|
+
}
|
|
95
|
+
const text = (el.textContent || '').replace(/\s+/g, ' ').trim();
|
|
96
|
+
if (text) return text;
|
|
97
|
+
return el.getAttribute('title') || el.getAttribute('alt') || '';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isVisible(el) {
|
|
101
|
+
const style = window.getComputedStyle(el);
|
|
102
|
+
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0)
|
|
103
|
+
return false;
|
|
104
|
+
const r = el.getBoundingClientRect();
|
|
105
|
+
return r.width > 0 && r.height > 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const forms = Array.from(document.querySelectorAll('form'));
|
|
109
|
+
const nodes = Array.from(document.querySelectorAll(SELECTOR));
|
|
110
|
+
const out = [];
|
|
111
|
+
for (const el of nodes) {
|
|
112
|
+
const tag = el.tagName.toLowerCase();
|
|
113
|
+
const form = el.closest ? el.closest('form') : null;
|
|
114
|
+
const r = el.getBoundingClientRect();
|
|
115
|
+
out.push({
|
|
116
|
+
tag,
|
|
117
|
+
type: el.getAttribute('type') || '',
|
|
118
|
+
role: el.getAttribute('role') || '',
|
|
119
|
+
label: accessibleName(el),
|
|
120
|
+
href: tag === 'a' ? el.getAttribute('href') || '' : '',
|
|
121
|
+
name: el.getAttribute('name') || '',
|
|
122
|
+
id: el.id || '',
|
|
123
|
+
selector: selectorOf(el),
|
|
124
|
+
inForm: !!form,
|
|
125
|
+
formIndex: form ? forms.indexOf(form) : -1,
|
|
126
|
+
formMethod: form ? (form.getAttribute('method') || 'get') : '',
|
|
127
|
+
formAction: form ? form.getAttribute('action') || '' : '',
|
|
128
|
+
disabled: !!el.disabled,
|
|
129
|
+
visible: isVisible(el),
|
|
130
|
+
rect: [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)],
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Discover actionable elements on the page's current view.
|
|
138
|
+
*
|
|
139
|
+
* @param {import('puppeteer-core').Page} page
|
|
140
|
+
* @param {{ limit?: number, allow?: string[] }} [opts]
|
|
141
|
+
* limit — max ranked elements to keep; allow — selectors of destructive
|
|
142
|
+
* actions the caller has explicitly permitted the runner to trigger.
|
|
143
|
+
*/
|
|
144
|
+
export async function discoverActionables(page, opts = {}) {
|
|
145
|
+
const raw = await page.evaluate(extractActionables);
|
|
146
|
+
const ranked = rankElements(raw, { limit: opts.limit ?? 50 });
|
|
147
|
+
const { safe, skipped } = partitionSafe(ranked, { allow: opts.allow ?? [] });
|
|
148
|
+
const meta = await page.evaluate(() => ({
|
|
149
|
+
url: location.href,
|
|
150
|
+
title: document.title,
|
|
151
|
+
}));
|
|
152
|
+
return {
|
|
153
|
+
url: meta.url,
|
|
154
|
+
title: meta.title,
|
|
155
|
+
total: raw.length,
|
|
156
|
+
ranked,
|
|
157
|
+
safe,
|
|
158
|
+
skipped,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export { extractActionables };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI wrapper for the evaluation orchestrator — the default action of
|
|
3
|
+
* `spectra performance`. Runs the golden-path suite against --url, prints the
|
|
4
|
+
* performance matrix, and saves an aggregate report alongside the per-path
|
|
5
|
+
* reports (all openable in the dashboard).
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { runEvaluation } from './evaluate.js';
|
|
10
|
+
import { REPORTS_DIR, TARGET_NAME } from '../config.js';
|
|
11
|
+
|
|
12
|
+
const args = process.argv.slice(2);
|
|
13
|
+
const opt = (name, fallback) => {
|
|
14
|
+
const i = args.indexOf(`--${name}`);
|
|
15
|
+
return i === -1 ? fallback : args[i + 1] ?? true;
|
|
16
|
+
};
|
|
17
|
+
const flag = name => args.includes(`--${name}`);
|
|
18
|
+
|
|
19
|
+
const url = opt('url');
|
|
20
|
+
if (!url) {
|
|
21
|
+
console.error('spectra: evaluate requires --url');
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const pad = (s, n) => String(s ?? '').padEnd(n).slice(0, n);
|
|
26
|
+
const padL = (s, n) => String(s ?? '').padStart(n);
|
|
27
|
+
const ms = v => (v == null ? '—' : `${v}ms`);
|
|
28
|
+
|
|
29
|
+
function printMatrix(result) {
|
|
30
|
+
const rows = [result.baseline, ...result.paths];
|
|
31
|
+
console.log(`\n${'═'.repeat(78)}`);
|
|
32
|
+
console.log(`Spectra performance evaluation — ${result.target}`);
|
|
33
|
+
console.log(`${result.url}`);
|
|
34
|
+
console.log(
|
|
35
|
+
`${result.discovered} actionable elements · ${result.paths.length} golden paths · ` +
|
|
36
|
+
`${result.skippedDestructive.length} destructive skipped (safe mode)`,
|
|
37
|
+
);
|
|
38
|
+
console.log('═'.repeat(78));
|
|
39
|
+
console.log(
|
|
40
|
+
`${pad('#', 3)}${pad('Golden path', 34)}${pad('Result', 8)}${padL('TBT', 7)}${padL('LCP', 8)}${padL('LongT', 7)} Top finding`,
|
|
41
|
+
);
|
|
42
|
+
console.log('─'.repeat(78));
|
|
43
|
+
rows.forEach((r, i) => {
|
|
44
|
+
const num = i === 0 ? '·' : String(i);
|
|
45
|
+
const status = r.ok ? 'PASS' : 'FAIL';
|
|
46
|
+
const s = r.summary || {};
|
|
47
|
+
const top = s.topFinding ? `[${s.topFinding.severity}] ${s.topFinding.title}` : '—';
|
|
48
|
+
console.log(
|
|
49
|
+
`${pad(num, 3)}${pad(r.title, 34)}${pad(status, 8)}${padL(ms(s.tbtMs), 7)}${padL(ms(s.lcpMs), 8)}${padL(s.longTaskCount ?? '—', 7)} ${top.slice(0, 40)}`,
|
|
50
|
+
);
|
|
51
|
+
if (!r.ok && r.error) console.log(` ↳ ${r.error}`);
|
|
52
|
+
});
|
|
53
|
+
console.log('─'.repeat(78));
|
|
54
|
+
const passed = result.paths.filter(p => p.ok).length;
|
|
55
|
+
console.log(`Automation: ${passed}/${result.paths.length} golden paths passed.`);
|
|
56
|
+
if (result.skippedDestructive.length) {
|
|
57
|
+
console.log(
|
|
58
|
+
`Skipped (safe mode): ${result.skippedDestructive.map(s => s.name).join(', ')}`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function stamp() {
|
|
64
|
+
const d = new Date();
|
|
65
|
+
const p = n => String(n).padStart(2, '0');
|
|
66
|
+
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}T${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const result = await runEvaluation({
|
|
70
|
+
url,
|
|
71
|
+
headless: !flag('headed'),
|
|
72
|
+
limit: Number(opt('paths', 12)),
|
|
73
|
+
signIn: opt('sign-in'),
|
|
74
|
+
onProgress: ev => {
|
|
75
|
+
if (ev.phase === 'baseline') console.error('spectra: recording baseline page load …');
|
|
76
|
+
else if (ev.phase === 'discover') console.error('spectra: discovering actionable elements …');
|
|
77
|
+
else if (ev.phase === 'synthesized')
|
|
78
|
+
console.error(
|
|
79
|
+
`spectra: ${ev.discovered} actionable elements (${ev.skipped} destructive skipped) → ${ev.paths} golden paths`,
|
|
80
|
+
);
|
|
81
|
+
else if (ev.phase === 'path')
|
|
82
|
+
console.error(`spectra: [${ev.index}/${ev.total}] ${ev.title}`);
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
printMatrix(result);
|
|
87
|
+
|
|
88
|
+
const id = `evaluation-${stamp()}`;
|
|
89
|
+
fs.mkdirSync(REPORTS_DIR, { recursive: true });
|
|
90
|
+
const file = path.join(REPORTS_DIR, `${id}.json`);
|
|
91
|
+
fs.writeFileSync(
|
|
92
|
+
file,
|
|
93
|
+
JSON.stringify({ id, kind: 'evaluation', createdAt: Date.now(), target: TARGET_NAME, ...result }, null, 2),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const installed = !!process.env.PERF_REPORTS_DIR;
|
|
97
|
+
console.log(`\nSaved ${file}`);
|
|
98
|
+
console.log(
|
|
99
|
+
`Per-path reports are in ${REPORTS_DIR} — view them with: ${installed ? 'spectra dashboard' : 'npm run dev'}`,
|
|
100
|
+
);
|
|
101
|
+
process.exit(0);
|