@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.
Files changed (99) hide show
  1. package/README.md +73 -1
  2. package/dist/agents/aider.d.ts.map +1 -1
  3. package/dist/agents/aider.js +6 -14
  4. package/dist/agents/claude.d.ts.map +1 -1
  5. package/dist/agents/claude.js +14 -0
  6. package/dist/agents/codex.d.ts.map +1 -1
  7. package/dist/agents/codex.js +10 -4
  8. package/dist/agents/cursor.d.ts.map +1 -1
  9. package/dist/agents/cursor.js +8 -17
  10. package/dist/agents/gemini.d.ts.map +1 -1
  11. package/dist/agents/gemini.js +3 -14
  12. package/dist/agents/invoke.d.ts.map +1 -1
  13. package/dist/agents/invoke.js +10 -1
  14. package/dist/agents/qwen.d.ts.map +1 -1
  15. package/dist/agents/qwen.js +3 -14
  16. package/dist/agents/shared.d.ts +28 -0
  17. package/dist/agents/shared.d.ts.map +1 -0
  18. package/dist/agents/shared.js +35 -0
  19. package/dist/agents/types.d.ts +11 -0
  20. package/dist/agents/types.d.ts.map +1 -1
  21. package/dist/mcp/sourceFence.d.ts +23 -0
  22. package/dist/mcp/sourceFence.d.ts.map +1 -0
  23. package/dist/mcp/sourceFence.js +75 -0
  24. package/dist/mcp/sourceServer.d.ts +3 -0
  25. package/dist/mcp/sourceServer.d.ts.map +1 -0
  26. package/dist/mcp/sourceServer.js +116 -0
  27. package/dist/playwright/preflight.d.ts.map +1 -1
  28. package/dist/playwright/preflight.js +6 -1
  29. package/dist/playwright/raiseWindow.d.ts.map +1 -1
  30. package/dist/playwright/raiseWindow.js +22 -3
  31. package/dist/playwright/resolveMcpConfig.d.ts +11 -0
  32. package/dist/playwright/resolveMcpConfig.d.ts.map +1 -1
  33. package/dist/playwright/resolveMcpConfig.js +17 -3
  34. package/dist/plugin-api.d.ts +7 -0
  35. package/dist/plugin-api.d.ts.map +1 -1
  36. package/dist/runSession.d.ts +42 -0
  37. package/dist/runSession.d.ts.map +1 -0
  38. package/dist/runSession.js +81 -0
  39. package/dist/service/cdpHandlers.d.ts +3 -7
  40. package/dist/service/cdpHandlers.d.ts.map +1 -1
  41. package/dist/service/cdpHandlers.js +4 -16
  42. package/dist/service/cdpHint.d.ts.map +1 -1
  43. package/dist/service/cdpHint.js +30 -14
  44. package/dist/service/conventions.d.ts +8 -0
  45. package/dist/service/conventions.d.ts.map +1 -0
  46. package/dist/service/conventions.js +42 -0
  47. package/dist/service/saveHandlers.d.ts +10 -13
  48. package/dist/service/saveHandlers.d.ts.map +1 -1
  49. package/dist/service/saveHandlers.js +9 -25
  50. package/dist/service/types.d.ts +5 -0
  51. package/dist/service/types.d.ts.map +1 -1
  52. package/dist/service.d.ts +13 -4
  53. package/dist/service.d.ts.map +1 -1
  54. package/dist/service.js +264 -148
  55. package/dist/skills/writeSkill.d.ts +12 -35
  56. package/dist/skills/writeSkill.d.ts.map +1 -1
  57. package/dist/skills/writeSkill.js +10 -166
  58. package/dist/specs/detectSharedFlows.d.ts +35 -0
  59. package/dist/specs/detectSharedFlows.d.ts.map +1 -0
  60. package/dist/specs/detectSharedFlows.js +171 -0
  61. package/dist/specs/extractPageObjects.d.ts +18 -0
  62. package/dist/specs/extractPageObjects.d.ts.map +1 -0
  63. package/dist/specs/extractPageObjects.js +98 -0
  64. package/dist/specs/generatePageObject.d.ts +29 -0
  65. package/dist/specs/generatePageObject.d.ts.map +1 -0
  66. package/dist/specs/generatePageObject.js +149 -0
  67. package/dist/specs/listSpecs.d.ts +12 -0
  68. package/dist/specs/listSpecs.d.ts.map +1 -1
  69. package/dist/specs/listSpecs.js +27 -2
  70. package/dist/specs/optimizationSuggestion.d.ts +26 -0
  71. package/dist/specs/optimizationSuggestion.d.ts.map +1 -0
  72. package/dist/specs/optimizationSuggestion.js +28 -0
  73. package/dist/specs/optimizeSpec.d.ts +42 -0
  74. package/dist/specs/optimizeSpec.d.ts.map +1 -0
  75. package/dist/specs/optimizeSpec.js +188 -0
  76. package/dist/specs/optimizeSpecWithAgent.d.ts +11 -0
  77. package/dist/specs/optimizeSpecWithAgent.d.ts.map +1 -0
  78. package/dist/specs/optimizeSpecWithAgent.js +40 -0
  79. package/dist/specs/pageObjectManifest.d.ts +20 -0
  80. package/dist/specs/pageObjectManifest.d.ts.map +1 -0
  81. package/dist/specs/pageObjectManifest.js +40 -0
  82. package/dist/specs/seeds.d.ts +36 -0
  83. package/dist/specs/seeds.d.ts.map +1 -0
  84. package/dist/specs/seeds.js +74 -0
  85. package/dist/specs/sidecar.d.ts +25 -0
  86. package/dist/specs/sidecar.d.ts.map +1 -0
  87. package/dist/specs/sidecar.js +38 -0
  88. package/dist/specs/softBatch.d.ts +14 -0
  89. package/dist/specs/softBatch.d.ts.map +1 -0
  90. package/dist/specs/softBatch.js +177 -0
  91. package/dist/specs/text.d.ts +17 -0
  92. package/dist/specs/text.d.ts.map +1 -0
  93. package/dist/specs/text.js +24 -0
  94. package/dist/specs/writeCaseCsv.d.ts.map +1 -1
  95. package/dist/specs/writeCaseCsv.js +2 -8
  96. package/dist/specs/writeSpec.d.ts +50 -0
  97. package/dist/specs/writeSpec.d.ts.map +1 -1
  98. package/dist/specs/writeSpec.js +251 -84
  99. package/package.json +5 -3
