@hover-dev/core 0.14.1 → 0.16.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/aider.d.ts.map +1 -1
- package/dist/agents/aider.js +6 -14
- 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 +10 -4
- package/dist/agents/cursor.d.ts.map +1 -1
- package/dist/agents/cursor.js +8 -17
- package/dist/agents/gemini.d.ts.map +1 -1
- package/dist/agents/gemini.js +3 -14
- package/dist/agents/invoke.d.ts.map +1 -1
- package/dist/agents/invoke.js +10 -1
- package/dist/agents/qwen.d.ts.map +1 -1
- package/dist/agents/qwen.js +3 -14
- package/dist/agents/shared.d.ts +28 -0
- package/dist/agents/shared.d.ts.map +1 -0
- package/dist/agents/shared.js +35 -0
- package/dist/agents/types.d.ts +11 -0
- package/dist/agents/types.d.ts.map +1 -1
- package/dist/mcp/sourceFence.d.ts +23 -0
- package/dist/mcp/sourceFence.d.ts.map +1 -0
- package/dist/mcp/sourceFence.js +75 -0
- package/dist/mcp/sourceServer.d.ts +3 -0
- package/dist/mcp/sourceServer.d.ts.map +1 -0
- package/dist/mcp/sourceServer.js +116 -0
- package/dist/playwright/preflight.d.ts.map +1 -1
- package/dist/playwright/preflight.js +6 -1
- package/dist/playwright/raiseWindow.d.ts.map +1 -1
- package/dist/playwright/raiseWindow.js +22 -3
- package/dist/playwright/resolveMcpConfig.d.ts +11 -0
- package/dist/playwright/resolveMcpConfig.d.ts.map +1 -1
- package/dist/playwright/resolveMcpConfig.js +17 -3
- package/dist/plugin-api.d.ts +7 -0
- package/dist/plugin-api.d.ts.map +1 -1
- package/dist/runSession.d.ts +42 -0
- package/dist/runSession.d.ts.map +1 -0
- package/dist/runSession.js +81 -0
- package/dist/service/cdpHandlers.d.ts +3 -7
- package/dist/service/cdpHandlers.d.ts.map +1 -1
- package/dist/service/cdpHandlers.js +4 -16
- 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 +13 -4
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +264 -148
- 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 +188 -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/softBatch.d.ts +14 -0
- package/dist/specs/softBatch.d.ts.map +1 -0
- package/dist/specs/softBatch.js +177 -0
- package/dist/specs/text.d.ts +17 -0
- package/dist/specs/text.d.ts.map +1 -0
- package/dist/specs/text.js +24 -0
- package/dist/specs/writeCaseCsv.d.ts.map +1 -1
- package/dist/specs/writeCaseCsv.js +2 -8
- package/dist/specs/writeSpec.d.ts +50 -0
- package/dist/specs/writeSpec.d.ts.map +1 -1
- package/dist/specs/writeSpec.js +251 -84
- package/package.json +5 -3
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Captured-step type.
|
|
3
|
+
*
|
|
4
|
+
* NOTE: Save-as-Skill (writing `.claude/skills/<slug>/SKILL.md` for agent
|
|
5
|
+
* replay) was retired — `spec` + ⟳ Re-record covers intent-driven replay, and
|
|
6
|
+
* "skill" collided with Claude Code's own skills concept. All that remains
|
|
7
|
+
* here is `SkillStep`: the serialized message shape from the widget's
|
|
8
|
+
* localStorage, which the whole spec pipeline (writeSpec, sidecar, listSpecs,
|
|
9
|
+
* Page-Object extraction) consumes as `SpecStep`. The file keeps its path so
|
|
10
|
+
* the many `import { SkillStep } from '../skills/writeSkill.js'` call sites
|
|
11
|
+
* don't churn; renaming to a neutral module is a separate mechanical pass.
|
|
12
|
+
*/
|
|
6
13
|
/**
|
|
7
14
|
* Serialized message shape from the widget's localStorage. Matches the
|
|
8
15
|
* `state.messages` schema in packages/widget-bootstrap/src/widget/client.js.
|
|
@@ -17,34 +24,4 @@ export interface SkillStep {
|
|
|
17
24
|
costUsd?: number;
|
|
18
25
|
summary?: string;
|
|
19
26
|
}
|
|
20
|
-
export interface WriteSkillOptions {
|
|
21
|
-
/** Directory under which `.claude/skills/<slug>/` is created. Usually the
|
|
22
|
-
* Vite project root (`server.config.root`). */
|
|
23
|
-
devRoot: string;
|
|
24
|
-
name: string;
|
|
25
|
-
description?: string;
|
|
26
|
-
steps: SkillStep[];
|
|
27
|
-
/** If false (default), throws SkillExistsError when a skill with the same
|
|
28
|
-
* slug already exists. If true, overwrites unconditionally. The widget
|
|
29
|
-
* uses the two paths to give the user a confirm dialog. */
|
|
30
|
-
overwrite?: boolean;
|
|
31
|
-
}
|
|
32
|
-
export interface WriteSkillResult {
|
|
33
|
-
path: string;
|
|
34
|
-
slug: string;
|
|
35
|
-
}
|
|
36
|
-
export declare function writeSkill(opts: WriteSkillOptions): Promise<WriteSkillResult>;
|
|
37
|
-
export interface SkillSummary {
|
|
38
|
-
slug: string;
|
|
39
|
-
name: string;
|
|
40
|
-
description: string;
|
|
41
|
-
path: string;
|
|
42
|
-
}
|
|
43
|
-
/**
|
|
44
|
-
* List skills under <devRoot>/.claude/skills/, reading the YAML frontmatter
|
|
45
|
-
* of each SKILL.md for `name` and `description`. Malformed entries are
|
|
46
|
-
* silently skipped — better to show 9 valid skills than refuse to render
|
|
47
|
-
* because one is broken. Hand-edited skills are first-class.
|
|
48
|
-
*/
|
|
49
|
-
export declare function listSkills(devRoot: string): Promise<SkillSummary[]>;
|
|
50
27
|
//# sourceMappingURL=writeSkill.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"writeSkill.d.ts","sourceRoot":"","sources":["../../src/skills/writeSkill.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"writeSkill.d.ts","sourceRoot":"","sources":["../../src/skills/writeSkill.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH;;;GAGG;AACH,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,GAAG,QAAQ,GAAG,MAAM,GAAG,IAAI,GAAG,MAAM,CAAC;IACjD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB"}
|
|
@@ -1,169 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Captured-step type.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* - The original user prompt + AI outcome are preserved as prose. If the
|
|
13
|
-
* page changed and the literal selectors no longer apply, the agent has
|
|
14
|
-
* enough context to adapt rather than fail.
|
|
15
|
-
*/
|
|
16
|
-
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
|
17
|
-
import { existsSync } from 'node:fs';
|
|
18
|
-
import { join } from 'node:path';
|
|
19
|
-
export class SkillExistsError extends Error {
|
|
20
|
-
slug;
|
|
21
|
-
path;
|
|
22
|
-
constructor(slug, path) {
|
|
23
|
-
super(`Skill "${slug}" already exists at ${path}`);
|
|
24
|
-
this.slug = slug;
|
|
25
|
-
this.path = path;
|
|
26
|
-
this.name = 'SkillExistsError';
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
export async function writeSkill(opts) {
|
|
30
|
-
const slug = slugify(opts.name);
|
|
31
|
-
if (!slug) {
|
|
32
|
-
throw new Error('skill name must contain at least one alphanumeric character');
|
|
33
|
-
}
|
|
34
|
-
if (!opts.steps.some(s => s.kind === 'step')) {
|
|
35
|
-
throw new Error('skill must contain at least one tool_use step to replay');
|
|
36
|
-
}
|
|
37
|
-
const dir = join(opts.devRoot, '.claude', 'skills', slug);
|
|
38
|
-
const path = join(dir, 'SKILL.md');
|
|
39
|
-
if (!opts.overwrite && existsSync(path)) {
|
|
40
|
-
throw new SkillExistsError(slug, path);
|
|
41
|
-
}
|
|
42
|
-
await mkdir(dir, { recursive: true });
|
|
43
|
-
const md = renderSkill(slug, opts.description ?? '', opts.steps);
|
|
44
|
-
await writeFile(path, md, 'utf-8');
|
|
45
|
-
return { path, slug };
|
|
46
|
-
}
|
|
47
|
-
function slugify(name) {
|
|
48
|
-
return name
|
|
49
|
-
.toLowerCase()
|
|
50
|
-
.trim()
|
|
51
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
52
|
-
.replace(/^-+|-+$/g, '');
|
|
53
|
-
}
|
|
54
|
-
function renderSkill(slug, description, steps) {
|
|
55
|
-
const userMsg = steps.find(s => s.kind === 'user');
|
|
56
|
-
const doneMsg = [...steps].reverse().find(s => s.kind === 'done');
|
|
57
|
-
const toolSteps = steps.filter(s => s.kind === 'step');
|
|
58
|
-
const out = [];
|
|
59
|
-
out.push('---');
|
|
60
|
-
out.push(`name: ${slug}`);
|
|
61
|
-
// YAML description — quote if it contains anything that could confuse the parser
|
|
62
|
-
out.push(`description: ${yamlString(description || slug)}`);
|
|
63
|
-
out.push('---');
|
|
64
|
-
out.push('');
|
|
65
|
-
if (userMsg?.text) {
|
|
66
|
-
out.push('## Original intent');
|
|
67
|
-
out.push('');
|
|
68
|
-
out.push(blockquote(userMsg.text));
|
|
69
|
-
out.push('');
|
|
70
|
-
}
|
|
71
|
-
out.push('## Replay steps');
|
|
72
|
-
out.push('');
|
|
73
|
-
out.push('Replay these steps using the `mcp__playwright` tools, in order. ' +
|
|
74
|
-
'If a literal selector id (e.g. `e15`) no longer matches, interpret the ' +
|
|
75
|
-
'natural-language element description instead — selector ids regenerate on every snapshot.');
|
|
76
|
-
out.push('');
|
|
77
|
-
out.push('Do not narrate each step, do not summarize at the end. Hover surfaces ' +
|
|
78
|
-
'tool calls + the final result to the user automatically — extra commentary is noise.');
|
|
79
|
-
out.push('');
|
|
80
|
-
toolSteps.forEach((step, i) => {
|
|
81
|
-
const tool = step.tool ?? '(unknown)';
|
|
82
|
-
const inputStr = JSON.stringify(step.input ?? {});
|
|
83
|
-
const truncated = inputStr.length > 240 ? inputStr.slice(0, 237) + '…' : inputStr;
|
|
84
|
-
out.push(`${i + 1}. \`${tool}\` — \`${truncated}\``);
|
|
85
|
-
});
|
|
86
|
-
out.push('');
|
|
87
|
-
if (doneMsg?.summary) {
|
|
88
|
-
out.push('## Original outcome');
|
|
89
|
-
out.push('');
|
|
90
|
-
out.push(doneMsg.summary.trim());
|
|
91
|
-
out.push('');
|
|
92
|
-
}
|
|
93
|
-
return out.join('\n');
|
|
94
|
-
}
|
|
95
|
-
function yamlString(s) {
|
|
96
|
-
// Cheap quoting — if it has YAML-significant chars, double-quote and escape.
|
|
97
|
-
if (/[:#\n"'\\]/.test(s)) {
|
|
98
|
-
return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
99
|
-
}
|
|
100
|
-
return s;
|
|
101
|
-
}
|
|
102
|
-
function blockquote(s) {
|
|
103
|
-
return s
|
|
104
|
-
.split('\n')
|
|
105
|
-
.map(line => `> ${line}`)
|
|
106
|
-
.join('\n');
|
|
107
|
-
}
|
|
108
|
-
/**
|
|
109
|
-
* List skills under <devRoot>/.claude/skills/, reading the YAML frontmatter
|
|
110
|
-
* of each SKILL.md for `name` and `description`. Malformed entries are
|
|
111
|
-
* silently skipped — better to show 9 valid skills than refuse to render
|
|
112
|
-
* because one is broken. Hand-edited skills are first-class.
|
|
4
|
+
* NOTE: Save-as-Skill (writing `.claude/skills/<slug>/SKILL.md` for agent
|
|
5
|
+
* replay) was retired — `spec` + ⟳ Re-record covers intent-driven replay, and
|
|
6
|
+
* "skill" collided with Claude Code's own skills concept. All that remains
|
|
7
|
+
* here is `SkillStep`: the serialized message shape from the widget's
|
|
8
|
+
* localStorage, which the whole spec pipeline (writeSpec, sidecar, listSpecs,
|
|
9
|
+
* Page-Object extraction) consumes as `SpecStep`. The file keeps its path so
|
|
10
|
+
* the many `import { SkillStep } from '../skills/writeSkill.js'` call sites
|
|
11
|
+
* don't churn; renaming to a neutral module is a separate mechanical pass.
|
|
113
12
|
*/
|
|
114
|
-
export
|
|
115
|
-
const root = join(devRoot, '.claude', 'skills');
|
|
116
|
-
let entries;
|
|
117
|
-
try {
|
|
118
|
-
entries = await readdir(root);
|
|
119
|
-
}
|
|
120
|
-
catch {
|
|
121
|
-
return [];
|
|
122
|
-
}
|
|
123
|
-
const skills = [];
|
|
124
|
-
for (const slug of entries) {
|
|
125
|
-
if (slug.startsWith('.'))
|
|
126
|
-
continue;
|
|
127
|
-
const path = join(root, slug, 'SKILL.md');
|
|
128
|
-
let content;
|
|
129
|
-
try {
|
|
130
|
-
content = await readFile(path, 'utf-8');
|
|
131
|
-
}
|
|
132
|
-
catch {
|
|
133
|
-
continue;
|
|
134
|
-
}
|
|
135
|
-
const fm = parseFrontmatter(content);
|
|
136
|
-
skills.push({
|
|
137
|
-
slug,
|
|
138
|
-
name: fm.name ?? slug,
|
|
139
|
-
description: fm.description ?? '',
|
|
140
|
-
path,
|
|
141
|
-
});
|
|
142
|
-
}
|
|
143
|
-
// Sort newest-first by mtime would require fs.stat; alpha is fine and stable.
|
|
144
|
-
skills.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
145
|
-
return skills;
|
|
146
|
-
}
|
|
147
|
-
function parseFrontmatter(content) {
|
|
148
|
-
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
149
|
-
if (!match)
|
|
150
|
-
return {};
|
|
151
|
-
const out = {};
|
|
152
|
-
for (const line of match[1].split('\n')) {
|
|
153
|
-
const m = line.match(/^(\w+)\s*:\s*(.+)$/);
|
|
154
|
-
if (!m)
|
|
155
|
-
continue;
|
|
156
|
-
let value = m[2].trim();
|
|
157
|
-
// Strip wrapping quotes if present (yamlString() in renderSkill quotes
|
|
158
|
-
// strings with YAML-significant chars).
|
|
159
|
-
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
160
|
-
(value.startsWith("'") && value.endsWith("'"))) {
|
|
161
|
-
value = value
|
|
162
|
-
.slice(1, -1)
|
|
163
|
-
.replace(/\\"/g, '"')
|
|
164
|
-
.replace(/\\\\/g, '\\');
|
|
165
|
-
}
|
|
166
|
-
out[m[1]] = value;
|
|
167
|
-
}
|
|
168
|
-
return out;
|
|
169
|
-
}
|
|
13
|
+
export {};
|
|
@@ -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"}
|