@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.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -2
  3. package/bin/spectra.js +215 -0
  4. package/dist/assets/index-D0Lxrmdt.js +46 -0
  5. package/dist/assets/index-Da3Le5Px.css +1 -0
  6. package/dist/index.html +14 -0
  7. package/index.html +13 -0
  8. package/package.json +76 -4
  9. package/server/analysis/cpu.js +369 -0
  10. package/server/analysis/findings.js +570 -0
  11. package/server/analysis/locate.js +99 -0
  12. package/server/analysis/report.js +775 -0
  13. package/server/analysis/sources.js +249 -0
  14. package/server/analysis/trace.js +314 -0
  15. package/server/api.js +292 -0
  16. package/server/chrome.js +81 -0
  17. package/server/cli.js +94 -0
  18. package/server/config.js +59 -0
  19. package/server/dashboard.js +154 -0
  20. package/server/explore/boot.js +150 -0
  21. package/server/explore/classify.js +169 -0
  22. package/server/explore/discover.js +162 -0
  23. package/server/explore/evaluate-cli.js +101 -0
  24. package/server/explore/evaluate.js +165 -0
  25. package/server/explore/paths.js +105 -0
  26. package/server/explore/runner.js +94 -0
  27. package/server/explore/test-explore.js +139 -0
  28. package/server/explore/test-paths.js +75 -0
  29. package/server/framework.js +67 -0
  30. package/server/git.js +59 -0
  31. package/server/pdf/render.js +60 -0
  32. package/server/pdf/template.js +419 -0
  33. package/server/plugin.js +19 -0
  34. package/server/probe.browser.js +828 -0
  35. package/server/recorder.js +610 -0
  36. package/server/signin.js +27 -0
  37. package/server/test-analysis.js +424 -0
  38. package/server/test-compare.js +473 -0
  39. package/server/test-sources.js +136 -0
  40. package/shared/compare.js +977 -0
  41. package/shared/snapshot.js +77 -0
  42. package/src/App.jsx +361 -0
  43. package/src/api.js +51 -0
  44. package/src/components/AppearanceMenu.jsx +76 -0
  45. package/src/components/CodeRef.jsx +48 -0
  46. package/src/components/ComparePanel.jsx +143 -0
  47. package/src/components/CompareResults.jsx +473 -0
  48. package/src/components/ControlBar.jsx +336 -0
  49. package/src/components/CulpritSphere.jsx +218 -0
  50. package/src/components/Culprits.jsx +120 -0
  51. package/src/components/CulpritsHero.jsx +109 -0
  52. package/src/components/EvaluationPanel.jsx +160 -0
  53. package/src/components/FilePanel.jsx +225 -0
  54. package/src/components/FindingsList.jsx +381 -0
  55. package/src/components/HeatTrees.jsx +222 -0
  56. package/src/components/Icons.jsx +58 -0
  57. package/src/components/LongTasks.jsx +199 -0
  58. package/src/components/NetworkPanel.jsx +98 -0
  59. package/src/components/PageMap.jsx +329 -0
  60. package/src/components/Popover.jsx +51 -0
  61. package/src/components/ReactPanel.jsx +206 -0
  62. package/src/components/SidePicker.jsx +207 -0
  63. package/src/components/SnapshotButton.jsx +140 -0
  64. package/src/components/SpectraCulprits.jsx +39 -0
  65. package/src/components/TimeBreakdown.jsx +45 -0
  66. package/src/components/TraceButton.jsx +76 -0
  67. package/src/components/TreeView.jsx +173 -0
  68. package/src/components/Vitals.jsx +163 -0
  69. package/src/culprits.js +127 -0
  70. package/src/download.js +18 -0
  71. package/src/format.js +26 -0
  72. package/src/heat.js +177 -0
  73. package/src/highlight.js +45 -0
  74. package/src/hooks/useCompare.js +187 -0
  75. package/src/hooks/useSession.js +73 -0
  76. package/src/layout.js +55 -0
  77. package/src/main.jsx +15 -0
  78. package/src/styles.css +2478 -0
  79. package/src/theme.js +75 -0
  80. package/src/themes.css +286 -0
  81. 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
+ }