@progen-labs/spectra 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +99 -2
- package/bin/spectra.js +215 -0
- package/dist/assets/index-BvBsPok5.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 +551 -0
- package/server/analysis/locate.js +99 -0
- package/server/analysis/report.js +750 -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 +689 -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 +118 -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 +200 -0
- package/src/components/SidePicker.jsx +207 -0
- package/src/components/SnapshotButton.jsx +140 -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,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evaluation orchestrator: the engine behind `spectra performance`.
|
|
3
|
+
*
|
|
4
|
+
* Connects once, records a baseline page-load, discovers the actionable
|
|
5
|
+
* surface, synthesizes golden paths, and runs each path as its own live
|
|
6
|
+
* recording. Every path therefore produces a standard report (persisted by the
|
|
7
|
+
* recorder to REPORTS_DIR) that the dashboard renders with all existing
|
|
8
|
+
* features — culprits, PDF export, commit compare. The aggregate returned here
|
|
9
|
+
* is the performance matrix across those reports.
|
|
10
|
+
*/
|
|
11
|
+
import { PerfSession, readReport } from '../recorder.js';
|
|
12
|
+
import { TARGET_NAME } from '../config.js';
|
|
13
|
+
import { discoverActionables } from './discover.js';
|
|
14
|
+
import { synthesizePaths } from './paths.js';
|
|
15
|
+
import { runPath } from './runner.js';
|
|
16
|
+
|
|
17
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
18
|
+
|
|
19
|
+
function summarizeReport(report) {
|
|
20
|
+
const v = report.vitals || {};
|
|
21
|
+
const top = (report.findings || [])[0];
|
|
22
|
+
return {
|
|
23
|
+
tbtMs: v.tbtMs ?? null,
|
|
24
|
+
longTaskCount: v.longTaskCount ?? null,
|
|
25
|
+
lcpMs: v.lcpMs ?? null,
|
|
26
|
+
fcpMs: v.fcpMs ?? null,
|
|
27
|
+
cls: v.cls ?? null,
|
|
28
|
+
forcedLayoutCount: report.totals?.forcedLayoutCount ?? null,
|
|
29
|
+
findingCount: (report.findings || []).length,
|
|
30
|
+
topFinding: top
|
|
31
|
+
? { severity: top.severity, category: top.category, title: top.title }
|
|
32
|
+
: null,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Record the baseline page load (load mode self-stops after settle). */
|
|
37
|
+
function recordBaseline(session) {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const onReport = r => {
|
|
40
|
+
cleanup();
|
|
41
|
+
resolve(r.id);
|
|
42
|
+
};
|
|
43
|
+
const onState = s => {
|
|
44
|
+
if (s.error) {
|
|
45
|
+
cleanup();
|
|
46
|
+
reject(new Error(s.error));
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const cleanup = () => {
|
|
50
|
+
session.off('report', onReport);
|
|
51
|
+
session.off('state', onState);
|
|
52
|
+
};
|
|
53
|
+
session.on('report', onReport);
|
|
54
|
+
session.on('state', onState);
|
|
55
|
+
session.start({ mode: 'load' }).catch(err => {
|
|
56
|
+
cleanup();
|
|
57
|
+
reject(err);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {object} opts
|
|
64
|
+
* @param {string} opts.url start URL (already reachable)
|
|
65
|
+
* @param {boolean} [opts.headless] default true
|
|
66
|
+
* @param {number} [opts.limit] max golden paths (default 12)
|
|
67
|
+
* @param {string[]} [opts.allow] selectors of destructive actions to permit
|
|
68
|
+
* @param {string} [opts.signIn] sign-in URL template
|
|
69
|
+
* @param {(ev:object)=>void} [opts.onProgress]
|
|
70
|
+
*/
|
|
71
|
+
export async function runEvaluation({
|
|
72
|
+
url,
|
|
73
|
+
headless = true,
|
|
74
|
+
limit = 12,
|
|
75
|
+
allow = [],
|
|
76
|
+
signIn,
|
|
77
|
+
onProgress,
|
|
78
|
+
} = {}) {
|
|
79
|
+
const log = onProgress || (() => {});
|
|
80
|
+
const session = new PerfSession();
|
|
81
|
+
const results = [];
|
|
82
|
+
try {
|
|
83
|
+
await session.connect({ mode: 'launch', url, headless, signIn });
|
|
84
|
+
// Auto-dismiss alert/confirm/beforeunload dialogs so a journey can't hang.
|
|
85
|
+
if (session.page) {
|
|
86
|
+
session.page.on('dialog', d => d.dismiss().catch(() => {}));
|
|
87
|
+
}
|
|
88
|
+
await sleep(1200);
|
|
89
|
+
await session.reload(); // make sure the probe is installed before load
|
|
90
|
+
await sleep(800);
|
|
91
|
+
|
|
92
|
+
// 1) Baseline page-load profile.
|
|
93
|
+
log({ phase: 'baseline' });
|
|
94
|
+
const loadId = await recordBaseline(session);
|
|
95
|
+
const baseline = {
|
|
96
|
+
id: loadId,
|
|
97
|
+
title: 'Initial page load',
|
|
98
|
+
kind: 'load',
|
|
99
|
+
ok: true,
|
|
100
|
+
steps: [],
|
|
101
|
+
summary: summarizeReport(readReport(loadId)),
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// 2) Discover the actionable surface on the loaded page.
|
|
105
|
+
log({ phase: 'discover' });
|
|
106
|
+
const inventory = await discoverActionables(session.page, { allow });
|
|
107
|
+
|
|
108
|
+
// 3) Synthesize golden paths.
|
|
109
|
+
const paths = synthesizePaths(inventory, { limit });
|
|
110
|
+
log({
|
|
111
|
+
phase: 'synthesized',
|
|
112
|
+
discovered: inventory.ranked.length,
|
|
113
|
+
skipped: inventory.skipped.length,
|
|
114
|
+
paths: paths.length,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// 4) Run each golden path as its own live recording.
|
|
118
|
+
for (let i = 0; i < paths.length; i++) {
|
|
119
|
+
const p = paths[i];
|
|
120
|
+
log({ phase: 'path', index: i + 1, total: paths.length, title: p.title });
|
|
121
|
+
let runRes = null;
|
|
122
|
+
let report = null;
|
|
123
|
+
let error = null;
|
|
124
|
+
try {
|
|
125
|
+
await session.navigate(url, signIn);
|
|
126
|
+
await sleep(500);
|
|
127
|
+
await session.start({ mode: 'live' });
|
|
128
|
+
runRes = await runPath(session.page, p);
|
|
129
|
+
report = await session.stop();
|
|
130
|
+
} catch (err) {
|
|
131
|
+
error = err.message;
|
|
132
|
+
if (session.state === 'recording') {
|
|
133
|
+
try {
|
|
134
|
+
report = await session.stop();
|
|
135
|
+
} catch {
|
|
136
|
+
/* ignore */
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
results.push({
|
|
141
|
+
id: report ? report.id : null,
|
|
142
|
+
title: p.title,
|
|
143
|
+
kind: p.kind,
|
|
144
|
+
ok: !!(runRes && runRes.ok) && !error,
|
|
145
|
+
steps: runRes ? runRes.steps : [],
|
|
146
|
+
error,
|
|
147
|
+
summary: report ? summarizeReport(report) : null,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
url,
|
|
153
|
+
target: TARGET_NAME,
|
|
154
|
+
baseline,
|
|
155
|
+
discovered: inventory.ranked.length,
|
|
156
|
+
skippedDestructive: inventory.skipped.map(s => ({
|
|
157
|
+
name: s.name,
|
|
158
|
+
selector: s.selector,
|
|
159
|
+
})),
|
|
160
|
+
paths: results,
|
|
161
|
+
};
|
|
162
|
+
} finally {
|
|
163
|
+
await session.disconnect();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Golden-path synthesis: turn a ranked, classified element inventory (from
|
|
3
|
+
* discover.js) into a small set of automation journeys the runner can drive.
|
|
4
|
+
*
|
|
5
|
+
* Heuristic and deterministic — no AI, no network. Safe by default: a journey
|
|
6
|
+
* only submits a form when that form's submit is non-destructive, and
|
|
7
|
+
* destructive controls are never clicked unless the caller allowlisted them
|
|
8
|
+
* (partitionSafe has already filtered those into `skipped`).
|
|
9
|
+
*
|
|
10
|
+
* A path:
|
|
11
|
+
* { id, title, kind, steps: [ { action, selector, value?, label } ] }
|
|
12
|
+
* Step actions: 'navigate' (click a link), 'click', 'fill', 'choose', 'submit'.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** A plausible synthetic value for a text-like input, from its type/name. */
|
|
16
|
+
export function syntheticValue(d) {
|
|
17
|
+
const type = (d.type || '').toLowerCase();
|
|
18
|
+
const hint = `${d.name || ''} ${d.label || ''} ${d.id || ''}`.toLowerCase();
|
|
19
|
+
if (type === 'email' || /e-?mail/.test(hint)) return 'test@example.com';
|
|
20
|
+
if (type === 'password' || /pass/.test(hint)) return 'Password123!';
|
|
21
|
+
if (type === 'number' || /\b(age|qty|quantity|count|amount)\b/.test(hint)) return '1';
|
|
22
|
+
if (type === 'tel' || /phone|mobile|tel/.test(hint)) return '5551234567';
|
|
23
|
+
if (type === 'url' || /url|website|link/.test(hint)) return 'https://example.com';
|
|
24
|
+
if (type === 'date') return '2024-01-01';
|
|
25
|
+
if (type === 'search' || /search|query/.test(hint)) return 'test';
|
|
26
|
+
if (/name/.test(hint)) return 'Test User';
|
|
27
|
+
return 'test';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function navPaths(safe, limit) {
|
|
31
|
+
return safe
|
|
32
|
+
.filter(e => e.kind === 'nav-link')
|
|
33
|
+
.slice(0, limit)
|
|
34
|
+
.map((e, i) => ({
|
|
35
|
+
id: `nav-${i + 1}`,
|
|
36
|
+
title: `Navigate to "${e.name}"`,
|
|
37
|
+
kind: 'navigation',
|
|
38
|
+
steps: [{ action: 'navigate', selector: e.selector, label: e.name }],
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function clickPaths(safe, limit) {
|
|
43
|
+
return safe
|
|
44
|
+
.filter(e => e.kind === 'button' && e.formIndex === -1)
|
|
45
|
+
.slice(0, limit)
|
|
46
|
+
.map((e, i) => ({
|
|
47
|
+
id: `click-${i + 1}`,
|
|
48
|
+
title: `Click "${e.name}"`,
|
|
49
|
+
kind: 'interaction',
|
|
50
|
+
steps: [{ action: 'click', selector: e.selector, label: e.name }],
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function formPaths(safe, limit) {
|
|
55
|
+
// Group safe, fillable controls by the form they belong to.
|
|
56
|
+
const byForm = new Map();
|
|
57
|
+
for (const e of safe) {
|
|
58
|
+
if (e.formIndex === -1) continue;
|
|
59
|
+
if (!byForm.has(e.formIndex)) byForm.set(e.formIndex, []);
|
|
60
|
+
byForm.get(e.formIndex).push(e);
|
|
61
|
+
}
|
|
62
|
+
const paths = [];
|
|
63
|
+
let i = 0;
|
|
64
|
+
for (const [, members] of byForm) {
|
|
65
|
+
if (paths.length >= limit) break;
|
|
66
|
+
const steps = [];
|
|
67
|
+
for (const e of members) {
|
|
68
|
+
if (e.kind === 'text-input') {
|
|
69
|
+
steps.push({ action: 'fill', selector: e.selector, value: syntheticValue(e), label: e.name });
|
|
70
|
+
} else if (e.kind === 'toggle') {
|
|
71
|
+
steps.push({ action: 'choose', selector: e.selector, label: e.name });
|
|
72
|
+
} else if (e.kind === 'select') {
|
|
73
|
+
steps.push({ action: 'choose', selector: e.selector, value: '', label: e.name });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (!steps.length) continue;
|
|
77
|
+
// Submit only if this form's submit survived the safe filter.
|
|
78
|
+
const submit = members.find(e => e.kind === 'submit');
|
|
79
|
+
if (submit) {
|
|
80
|
+
steps.push({ action: 'submit', selector: submit.selector, label: submit.name });
|
|
81
|
+
}
|
|
82
|
+
const title = submit
|
|
83
|
+
? `Fill & submit the "${steps[0].label || 'form'}" form`
|
|
84
|
+
: `Fill the "${steps[0].label || 'form'}" form`;
|
|
85
|
+
paths.push({ id: `form-${++i}`, title, kind: 'form', steps });
|
|
86
|
+
}
|
|
87
|
+
return paths;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Synthesize golden paths from a discovery result (`{ safe, ... }`).
|
|
92
|
+
*
|
|
93
|
+
* @param {{ safe: object[] }} discovery
|
|
94
|
+
* @param {{ limit?: number }} [opts] max total paths (default 12)
|
|
95
|
+
*/
|
|
96
|
+
export function synthesizePaths(discovery, { limit = 12 } = {}) {
|
|
97
|
+
const safe = discovery.safe || [];
|
|
98
|
+
// Forms are the highest-value journeys, then navigation, then lone buttons.
|
|
99
|
+
const forms = formPaths(safe, Math.min(limit, 5));
|
|
100
|
+
const remaining1 = limit - forms.length;
|
|
101
|
+
const navs = navPaths(safe, Math.min(remaining1, 6));
|
|
102
|
+
const remaining2 = remaining1 - navs.length;
|
|
103
|
+
const clicks = clickPaths(safe, Math.max(0, remaining2));
|
|
104
|
+
return [...forms, ...navs, ...clicks].slice(0, limit);
|
|
105
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Journey runner: drive one synthesized golden path against a live puppeteer
|
|
3
|
+
* Page. Each step is attempted in order; the first failure stops the journey
|
|
4
|
+
* (you cannot meaningfully continue a flow past a broken step) and the rest are
|
|
5
|
+
* marked skipped. Returns a per-step result and an overall pass/fail.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const STEP_TIMEOUT = 8000;
|
|
9
|
+
const SETTLE_MS = 600;
|
|
10
|
+
|
|
11
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
12
|
+
|
|
13
|
+
async function waitFor(page, selector) {
|
|
14
|
+
return page.waitForSelector(selector, { timeout: STEP_TIMEOUT, visible: true });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function runStep(page, step) {
|
|
18
|
+
const el = await waitFor(page, step.selector);
|
|
19
|
+
switch (step.action) {
|
|
20
|
+
case 'navigate': {
|
|
21
|
+
// A link click that may trigger a navigation; tolerate SPA (no nav).
|
|
22
|
+
await Promise.race([
|
|
23
|
+
page.waitForNavigation({ timeout: STEP_TIMEOUT, waitUntil: 'domcontentloaded' }).catch(() => {}),
|
|
24
|
+
el.click(),
|
|
25
|
+
]);
|
|
26
|
+
await sleep(SETTLE_MS);
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
case 'click': {
|
|
30
|
+
await el.click();
|
|
31
|
+
await sleep(SETTLE_MS);
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
case 'fill': {
|
|
35
|
+
await el.click({ clickCount: 3 }).catch(() => {}); // select existing text
|
|
36
|
+
await el.type(String(step.value ?? ''), { delay: 10 });
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
case 'choose': {
|
|
40
|
+
const tag = await page.evaluate(e => e.tagName.toLowerCase(), el);
|
|
41
|
+
if (tag === 'select') {
|
|
42
|
+
// Pick the first non-empty option.
|
|
43
|
+
const value = await page.evaluate(e => {
|
|
44
|
+
const opt = Array.from(e.options).find(o => o.value) || e.options[0];
|
|
45
|
+
return opt ? opt.value : '';
|
|
46
|
+
}, el);
|
|
47
|
+
if (value) await page.select(step.selector, value);
|
|
48
|
+
} else {
|
|
49
|
+
await el.click(); // checkbox / radio / switch
|
|
50
|
+
}
|
|
51
|
+
await sleep(SETTLE_MS);
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
case 'submit': {
|
|
55
|
+
await Promise.race([
|
|
56
|
+
page.waitForNavigation({ timeout: STEP_TIMEOUT, waitUntil: 'domcontentloaded' }).catch(() => {}),
|
|
57
|
+
el.click(),
|
|
58
|
+
]);
|
|
59
|
+
await sleep(SETTLE_MS);
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
default:
|
|
63
|
+
throw new Error(`unknown step action "${step.action}"`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Run a path's steps on `page`. Does not start/stop recording — the caller
|
|
69
|
+
* wraps this in a PerfSession window.
|
|
70
|
+
*
|
|
71
|
+
* @returns {Promise<{ ok: boolean, steps: Array<{ action, label, selector, ok, error?, skipped? }> }>}
|
|
72
|
+
*/
|
|
73
|
+
export async function runPath(page, path) {
|
|
74
|
+
const results = [];
|
|
75
|
+
let failed = false;
|
|
76
|
+
for (const step of path.steps) {
|
|
77
|
+
if (failed) {
|
|
78
|
+
results.push({ ...stepMeta(step), ok: false, skipped: true });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
await runStep(page, step);
|
|
83
|
+
results.push({ ...stepMeta(step), ok: true });
|
|
84
|
+
} catch (err) {
|
|
85
|
+
failed = true;
|
|
86
|
+
results.push({ ...stepMeta(step), ok: false, error: err.message });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { ok: !failed, steps: results };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function stepMeta(step) {
|
|
93
|
+
return { action: step.action, label: step.label || '', selector: step.selector };
|
|
94
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {
|
|
4
|
+
classifyElement,
|
|
5
|
+
isInternalHref,
|
|
6
|
+
isDestructive,
|
|
7
|
+
labelOf,
|
|
8
|
+
scoreElement,
|
|
9
|
+
rankElements,
|
|
10
|
+
partitionSafe,
|
|
11
|
+
} from './classify.js';
|
|
12
|
+
|
|
13
|
+
const desc = (over = {}) => ({
|
|
14
|
+
tag: 'button',
|
|
15
|
+
type: '',
|
|
16
|
+
role: '',
|
|
17
|
+
label: 'Click me',
|
|
18
|
+
href: '',
|
|
19
|
+
name: '',
|
|
20
|
+
id: '',
|
|
21
|
+
selector: 'button.cta',
|
|
22
|
+
inForm: false,
|
|
23
|
+
formMethod: '',
|
|
24
|
+
formAction: '',
|
|
25
|
+
disabled: false,
|
|
26
|
+
visible: true,
|
|
27
|
+
rect: [0, 0, 100, 40],
|
|
28
|
+
...over,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('classifyElement maps tags, input types and ARIA roles to a kind/action', () => {
|
|
32
|
+
assert.deepEqual(classifyElement(desc({ tag: 'a', href: '/pricing' })), {
|
|
33
|
+
kind: 'nav-link',
|
|
34
|
+
actionType: 'navigate',
|
|
35
|
+
});
|
|
36
|
+
assert.deepEqual(classifyElement(desc({ tag: 'a', href: 'https://x.com' })), {
|
|
37
|
+
kind: 'link',
|
|
38
|
+
actionType: 'navigate',
|
|
39
|
+
});
|
|
40
|
+
assert.deepEqual(classifyElement(desc({ tag: 'input', type: 'email' })), {
|
|
41
|
+
kind: 'text-input',
|
|
42
|
+
actionType: 'fill',
|
|
43
|
+
});
|
|
44
|
+
assert.deepEqual(classifyElement(desc({ tag: 'input', type: 'submit' })), {
|
|
45
|
+
kind: 'submit',
|
|
46
|
+
actionType: 'submit',
|
|
47
|
+
});
|
|
48
|
+
assert.deepEqual(classifyElement(desc({ tag: 'input', type: 'checkbox' })), {
|
|
49
|
+
kind: 'toggle',
|
|
50
|
+
actionType: 'choose',
|
|
51
|
+
});
|
|
52
|
+
assert.deepEqual(classifyElement(desc({ tag: 'div', role: 'button' })), {
|
|
53
|
+
kind: 'button',
|
|
54
|
+
actionType: 'click',
|
|
55
|
+
});
|
|
56
|
+
assert.deepEqual(classifyElement(desc({ tag: 'select' })), {
|
|
57
|
+
kind: 'select',
|
|
58
|
+
actionType: 'choose',
|
|
59
|
+
});
|
|
60
|
+
// hidden inputs and unknown elements carry no action
|
|
61
|
+
assert.equal(classifyElement(desc({ tag: 'input', type: 'hidden' })).actionType, null);
|
|
62
|
+
assert.equal(classifyElement(desc({ tag: 'span', role: '' })).actionType, null);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('isInternalHref distinguishes in-app navigation from external/non-nav', () => {
|
|
66
|
+
assert.equal(isInternalHref('/dashboard'), true);
|
|
67
|
+
assert.equal(isInternalHref('./settings'), true);
|
|
68
|
+
assert.equal(isInternalHref('products'), true);
|
|
69
|
+
assert.equal(isInternalHref('#section'), false);
|
|
70
|
+
assert.equal(isInternalHref('https://example.com'), false);
|
|
71
|
+
assert.equal(isInternalHref('mailto:a@b.com'), false);
|
|
72
|
+
assert.equal(isInternalHref('javascript:void(0)'), false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('isDestructive flags mutating labels, logout hrefs and POST submits', () => {
|
|
76
|
+
assert.equal(isDestructive(desc({ label: 'Delete account' })), true);
|
|
77
|
+
assert.equal(isDestructive(desc({ label: 'Pay now' })), true);
|
|
78
|
+
assert.equal(isDestructive(desc({ label: 'Log out' })), true);
|
|
79
|
+
assert.equal(isDestructive(desc({ tag: 'a', href: '/logout' })), true);
|
|
80
|
+
assert.equal(
|
|
81
|
+
isDestructive(desc({ tag: 'input', type: 'submit', inForm: true, formMethod: 'post' })),
|
|
82
|
+
true,
|
|
83
|
+
);
|
|
84
|
+
// a GET form submit (e.g. a search) is not destructive
|
|
85
|
+
assert.equal(
|
|
86
|
+
isDestructive(desc({ tag: 'input', type: 'submit', inForm: true, formMethod: 'get', label: 'Search' })),
|
|
87
|
+
false,
|
|
88
|
+
);
|
|
89
|
+
// an ordinary navigation / click is safe
|
|
90
|
+
assert.equal(isDestructive(desc({ tag: 'a', href: '/pricing', label: 'Pricing' })), false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('labelOf prefers accessible text, then name/href, then kind, and truncates', () => {
|
|
94
|
+
assert.equal(labelOf(desc({ label: ' Buy now ' })), 'Buy now');
|
|
95
|
+
assert.equal(labelOf(desc({ label: '', name: 'email', tag: 'input', type: 'email' })), 'email');
|
|
96
|
+
assert.equal(labelOf(desc({ label: '', name: '', tag: 'a', href: '/x' })), '/x');
|
|
97
|
+
const long = 'x'.repeat(80);
|
|
98
|
+
const truncated = labelOf(desc({ label: long }));
|
|
99
|
+
assert.ok(truncated.length <= 60);
|
|
100
|
+
assert.ok(truncated.endsWith('…'));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('scoreElement rewards visible, labelled, primary entry points over noise', () => {
|
|
104
|
+
const primaryNav = desc({ tag: 'a', href: '/dashboard', label: 'Dashboard', selector: 'a#nav-dash' });
|
|
105
|
+
const hiddenThing = desc({ visible: false, label: '' });
|
|
106
|
+
const tinyIcon = desc({ rect: [0, 0, 2, 2], label: '' });
|
|
107
|
+
assert.ok(scoreElement(primaryNav) > scoreElement(hiddenThing));
|
|
108
|
+
assert.ok(scoreElement(primaryNav) > scoreElement(tinyIcon));
|
|
109
|
+
// unknown / non-actionable sinks to -Infinity
|
|
110
|
+
assert.equal(scoreElement(desc({ tag: 'span', role: '' })), -Infinity);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('rankElements classifies, de-dupes by selector and sorts by score', () => {
|
|
114
|
+
const input = [
|
|
115
|
+
desc({ tag: 'a', href: '/dashboard', label: 'Dashboard', selector: 'a#nav-dash' }),
|
|
116
|
+
desc({ tag: 'a', href: '/dashboard', label: 'Dashboard', selector: 'a#nav-dash' }), // dup
|
|
117
|
+
desc({ tag: 'span', role: '', selector: 'span.x' }), // non-actionable, dropped
|
|
118
|
+
desc({ tag: 'button', label: 'OK', selector: 'button.ok' }),
|
|
119
|
+
];
|
|
120
|
+
const ranked = rankElements(input, { limit: 10 });
|
|
121
|
+
assert.equal(ranked.length, 2); // dup collapsed, non-actionable removed
|
|
122
|
+
assert.ok(ranked.every(r => 'kind' in r && 'actionType' in r && 'destructive' in r));
|
|
123
|
+
// sorted descending by score
|
|
124
|
+
for (let i = 1; i < ranked.length; i++) assert.ok(ranked[i - 1].score >= ranked[i].score);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('partitionSafe holds back destructive actions unless allowlisted', () => {
|
|
128
|
+
const ranked = rankElements([
|
|
129
|
+
desc({ label: 'Pricing', tag: 'a', href: '/pricing', selector: 'a#price' }),
|
|
130
|
+
desc({ label: 'Delete', selector: 'button#del' }),
|
|
131
|
+
]);
|
|
132
|
+
const { safe, skipped } = partitionSafe(ranked);
|
|
133
|
+
assert.deepEqual(safe.map(s => s.selector), ['a#price']);
|
|
134
|
+
assert.deepEqual(skipped.map(s => s.selector), ['button#del']);
|
|
135
|
+
|
|
136
|
+
const allowed = partitionSafe(ranked, { allow: ['button#del'] });
|
|
137
|
+
assert.equal(allowed.skipped.length, 0);
|
|
138
|
+
assert.equal(allowed.safe.length, 2);
|
|
139
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { syntheticValue, synthesizePaths } from './paths.js';
|
|
4
|
+
|
|
5
|
+
test('syntheticValue derives plausible values from type and name hints', () => {
|
|
6
|
+
assert.equal(syntheticValue({ type: 'email' }), 'test@example.com');
|
|
7
|
+
assert.equal(syntheticValue({ type: 'text', name: 'userEmail' }), 'test@example.com');
|
|
8
|
+
assert.equal(syntheticValue({ type: 'password' }), 'Password123!');
|
|
9
|
+
assert.equal(syntheticValue({ type: 'search' }), 'test');
|
|
10
|
+
assert.equal(syntheticValue({ type: 'date' }), '2024-01-01');
|
|
11
|
+
assert.equal(syntheticValue({ type: 'tel' }), '5551234567');
|
|
12
|
+
assert.equal(syntheticValue({ type: 'text', name: 'fullName' }), 'Test User');
|
|
13
|
+
assert.equal(syntheticValue({ type: 'text' }), 'test');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const el = (over) => ({
|
|
17
|
+
kind: 'button',
|
|
18
|
+
selector: 'button',
|
|
19
|
+
name: 'x',
|
|
20
|
+
formIndex: -1,
|
|
21
|
+
type: '',
|
|
22
|
+
...over,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('synthesizePaths builds a fill+submit journey for a form', () => {
|
|
26
|
+
const safe = [
|
|
27
|
+
el({ kind: 'text-input', type: 'email', selector: '#email', name: 'email', formIndex: 0 }),
|
|
28
|
+
el({ kind: 'text-input', type: 'password', selector: '#pw', name: 'password', formIndex: 0 }),
|
|
29
|
+
el({ kind: 'toggle', selector: '#remember', name: 'remember', formIndex: 0 }),
|
|
30
|
+
el({ kind: 'submit', selector: '#login', name: 'Log in', formIndex: 0 }),
|
|
31
|
+
];
|
|
32
|
+
const [form] = synthesizePaths({ safe });
|
|
33
|
+
assert.equal(form.kind, 'form');
|
|
34
|
+
const actions = form.steps.map(s => s.action);
|
|
35
|
+
assert.deepEqual(actions, ['fill', 'fill', 'choose', 'submit']);
|
|
36
|
+
assert.equal(form.steps[0].value, 'test@example.com');
|
|
37
|
+
assert.equal(form.steps[1].value, 'Password123!');
|
|
38
|
+
assert.match(form.title, /submit/i);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('a form with no safe submit is filled but not submitted', () => {
|
|
42
|
+
const safe = [
|
|
43
|
+
el({ kind: 'text-input', type: 'search', selector: '#q', name: 'q', formIndex: 0 }),
|
|
44
|
+
];
|
|
45
|
+
const [form] = synthesizePaths({ safe });
|
|
46
|
+
assert.deepEqual(form.steps.map(s => s.action), ['fill']);
|
|
47
|
+
assert.match(form.title, /^Fill the/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('synthesizePaths makes navigation and click journeys and honors the limit', () => {
|
|
51
|
+
const safe = [
|
|
52
|
+
el({ kind: 'nav-link', selector: 'a#a', name: 'Home' }),
|
|
53
|
+
el({ kind: 'nav-link', selector: 'a#b', name: 'Pricing' }),
|
|
54
|
+
el({ kind: 'button', selector: 'button#c', name: 'Open menu' }),
|
|
55
|
+
];
|
|
56
|
+
const paths = synthesizePaths({ safe }, { limit: 2 });
|
|
57
|
+
assert.equal(paths.length, 2);
|
|
58
|
+
const all = synthesizePaths({ safe });
|
|
59
|
+
assert.deepEqual(
|
|
60
|
+
all.map(p => p.kind),
|
|
61
|
+
['navigation', 'navigation', 'interaction'],
|
|
62
|
+
);
|
|
63
|
+
assert.equal(all[0].steps[0].action, 'navigate');
|
|
64
|
+
assert.equal(all[2].steps[0].action, 'click');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('forms are prioritized over navigation and lone buttons', () => {
|
|
68
|
+
const safe = [
|
|
69
|
+
el({ kind: 'nav-link', selector: 'a#a', name: 'Home' }),
|
|
70
|
+
el({ kind: 'text-input', type: 'email', selector: '#e', name: 'email', formIndex: 0 }),
|
|
71
|
+
el({ kind: 'submit', selector: '#s', name: 'Subscribe-less', formIndex: 0 }),
|
|
72
|
+
];
|
|
73
|
+
const paths = synthesizePaths({ safe });
|
|
74
|
+
assert.equal(paths[0].kind, 'form');
|
|
75
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
function readPkg(root) {
|
|
5
|
+
try {
|
|
6
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
7
|
+
return pkg;
|
|
8
|
+
} catch {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function detectFramework(root) {
|
|
14
|
+
const pkg = readPkg(root) || {};
|
|
15
|
+
const deps = Object.assign({}, pkg.dependencies, pkg.devDependencies);
|
|
16
|
+
const files = new Set(fs.readdirSync(root).map(f => f.toLowerCase()));
|
|
17
|
+
|
|
18
|
+
const result = {
|
|
19
|
+
framework: 'unknown',
|
|
20
|
+
bundler: 'unknown',
|
|
21
|
+
isSSR: false,
|
|
22
|
+
hints: [],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
if (deps['next'] || files.has('next.config.js') || fs.existsSync(path.join(root, 'pages'))) {
|
|
26
|
+
result.framework = 'next';
|
|
27
|
+
result.bundler = 'webpack';
|
|
28
|
+
result.isSSR = true;
|
|
29
|
+
result.hints.push('next');
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (deps['react'] && deps['react-dom']) {
|
|
34
|
+
result.framework = 'react';
|
|
35
|
+
result.hints.push('react');
|
|
36
|
+
}
|
|
37
|
+
if (deps['vue'] || files.has('vue.config.js')) {
|
|
38
|
+
result.framework = 'vue';
|
|
39
|
+
result.hints.push('vue');
|
|
40
|
+
}
|
|
41
|
+
if (deps['svelte'] || files.has('svelte.config.js')) {
|
|
42
|
+
result.framework = 'svelte';
|
|
43
|
+
result.hints.push('svelte');
|
|
44
|
+
}
|
|
45
|
+
if (deps['@angular/core'] || files.has('angular.json')) {
|
|
46
|
+
result.framework = 'angular';
|
|
47
|
+
result.hints.push('angular');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (files.has('vite.config.js') || files.has('vite.config.ts') || deps['vite']) {
|
|
51
|
+
result.bundler = 'vite';
|
|
52
|
+
result.hints.push('vite');
|
|
53
|
+
}
|
|
54
|
+
if (files.has('webpack.config.js') || deps['webpack']) {
|
|
55
|
+
result.bundler = 'webpack';
|
|
56
|
+
result.hints.push('webpack');
|
|
57
|
+
}
|
|
58
|
+
if (files.has('rollup.config.js') || deps['rollup']) {
|
|
59
|
+
result.bundler = 'rollup';
|
|
60
|
+
result.hints.push('rollup');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Some frameworks bundle SSR runtimes but still have client bundles.
|
|
64
|
+
if (result.framework === 'next') result.isSSR = true;
|
|
65
|
+
|
|
66
|
+
return result;
|
|
67
|
+
}
|