@@ -1,8 +1,15 @@
1
- export declare class SkillExistsError extends Error {
2
- readonly slug: string;
3
- readonly path: string;
4
- constructor(slug: string, path: string);
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":"AAmBA,qBAAa,gBAAiB,SAAQ,KAAK;aACb,IAAI,EAAE,MAAM;aAAkB,IAAI,EAAE,MAAM;gBAA1C,IAAI,EAAE,MAAM,EAAkB,IAAI,EAAE,MAAM;CAIvE;AAED;;;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;AAED,MAAM,WAAW,iBAAiB;IAChC;oDACgD;IAChD,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,SAAS,EAAE,CAAC;IACnB;;gEAE4D;IAC5D,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAED,wBAAsB,UAAU,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAoBnF;AA8ED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;;;GAKG;AACH,wBAAsB,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CA8BzE"}
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
- * Save a completed Hover session as a Claude Code skill.
2
+ * Captured-step type.
3
3
  *
4
- * Writes a SKILL.md under `<devRoot>/.claude/skills/<slug>/`. When the agent
5
- * is later spawned with `cwd: devRoot`, Claude Code auto-discovers the skill
6
- * and can replay it when the user describes the same task in natural language
7
- * (e.g. "run the login-and-add-todo skill").
8
- *
9
- * Two reasons this is just-good-enough for v1:
10
- * - The exact tool calls (with args) become numbered steps the agent can
11
- * replay literally. Same dev server, same selectors same outcome.
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 async function listSkills(devRoot) {
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"}