@hover-dev/core 0.14.1 → 0.15.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/README.md +73 -1
- package/dist/agents/claude.d.ts.map +1 -1
- package/dist/agents/claude.js +14 -0
- package/dist/agents/codex.d.ts.map +1 -1
- package/dist/agents/codex.js +1 -0
- package/dist/agents/invoke.d.ts.map +1 -1
- package/dist/agents/invoke.js +10 -1
- package/dist/agents/types.d.ts +11 -0
- package/dist/agents/types.d.ts.map +1 -1
- package/dist/playwright/resolveMcpConfig.d.ts +5 -0
- package/dist/playwright/resolveMcpConfig.d.ts.map +1 -1
- package/dist/playwright/resolveMcpConfig.js +2 -1
- package/dist/runSession.d.ts +42 -0
- package/dist/runSession.d.ts.map +1 -0
- package/dist/runSession.js +76 -0
- package/dist/service/cdpHint.d.ts.map +1 -1
- package/dist/service/cdpHint.js +30 -14
- package/dist/service/conventions.d.ts +8 -0
- package/dist/service/conventions.d.ts.map +1 -0
- package/dist/service/conventions.js +42 -0
- package/dist/service/saveHandlers.d.ts +10 -13
- package/dist/service/saveHandlers.d.ts.map +1 -1
- package/dist/service/saveHandlers.js +9 -25
- package/dist/service/types.d.ts +5 -0
- package/dist/service/types.d.ts.map +1 -1
- package/dist/service.d.ts +7 -4
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +141 -104
- package/dist/skills/writeSkill.d.ts +12 -35
- package/dist/skills/writeSkill.d.ts.map +1 -1
- package/dist/skills/writeSkill.js +10 -166
- package/dist/specs/detectSharedFlows.d.ts +35 -0
- package/dist/specs/detectSharedFlows.d.ts.map +1 -0
- package/dist/specs/detectSharedFlows.js +171 -0
- package/dist/specs/extractPageObjects.d.ts +18 -0
- package/dist/specs/extractPageObjects.d.ts.map +1 -0
- package/dist/specs/extractPageObjects.js +98 -0
- package/dist/specs/generatePageObject.d.ts +29 -0
- package/dist/specs/generatePageObject.d.ts.map +1 -0
- package/dist/specs/generatePageObject.js +149 -0
- package/dist/specs/listSpecs.d.ts +12 -0
- package/dist/specs/listSpecs.d.ts.map +1 -1
- package/dist/specs/listSpecs.js +27 -2
- package/dist/specs/optimizationSuggestion.d.ts +26 -0
- package/dist/specs/optimizationSuggestion.d.ts.map +1 -0
- package/dist/specs/optimizationSuggestion.js +28 -0
- package/dist/specs/optimizeSpec.d.ts +42 -0
- package/dist/specs/optimizeSpec.d.ts.map +1 -0
- package/dist/specs/optimizeSpec.js +166 -0
- package/dist/specs/optimizeSpecWithAgent.d.ts +11 -0
- package/dist/specs/optimizeSpecWithAgent.d.ts.map +1 -0
- package/dist/specs/optimizeSpecWithAgent.js +40 -0
- package/dist/specs/pageObjectManifest.d.ts +20 -0
- package/dist/specs/pageObjectManifest.d.ts.map +1 -0
- package/dist/specs/pageObjectManifest.js +40 -0
- package/dist/specs/seeds.d.ts +36 -0
- package/dist/specs/seeds.d.ts.map +1 -0
- package/dist/specs/seeds.js +74 -0
- package/dist/specs/sidecar.d.ts +25 -0
- package/dist/specs/sidecar.d.ts.map +1 -0
- package/dist/specs/sidecar.js +38 -0
- package/dist/specs/writeSpec.d.ts +50 -0
- package/dist/specs/writeSpec.d.ts.map +1 -1
- package/dist/specs/writeSpec.js +249 -75
- package/package.json +1 -2
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { SkillStep } from '../skills/writeSkill.js';
|
|
2
|
+
export interface SharedFlow {
|
|
3
|
+
/** The shared signature prefix, one entry per step. */
|
|
4
|
+
signatures: string[];
|
|
5
|
+
/** Human-readable prose for each prefix step (from one representative spec),
|
|
6
|
+
* for display in the widget / CLI. */
|
|
7
|
+
prose: string[];
|
|
8
|
+
/** Slugs of the specs that share this prefix, sorted. */
|
|
9
|
+
specs: string[];
|
|
10
|
+
/** The representative spec's original steps for the shared prefix, fed to
|
|
11
|
+
* generatePageObject during Stage 3 extraction. */
|
|
12
|
+
prefixSteps: SkillStep[];
|
|
13
|
+
}
|
|
14
|
+
export interface DetectOptions {
|
|
15
|
+
/** Minimum number of specs that must share the prefix to report it.
|
|
16
|
+
* Default 2 (surface candidates early); Stage 3 extraction uses 3. */
|
|
17
|
+
minSpecs?: number;
|
|
18
|
+
/** Minimum prefix length (in steps) worth reporting. Default 2 — a single
|
|
19
|
+
* shared navigation is too weak to be a flow. */
|
|
20
|
+
minLen?: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Reduce one captured step to a signature string: the tool plus its structural
|
|
24
|
+
* target, with data values stripped. Returns null for steps that aren't part
|
|
25
|
+
* of a replayable flow (diagnostics, tab switches, waits) so they don't anchor
|
|
26
|
+
* or break a prefix.
|
|
27
|
+
*/
|
|
28
|
+
export declare function stepSignature(tool: string, rawInput: unknown): string | null;
|
|
29
|
+
/**
|
|
30
|
+
* Detect flows shared as a common prefix across saved specs. Groups specs by
|
|
31
|
+
* their first step's signature (the entry move), then reports each group's
|
|
32
|
+
* longest common prefix that ≥ minSpecs specs share and is ≥ minLen steps long.
|
|
33
|
+
*/
|
|
34
|
+
export declare function detectSharedFlows(devRoot: string, opts?: DetectOptions): Promise<SharedFlow[]>;
|
|
35
|
+
//# sourceMappingURL=detectSharedFlows.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"detectSharedFlows.d.ts","sourceRoot":"","sources":["../../src/specs/detectSharedFlows.ts"],"names":[],"mappings":"AAuBA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;AAEzD,MAAM,WAAW,UAAU;IACzB,uDAAuD;IACvD,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB;2CACuC;IACvC,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,yDAAyD;IACzD,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB;wDACoD;IACpD,WAAW,EAAE,SAAS,EAAE,CAAC;CAC1B;AAED,MAAM,WAAW,aAAa;IAC5B;2EACuE;IACvE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;sDACkD;IAClD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAgC5E;AAuDD;;;;GAIG;AACH,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,MAAM,EACf,IAAI,GAAE,aAAkB,GACvB,OAAO,CAAC,UAAU,EAAE,CAAC,CAiCvB"}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stage 2 of structured spec output: detect flows repeated across saved specs.
|
|
3
|
+
*
|
|
4
|
+
* Reads the `.hover/<slug>.json` sidecars (Stage 1), normalizes each captured
|
|
5
|
+
* step to a **signature** that keeps structure (tool + target) and drops data
|
|
6
|
+
* values, then reports the shared *prefix* across specs. This is read-only —
|
|
7
|
+
* it surfaces "these N specs all start by logging in" to the widget / CLI. It
|
|
8
|
+
* does NOT generate Page Objects; that is Stage 3 (F4), which consumes this.
|
|
9
|
+
*
|
|
10
|
+
* Why prefixes only (not arbitrary common subsequences): D5 — the dominant
|
|
11
|
+
* real case is many specs sharing an entry flow (login / navigate-to-X), and
|
|
12
|
+
* prefix detection is near-zero false positives and cheap. Arbitrary fragments
|
|
13
|
+
* are a later iteration.
|
|
14
|
+
*
|
|
15
|
+
* Why signatures (not parsing generated code): the sidecar already holds the
|
|
16
|
+
* structured `SpecStep[]`. Two sessions that both "click Sign in" produce the
|
|
17
|
+
* same signature even though their typed values differ — value vs. structure
|
|
18
|
+
* separation is mechanical (D4).
|
|
19
|
+
*/
|
|
20
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import { sidecarDir } from './sidecar.js';
|
|
23
|
+
import { humanStep } from './humanSteps.js';
|
|
24
|
+
/**
|
|
25
|
+
* Reduce one captured step to a signature string: the tool plus its structural
|
|
26
|
+
* target, with data values stripped. Returns null for steps that aren't part
|
|
27
|
+
* of a replayable flow (diagnostics, tab switches, waits) so they don't anchor
|
|
28
|
+
* or break a prefix.
|
|
29
|
+
*/
|
|
30
|
+
export function stepSignature(tool, rawInput) {
|
|
31
|
+
const i = (rawInput ?? {});
|
|
32
|
+
switch (tool) {
|
|
33
|
+
case 'browser_navigate':
|
|
34
|
+
return `navigate:${stripPath(String(i.url ?? ''))}`;
|
|
35
|
+
case 'browser_click':
|
|
36
|
+
return `click:${normElement(i.element)}`;
|
|
37
|
+
case 'browser_double_click':
|
|
38
|
+
return `dblclick:${normElement(i.element)}`;
|
|
39
|
+
case 'browser_hover':
|
|
40
|
+
return `hover:${normElement(i.element)}`;
|
|
41
|
+
case 'browser_type':
|
|
42
|
+
// The typed text is data — only the target field is structure.
|
|
43
|
+
return `type:${normElement(i.element)}`;
|
|
44
|
+
case 'browser_select_option':
|
|
45
|
+
return `select:${normElement(i.element)}`;
|
|
46
|
+
case 'browser_fill_form': {
|
|
47
|
+
// Field names are structure; their values are data. Sort so field order
|
|
48
|
+
// doesn't change the signature.
|
|
49
|
+
const fields = i.fields ?? [];
|
|
50
|
+
const names = fields
|
|
51
|
+
.map(f => normElement(f.name ?? f.element))
|
|
52
|
+
.filter(Boolean)
|
|
53
|
+
.sort();
|
|
54
|
+
return `fill:${names.join(',')}`;
|
|
55
|
+
}
|
|
56
|
+
case 'browser_press_key':
|
|
57
|
+
return `press:${String(i.key ?? '')}`;
|
|
58
|
+
default:
|
|
59
|
+
// Diagnostics / browser_tabs / browser_wait_for — not flow structure.
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Read and parse every sidecar under `.hover/`. Malformed files are skipped
|
|
64
|
+
* (better to detect across the valid ones than fail because one is broken). */
|
|
65
|
+
async function readSidecars(devRoot) {
|
|
66
|
+
const dir = sidecarDir(devRoot);
|
|
67
|
+
let entries;
|
|
68
|
+
try {
|
|
69
|
+
entries = await readdir(dir);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
const out = [];
|
|
75
|
+
for (const entry of entries) {
|
|
76
|
+
if (!entry.endsWith('.json'))
|
|
77
|
+
continue;
|
|
78
|
+
try {
|
|
79
|
+
const sc = JSON.parse(await readFile(join(dir, entry), 'utf-8'));
|
|
80
|
+
if (Array.isArray(sc.steps) && typeof sc.slug === 'string')
|
|
81
|
+
out.push(sc);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// skip malformed sidecar
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
/** Project a sidecar's steps to (signature, prose) lists, dropping
|
|
90
|
+
* non-flow steps. */
|
|
91
|
+
function signatureSeq(sc) {
|
|
92
|
+
const sigs = [];
|
|
93
|
+
const prose = [];
|
|
94
|
+
const steps = [];
|
|
95
|
+
for (const s of sc.steps) {
|
|
96
|
+
if (s.kind !== 'step' || !s.tool)
|
|
97
|
+
continue;
|
|
98
|
+
const sig = stepSignature(s.tool, s.input);
|
|
99
|
+
if (sig == null)
|
|
100
|
+
continue;
|
|
101
|
+
sigs.push(sig);
|
|
102
|
+
prose.push(humanStep(s.tool, s.input) ?? s.tool);
|
|
103
|
+
steps.push(s);
|
|
104
|
+
}
|
|
105
|
+
return { slug: sc.slug, sigs, prose, steps };
|
|
106
|
+
}
|
|
107
|
+
function longestCommonPrefixLen(seqs) {
|
|
108
|
+
if (seqs.length === 0)
|
|
109
|
+
return 0;
|
|
110
|
+
const minL = Math.min(...seqs.map(s => s.length));
|
|
111
|
+
let i = 0;
|
|
112
|
+
for (; i < minL; i++) {
|
|
113
|
+
const v = seqs[0][i];
|
|
114
|
+
if (!seqs.every(s => s[i] === v))
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
return i;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Detect flows shared as a common prefix across saved specs. Groups specs by
|
|
121
|
+
* their first step's signature (the entry move), then reports each group's
|
|
122
|
+
* longest common prefix that ≥ minSpecs specs share and is ≥ minLen steps long.
|
|
123
|
+
*/
|
|
124
|
+
export async function detectSharedFlows(devRoot, opts = {}) {
|
|
125
|
+
const minSpecs = opts.minSpecs ?? 2;
|
|
126
|
+
const minLen = opts.minLen ?? 2;
|
|
127
|
+
const seqs = (await readSidecars(devRoot))
|
|
128
|
+
.map(signatureSeq)
|
|
129
|
+
.filter(s => s.sigs.length > 0);
|
|
130
|
+
// Group by first signature — the dominant case is many specs that all start
|
|
131
|
+
// with the same entry flow (login / navigate-to-X).
|
|
132
|
+
const groups = new Map();
|
|
133
|
+
for (const s of seqs) {
|
|
134
|
+
const key = s.sigs[0];
|
|
135
|
+
const arr = groups.get(key);
|
|
136
|
+
if (arr)
|
|
137
|
+
arr.push(s);
|
|
138
|
+
else
|
|
139
|
+
groups.set(key, [s]);
|
|
140
|
+
}
|
|
141
|
+
const flows = [];
|
|
142
|
+
for (const group of groups.values()) {
|
|
143
|
+
if (group.length < minSpecs)
|
|
144
|
+
continue;
|
|
145
|
+
const lcp = longestCommonPrefixLen(group.map(g => g.sigs));
|
|
146
|
+
if (lcp < minLen)
|
|
147
|
+
continue;
|
|
148
|
+
flows.push({
|
|
149
|
+
signatures: group[0].sigs.slice(0, lcp),
|
|
150
|
+
prose: group[0].prose.slice(0, lcp),
|
|
151
|
+
specs: group.map(g => g.slug).sort(),
|
|
152
|
+
prefixSteps: group[0].steps.slice(0, lcp),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
// Longest shared prefix first — the richest extraction candidate on top.
|
|
156
|
+
flows.sort((a, b) => b.signatures.length - a.signatures.length);
|
|
157
|
+
return flows;
|
|
158
|
+
}
|
|
159
|
+
function normElement(raw) {
|
|
160
|
+
return String(raw ?? '').trim().replace(/\s+/g, ' ');
|
|
161
|
+
}
|
|
162
|
+
function stripPath(url) {
|
|
163
|
+
if (!/^https?:\/\//.test(url))
|
|
164
|
+
return url;
|
|
165
|
+
try {
|
|
166
|
+
return new URL(url).pathname || '/';
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return url;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface ExtractedPage {
|
|
2
|
+
className: string;
|
|
3
|
+
methodName: string;
|
|
4
|
+
fileName: string;
|
|
5
|
+
/** Absolute path written. */
|
|
6
|
+
path: string;
|
|
7
|
+
/** Slugs of the specs that share this flow. */
|
|
8
|
+
specs: string[];
|
|
9
|
+
}
|
|
10
|
+
export interface ExtractResult {
|
|
11
|
+
pages: ExtractedPage[];
|
|
12
|
+
/** Absolute path of the written fixtures.ts, or null when nothing extracted. */
|
|
13
|
+
fixturesPath: string | null;
|
|
14
|
+
}
|
|
15
|
+
export declare function extractPageObjects(devRoot: string, opts?: {
|
|
16
|
+
minSpecs?: number;
|
|
17
|
+
}): Promise<ExtractResult>;
|
|
18
|
+
//# sourceMappingURL=extractPageObjects.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"extractPageObjects.d.ts","sourceRoot":"","sources":["../../src/specs/extractPageObjects.ts"],"names":[],"mappings":"AAoBA,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,6BAA6B;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,+CAA+C;IAC/C,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,aAAa,EAAE,CAAC;IACvB,gFAAgF;IAChF,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7B;AAED,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,MAAM,EACf,IAAI,GAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAO,GAC/B,OAAO,CAAC,aAAa,CAAC,CA4CxB"}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stage 3 (F4): extract Page Objects + a fixtures entry point from flows shared
|
|
3
|
+
* across saved specs.
|
|
4
|
+
*
|
|
5
|
+
* Reads detectSharedFlows (>= 3 specs sharing an entry prefix — the scaffold's
|
|
6
|
+
* 3-use threshold), generates a `pages/<Name>.ts` per flow, and (re)writes a
|
|
7
|
+
* single `fixtures.ts` that registers each Page Object via `base.extend`. New
|
|
8
|
+
* specs `import { test, expect } from './fixtures'` and consume e.g.
|
|
9
|
+
* `async ({ page, loginPage }) => …`.
|
|
10
|
+
*
|
|
11
|
+
* Manual trigger (Stage 3b): invoked by a CLI command, not on every save, and
|
|
12
|
+
* it never rewrites already-committed specs (D6) — it only emits the shared
|
|
13
|
+
* pages/ + fixtures.ts going forward.
|
|
14
|
+
*/
|
|
15
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { detectSharedFlows } from './detectSharedFlows.js';
|
|
18
|
+
import { generatePageObject } from './generatePageObject.js';
|
|
19
|
+
import { writePageObjectManifest } from './pageObjectManifest.js';
|
|
20
|
+
export async function extractPageObjects(devRoot, opts = {}) {
|
|
21
|
+
// 3-use threshold for extraction; lower thresholds only *report* (Stage 2).
|
|
22
|
+
const flows = await detectSharedFlows(devRoot, { minSpecs: opts.minSpecs ?? 3 });
|
|
23
|
+
if (flows.length === 0)
|
|
24
|
+
return { pages: [], fixturesPath: null };
|
|
25
|
+
const testsDir = join(devRoot, '__vibe_tests__');
|
|
26
|
+
const pagesDir = join(testsDir, 'pages');
|
|
27
|
+
await mkdir(pagesDir, { recursive: true });
|
|
28
|
+
const pages = [];
|
|
29
|
+
const entries = [];
|
|
30
|
+
const usedNames = new Set();
|
|
31
|
+
for (const flow of flows) {
|
|
32
|
+
const probe = generatePageObject(flow.prefixSteps);
|
|
33
|
+
const className = uniqueName(probe.className, usedNames);
|
|
34
|
+
const po = className === probe.className
|
|
35
|
+
? probe
|
|
36
|
+
: generatePageObject(flow.prefixSteps, { className });
|
|
37
|
+
const path = join(pagesDir, po.fileName);
|
|
38
|
+
await writeFile(path, po.source, 'utf-8');
|
|
39
|
+
pages.push({
|
|
40
|
+
className: po.className,
|
|
41
|
+
methodName: po.methodName,
|
|
42
|
+
fileName: po.fileName,
|
|
43
|
+
path,
|
|
44
|
+
specs: flow.specs,
|
|
45
|
+
});
|
|
46
|
+
entries.push({
|
|
47
|
+
className: po.className,
|
|
48
|
+
methodName: po.methodName,
|
|
49
|
+
fixtureName: fixtureName(po.className),
|
|
50
|
+
fileName: po.fileName,
|
|
51
|
+
signatures: flow.signatures,
|
|
52
|
+
specs: flow.specs,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
const fixturesPath = join(testsDir, 'fixtures.ts');
|
|
56
|
+
await writeFile(fixturesPath, renderFixtures(pages), 'utf-8');
|
|
57
|
+
// Manifest lets writeSpec match a new spec's prefix to a Page Object and
|
|
58
|
+
// consume it (Stage 3c) without re-running detection.
|
|
59
|
+
await writePageObjectManifest(devRoot, entries);
|
|
60
|
+
return { pages, fixturesPath };
|
|
61
|
+
}
|
|
62
|
+
function renderFixtures(pages) {
|
|
63
|
+
const lines = [];
|
|
64
|
+
lines.push(`import { test as base } from '@playwright/test';`);
|
|
65
|
+
for (const p of pages) {
|
|
66
|
+
lines.push(`import { ${p.className} } from './pages/${p.className}';`);
|
|
67
|
+
}
|
|
68
|
+
lines.push('');
|
|
69
|
+
lines.push('/**');
|
|
70
|
+
lines.push(' * Generated by Hover — Page Object fixtures lifted from flows shared');
|
|
71
|
+
lines.push(" * across specs. In a new spec: `import { test, expect } from './fixtures';`");
|
|
72
|
+
lines.push(' * then consume e.g. `async ({ page, loginPage }) => …`.');
|
|
73
|
+
lines.push(' */');
|
|
74
|
+
const typeMembers = pages.map(p => `${fixtureName(p.className)}: ${p.className}`).join('; ');
|
|
75
|
+
lines.push(`export const test = base.extend<{ ${typeMembers} }>({`);
|
|
76
|
+
for (const p of pages) {
|
|
77
|
+
lines.push(` ${fixtureName(p.className)}: async ({ page }, use) => {`);
|
|
78
|
+
lines.push(` await use(new ${p.className}(page));`);
|
|
79
|
+
lines.push(` },`);
|
|
80
|
+
}
|
|
81
|
+
lines.push(`});`);
|
|
82
|
+
lines.push('');
|
|
83
|
+
lines.push(`export { expect } from '@playwright/test';`);
|
|
84
|
+
lines.push('');
|
|
85
|
+
return lines.join('\n');
|
|
86
|
+
}
|
|
87
|
+
/** Class name -> fixture key: LoginPage -> loginPage. */
|
|
88
|
+
function fixtureName(className) {
|
|
89
|
+
return className.charAt(0).toLowerCase() + className.slice(1);
|
|
90
|
+
}
|
|
91
|
+
function uniqueName(base, used) {
|
|
92
|
+
let name = base;
|
|
93
|
+
let n = 2;
|
|
94
|
+
while (used.has(name))
|
|
95
|
+
name = `${base}${n++}`;
|
|
96
|
+
used.add(name);
|
|
97
|
+
return name;
|
|
98
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stage 3 (F4): generate a Page Object class from a flow shared across specs.
|
|
3
|
+
*
|
|
4
|
+
* Given the captured steps of a shared prefix (from detectSharedFlows), emit a
|
|
5
|
+
* `pages/<Name>.ts` class whose single method replays the flow. Per D4,
|
|
6
|
+
* structure becomes the method (selectors centralized here) and data values
|
|
7
|
+
* become method parameters. Naming follows D7: the entry navigation's last path
|
|
8
|
+
* segment (`/login` -> `LoginPage.login`), falling back to `FlowPage.run`.
|
|
9
|
+
*
|
|
10
|
+
* Deterministic, no LLM. Reuses writeSpec's selector + visibility-prelude
|
|
11
|
+
* emitters with a `this.page` page variable, so a Page Object's selectors match
|
|
12
|
+
* the crystallized specs exactly.
|
|
13
|
+
*/
|
|
14
|
+
import type { SkillStep } from '../skills/writeSkill.js';
|
|
15
|
+
export interface PageObjectResult {
|
|
16
|
+
/** PascalCase class name, e.g. `LoginPage`. */
|
|
17
|
+
className: string;
|
|
18
|
+
/** camelCase method name, e.g. `login`. */
|
|
19
|
+
methodName: string;
|
|
20
|
+
/** File to write under `pages/`, e.g. `LoginPage.ts`. */
|
|
21
|
+
fileName: string;
|
|
22
|
+
/** Generated TypeScript source. */
|
|
23
|
+
source: string;
|
|
24
|
+
}
|
|
25
|
+
export declare function generatePageObject(steps: SkillStep[], override?: {
|
|
26
|
+
className?: string;
|
|
27
|
+
methodName?: string;
|
|
28
|
+
}): PageObjectResult;
|
|
29
|
+
//# sourceMappingURL=generatePageObject.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"generatePageObject.d.ts","sourceRoot":"","sources":["../../src/specs/generatePageObject.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;AAUzD,MAAM,WAAW,gBAAgB;IAC/B,+CAA+C;IAC/C,SAAS,EAAE,MAAM,CAAC;IAClB,2CAA2C;IAC3C,UAAU,EAAE,MAAM,CAAC;IACnB,yDAAyD;IACzD,QAAQ,EAAE,MAAM,CAAC;IACjB,mCAAmC;IACnC,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,SAAS,EAAE,EAClB,QAAQ,GAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAAO,GACzD,gBAAgB,CAoElB"}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { selectorFromDescription, selectorForFormField, emitInteraction, blockScope, } from './writeSpec.js';
|
|
2
|
+
const PAGE_VAR = 'this.page';
|
|
3
|
+
export function generatePageObject(steps, override = {}) {
|
|
4
|
+
const derived = deriveNames(steps);
|
|
5
|
+
const className = override.className ?? derived.className;
|
|
6
|
+
const methodName = override.methodName ?? derived.methodName;
|
|
7
|
+
const params = [];
|
|
8
|
+
const used = new Set();
|
|
9
|
+
const body = [];
|
|
10
|
+
for (const s of steps) {
|
|
11
|
+
if (s.kind !== 'step' || !s.tool)
|
|
12
|
+
continue;
|
|
13
|
+
const i = (s.input ?? {});
|
|
14
|
+
switch (s.tool) {
|
|
15
|
+
case 'browser_navigate':
|
|
16
|
+
body.push(`await ${PAGE_VAR}.goto(${JSON.stringify(navPath(String(i.url ?? '')))});`);
|
|
17
|
+
break;
|
|
18
|
+
case 'browser_click':
|
|
19
|
+
body.push(...blockScope(emitInteraction(selectorFromDescription(String(i.element ?? ''), PAGE_VAR), 'click()')));
|
|
20
|
+
break;
|
|
21
|
+
case 'browser_double_click':
|
|
22
|
+
body.push(...blockScope(emitInteraction(selectorFromDescription(String(i.element ?? ''), PAGE_VAR), 'dblclick()')));
|
|
23
|
+
break;
|
|
24
|
+
case 'browser_hover':
|
|
25
|
+
body.push(...blockScope(emitInteraction(selectorFromDescription(String(i.element ?? ''), PAGE_VAR), 'hover()')));
|
|
26
|
+
break;
|
|
27
|
+
case 'browser_type': {
|
|
28
|
+
const p = uniqueParam(paramName(String(i.element ?? '')), used);
|
|
29
|
+
params.push(p);
|
|
30
|
+
body.push(...blockScope(emitInteraction(selectorFromDescription(String(i.element ?? ''), PAGE_VAR), `fill(${p})`)));
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
case 'browser_select_option': {
|
|
34
|
+
const p = uniqueParam(paramName(String(i.element ?? '')), used);
|
|
35
|
+
params.push(p);
|
|
36
|
+
body.push(...blockScope(emitInteraction(selectorFromDescription(String(i.element ?? ''), PAGE_VAR), `selectOption(${p})`)));
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
case 'browser_fill_form': {
|
|
40
|
+
const fields = i.fields ?? [];
|
|
41
|
+
for (const f of fields) {
|
|
42
|
+
const target = String(f.name ?? f.element ?? '');
|
|
43
|
+
const p = uniqueParam(paramName(target), used);
|
|
44
|
+
params.push(p);
|
|
45
|
+
body.push(...blockScope(emitInteraction(selectorForFormField(target, f.type, PAGE_VAR), `fill(${p})`)));
|
|
46
|
+
}
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
case 'browser_press_key':
|
|
50
|
+
body.push(`await ${PAGE_VAR}.keyboard.press(${JSON.stringify(String(i.key ?? ''))});`);
|
|
51
|
+
break;
|
|
52
|
+
default:
|
|
53
|
+
break; // diagnostics / non-replayable — skipped
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const paramList = params.map(p => `${p}: string`).join(', ');
|
|
57
|
+
return {
|
|
58
|
+
className,
|
|
59
|
+
methodName,
|
|
60
|
+
fileName: `${className}.ts`,
|
|
61
|
+
source: renderClass(className, methodName, paramList, body),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function renderClass(className, methodName, paramList, body) {
|
|
65
|
+
const out = body.length > 0 ? body : ['// (no replayable steps)'];
|
|
66
|
+
const lines = [];
|
|
67
|
+
lines.push(`import { expect, type Page } from '@playwright/test';`);
|
|
68
|
+
lines.push('');
|
|
69
|
+
lines.push('/**');
|
|
70
|
+
lines.push(' * Generated by Hover from a flow shared across saved specs.');
|
|
71
|
+
lines.push(' * Selectors live here so a UI change is a one-file edit; data values are');
|
|
72
|
+
lines.push(' * method parameters. Plain Playwright — no Hover runtime.');
|
|
73
|
+
lines.push(' */');
|
|
74
|
+
lines.push(`export class ${className} {`);
|
|
75
|
+
lines.push(` constructor(private readonly page: Page) {}`);
|
|
76
|
+
lines.push('');
|
|
77
|
+
lines.push(` async ${methodName}(${paramList}): Promise<void> {`);
|
|
78
|
+
for (const b of out)
|
|
79
|
+
lines.push(` ${b}`);
|
|
80
|
+
lines.push(` }`);
|
|
81
|
+
lines.push(`}`);
|
|
82
|
+
lines.push('');
|
|
83
|
+
return lines.join('\n');
|
|
84
|
+
}
|
|
85
|
+
function deriveNames(steps) {
|
|
86
|
+
for (const s of steps) {
|
|
87
|
+
if (s.kind === 'step' && s.tool === 'browser_navigate') {
|
|
88
|
+
const seg = lastSegment(String((s.input ?? {}).url ?? ''));
|
|
89
|
+
if (seg)
|
|
90
|
+
return { className: pascal(seg) + 'Page', methodName: camel(seg) };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { className: 'FlowPage', methodName: 'run' };
|
|
94
|
+
}
|
|
95
|
+
function lastSegment(url) {
|
|
96
|
+
let path = url;
|
|
97
|
+
try {
|
|
98
|
+
if (/^https?:\/\//.test(url))
|
|
99
|
+
path = new URL(url).pathname;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* keep raw */
|
|
103
|
+
}
|
|
104
|
+
const segs = path.split('/').filter(Boolean);
|
|
105
|
+
return segs.length ? segs[segs.length - 1].replace(/[^a-zA-Z0-9]+/g, ' ').trim() : '';
|
|
106
|
+
}
|
|
107
|
+
/** Tokenize a label into words, dropping a trailing MCP role keyword. */
|
|
108
|
+
function words(raw) {
|
|
109
|
+
return raw
|
|
110
|
+
.replace(/\s+(button|link|textbox|checkbox|radio|combobox|switch|field|input)$/i, '')
|
|
111
|
+
.replace(/[^a-zA-Z0-9]+/g, ' ')
|
|
112
|
+
.trim()
|
|
113
|
+
.split(/\s+/)
|
|
114
|
+
.filter(Boolean);
|
|
115
|
+
}
|
|
116
|
+
function camel(raw) {
|
|
117
|
+
const w = words(raw);
|
|
118
|
+
if (w.length === 0)
|
|
119
|
+
return 'value';
|
|
120
|
+
const c = w[0].toLowerCase() +
|
|
121
|
+
w.slice(1).map(x => x[0].toUpperCase() + x.slice(1).toLowerCase()).join('');
|
|
122
|
+
return /^[a-zA-Z_]/.test(c) ? c : `v${c}`;
|
|
123
|
+
}
|
|
124
|
+
function pascal(raw) {
|
|
125
|
+
const c = camel(raw);
|
|
126
|
+
return c.charAt(0).toUpperCase() + c.slice(1);
|
|
127
|
+
}
|
|
128
|
+
/** Parameter name derived from a field/element label. */
|
|
129
|
+
const paramName = camel;
|
|
130
|
+
function uniqueParam(base, used) {
|
|
131
|
+
const root = base || 'value';
|
|
132
|
+
let name = root;
|
|
133
|
+
let n = 2;
|
|
134
|
+
while (used.has(name))
|
|
135
|
+
name = `${root}${n++}`;
|
|
136
|
+
used.add(name);
|
|
137
|
+
return name;
|
|
138
|
+
}
|
|
139
|
+
function navPath(url) {
|
|
140
|
+
if (!/^https?:\/\//.test(url))
|
|
141
|
+
return url;
|
|
142
|
+
try {
|
|
143
|
+
const u = new URL(url);
|
|
144
|
+
return u.pathname + u.search + u.hash || '/';
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return url;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type OptimizationSuggestion } from './optimizationSuggestion.js';
|
|
1
2
|
export interface SpecSummary {
|
|
2
3
|
/** Path-relative slug, e.g. `login-and-counter`. Identifies the spec. */
|
|
3
4
|
slug: string;
|
|
@@ -13,6 +14,17 @@ export interface SpecSummary {
|
|
|
13
14
|
stepCount: number;
|
|
14
15
|
/** File mtime in ms — used to show "saved 2 hours ago" in the UI. */
|
|
15
16
|
mtimeMs: number;
|
|
17
|
+
/** Whether a structured `.hover/<slug>.json` sidecar exists. The widget
|
|
18
|
+
* gates the optimization pass on this — without a captured session there's
|
|
19
|
+
* no observed feedback for the LLM to add assertions from. */
|
|
20
|
+
hasSidecar: boolean;
|
|
21
|
+
/** Count of `// hover:optimizable` markers the deterministic translator left
|
|
22
|
+
* — interactions it couldn't fully translate single-step. >0 is a strong
|
|
23
|
+
* signal to run the optimization pass (or add a seed). */
|
|
24
|
+
optimizableCount: number;
|
|
25
|
+
/** The default-off "review optimization?" nudge (F7/D10): suggested + reasons,
|
|
26
|
+
* derived from optimizable markers + relevant seeds. */
|
|
27
|
+
optimization: OptimizationSuggestion;
|
|
16
28
|
}
|
|
17
29
|
export interface SpecHeader {
|
|
18
30
|
/** Raw text of `Original prompt:` line, or null when absent. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"listSpecs.d.ts","sourceRoot":"","sources":["../../src/specs/listSpecs.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"listSpecs.d.ts","sourceRoot":"","sources":["../../src/specs/listSpecs.ts"],"names":[],"mappings":"AAoBA,OAAO,EAA0B,KAAK,sBAAsB,EAAE,MAAM,6BAA6B,CAAC;AAGlG,MAAM,WAAW,WAAW;IAC1B,yEAAyE;IACzE,IAAI,EAAE,MAAM,CAAC;IACb,0CAA0C;IAC1C,IAAI,EAAE,MAAM,CAAC;IACb;;qCAEiC;IACjC,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,kEAAkE;IAClE,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,4DAA4D;IAC5D,SAAS,EAAE,MAAM,CAAC;IAClB,qEAAqE;IACrE,OAAO,EAAE,MAAM,CAAC;IAChB;;mEAE+D;IAC/D,UAAU,EAAE,OAAO,CAAC;IACpB;;+DAE2D;IAC3D,gBAAgB,EAAE,MAAM,CAAC;IACzB;6DACyD;IACzD,YAAY,EAAE,sBAAsB,CAAC;CACtC;AAED,MAAM,WAAW,UAAU;IACzB,gEAAgE;IAChE,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,gCAAgC;IAChC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,oDAAoD;IACpD,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,kDAAkD;IAClD,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,UAAU,CAsB1D;AA4BD;;;;GAIG;AACH,wBAAsB,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CA2DvE"}
|
package/dist/specs/listSpecs.js
CHANGED
|
@@ -10,11 +10,15 @@
|
|
|
10
10
|
* `originalPrompt: null` — the UI / CLI surfaces that "this spec can't be
|
|
11
11
|
* re-recorded automatically; the natural-language intent isn't recorded."
|
|
12
12
|
*
|
|
13
|
-
*
|
|
13
|
+
* Shares the SpecSummary row shape the widget's Specs tab renders.
|
|
14
14
|
*/
|
|
15
15
|
import { readdir, readFile } from 'node:fs/promises';
|
|
16
16
|
import { stat } from 'node:fs/promises';
|
|
17
|
+
import { existsSync } from 'node:fs';
|
|
17
18
|
import { join } from 'node:path';
|
|
19
|
+
import { countOptimizableMarkers } from './writeSpec.js';
|
|
20
|
+
import { readSeeds, relevantSeeds } from './seeds.js';
|
|
21
|
+
import { optimizationSuggestion } from './optimizationSuggestion.js';
|
|
18
22
|
/**
|
|
19
23
|
* Parse the JSDoc header that `writeSpec.ts` emits. Tolerant of:
|
|
20
24
|
* - Specs without any JSDoc (returns all-null).
|
|
@@ -84,6 +88,8 @@ export async function listSpecs(devRoot) {
|
|
|
84
88
|
catch {
|
|
85
89
|
return [];
|
|
86
90
|
}
|
|
91
|
+
// Seeds are devRoot-wide; read once and reuse for every spec's suggestion.
|
|
92
|
+
const seeds = await readSeeds(devRoot);
|
|
87
93
|
const summaries = [];
|
|
88
94
|
for (const entry of entries) {
|
|
89
95
|
if (!entry.endsWith('.spec.ts'))
|
|
@@ -100,13 +106,32 @@ export async function listSpecs(devRoot) {
|
|
|
100
106
|
continue;
|
|
101
107
|
}
|
|
102
108
|
const header = parseSpecHeader(content);
|
|
109
|
+
const slug = entry.replace(/\.spec\.ts$/, '');
|
|
110
|
+
const sidecarPath = join(root, '.hover', `${slug}.json`);
|
|
111
|
+
const hasSidecar = existsSync(sidecarPath);
|
|
112
|
+
const optimizableCount = countOptimizableMarkers(content);
|
|
113
|
+
// Which seeds could plausibly apply, from the sidecar's captured tools.
|
|
114
|
+
let relevantSeedNames = [];
|
|
115
|
+
if (hasSidecar && seeds.length > 0) {
|
|
116
|
+
try {
|
|
117
|
+
const sc = JSON.parse(await readFile(sidecarPath, 'utf-8'));
|
|
118
|
+
const tools = new Set((sc.steps ?? []).filter(s => s.kind === 'step' && s.tool).map(s => s.tool));
|
|
119
|
+
relevantSeedNames = relevantSeeds(seeds, tools).map(s => s.name);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
/* malformed sidecar — treat as no relevant seeds */
|
|
123
|
+
}
|
|
124
|
+
}
|
|
103
125
|
summaries.push({
|
|
104
|
-
slug
|
|
126
|
+
slug,
|
|
105
127
|
path,
|
|
106
128
|
originalPrompt: header.originalPrompt,
|
|
107
129
|
outcome: header.outcome,
|
|
108
130
|
stepCount: header.steps.length,
|
|
109
131
|
mtimeMs,
|
|
132
|
+
hasSidecar,
|
|
133
|
+
optimizableCount,
|
|
134
|
+
optimization: optimizationSuggestion({ hasSidecar, optimizableCount, relevantSeedNames }),
|
|
110
135
|
});
|
|
111
136
|
}
|
|
112
137
|
summaries.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The default-off "should we nudge the user to optimize this spec?" signal
|
|
3
|
+
* (F7 / D10). Optimization never runs automatically; instead, when a saved spec
|
|
4
|
+
* has a clear improvable shape, the widget surfaces a "review optimization?"
|
|
5
|
+
* prompt. This computes that decision + human-readable reasons.
|
|
6
|
+
*
|
|
7
|
+
* Pure function so it's trivially testable; `listSpecs` gathers the inputs
|
|
8
|
+
* (optimizable-marker count, sidecar presence, relevant seed names) and attaches
|
|
9
|
+
* the result to each SpecSummary.
|
|
10
|
+
*/
|
|
11
|
+
export interface OptimizationSuggestion {
|
|
12
|
+
/** Whether to nudge the user to run the optimization pass on this spec. */
|
|
13
|
+
suggested: boolean;
|
|
14
|
+
/** Human-readable reasons, for the widget tooltip / prompt. Empty when not
|
|
15
|
+
* suggested. */
|
|
16
|
+
reasons: string[];
|
|
17
|
+
}
|
|
18
|
+
export declare function optimizationSuggestion(args: {
|
|
19
|
+
/** Whether a `.hover/<slug>.json` sidecar exists. */
|
|
20
|
+
hasSidecar: boolean;
|
|
21
|
+
/** Count of `// hover:optimizable` markers in the spec. */
|
|
22
|
+
optimizableCount: number;
|
|
23
|
+
/** Names of seeds whose signature is relevant to this spec's tools. */
|
|
24
|
+
relevantSeedNames: string[];
|
|
25
|
+
}): OptimizationSuggestion;
|
|
26
|
+
//# sourceMappingURL=optimizationSuggestion.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"optimizationSuggestion.d.ts","sourceRoot":"","sources":["../../src/specs/optimizationSuggestion.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,MAAM,WAAW,sBAAsB;IACrC,2EAA2E;IAC3E,SAAS,EAAE,OAAO,CAAC;IACnB;qBACiB;IACjB,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAED,wBAAgB,sBAAsB,CAAC,IAAI,EAAE;IAC3C,qDAAqD;IACrD,UAAU,EAAE,OAAO,CAAC;IACpB,2DAA2D;IAC3D,gBAAgB,EAAE,MAAM,CAAC;IACzB,uEAAuE;IACvE,iBAAiB,EAAE,MAAM,EAAE,CAAC;CAC7B,GAAG,sBAAsB,CAqBzB"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The default-off "should we nudge the user to optimize this spec?" signal
|
|
3
|
+
* (F7 / D10). Optimization never runs automatically; instead, when a saved spec
|
|
4
|
+
* has a clear improvable shape, the widget surfaces a "review optimization?"
|
|
5
|
+
* prompt. This computes that decision + human-readable reasons.
|
|
6
|
+
*
|
|
7
|
+
* Pure function so it's trivially testable; `listSpecs` gathers the inputs
|
|
8
|
+
* (optimizable-marker count, sidecar presence, relevant seed names) and attaches
|
|
9
|
+
* the result to each SpecSummary.
|
|
10
|
+
*/
|
|
11
|
+
export function optimizationSuggestion(args) {
|
|
12
|
+
const { hasSidecar, optimizableCount, relevantSeedNames } = args;
|
|
13
|
+
const reasons = [];
|
|
14
|
+
// The optimization pass reads the sidecar (observed feedback, captured steps);
|
|
15
|
+
// without one there's nothing to optimize from. Matches the widget's Optimize
|
|
16
|
+
// gate, so we never suggest what can't be acted on.
|
|
17
|
+
if (!hasSidecar)
|
|
18
|
+
return { suggested: false, reasons };
|
|
19
|
+
if (optimizableCount > 0) {
|
|
20
|
+
const n = optimizableCount;
|
|
21
|
+
reasons.push(`${n} interaction${n === 1 ? '' : 's'} couldn't be fully translated — the optimization pass can complete ${n === 1 ? 'it' : 'them'}`);
|
|
22
|
+
}
|
|
23
|
+
if (relevantSeedNames.length > 0) {
|
|
24
|
+
const k = relevantSeedNames.length;
|
|
25
|
+
reasons.push(`${k} seed${k === 1 ? '' : 's'} may apply: ${relevantSeedNames.join(', ')}`);
|
|
26
|
+
}
|
|
27
|
+
return { suggested: reasons.length > 0, reasons };
|
|
28
|
+
}
|