@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
@@ -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":"AAkBA,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;CACjB;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,CAkCvE"}
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"}
@@ -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
- * Mirrors the listSkills shape so widget UI can use the same row renderer.
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: entry.replace(/\.spec\.ts$/, ''),
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
+ }
@@ -0,0 +1,42 @@
1
+ import { type SpecSidecar } from './sidecar.js';
2
+ import { type SeedRule } from './seeds.js';
3
+ export declare class OptimizeError extends Error {
4
+ constructor(message: string);
5
+ }
6
+ /** Runs the codegen LLM on a prompt and returns its raw text output. */
7
+ export type RunCodegen = (prompt: string) => Promise<string>;
8
+ export interface OptimizeResult {
9
+ /** Absolute path of the written candidate (never the original spec). */
10
+ candidatePath: string;
11
+ /** The validated candidate source. */
12
+ code: string;
13
+ /** The original (deterministic) spec the candidate was generated from —
14
+ * returned so callers can show a diff without re-reading the file. */
15
+ original: string;
16
+ }
17
+ export declare function optimizeSpec(devRoot: string, slug: string, runCodegen: RunCodegen): Promise<OptimizeResult>;
18
+ /**
19
+ * Build the codegen prompt: the current spec + the observed session, plus the
20
+ * same rules the deterministic path enforces (semantic selectors, no XPath, no
21
+ * waitForTimeout, keep the test.step shape).
22
+ */
23
+ export declare function buildOptimizePrompt(draft: string, sidecar: SpecSidecar | null, seeds?: SeedRule[]): string;
24
+ /** Strip a ```ts fence if the model wrapped its output in one. */
25
+ export declare function extractCode(raw: string): string;
26
+ /**
27
+ * Code-level guardrails — the same constraints the deterministic path keeps,
28
+ * enforced on the LLM's output so an optimization can't drift off-policy. This
29
+ * is what lets us allow an LLM to author here without a markdown constitution.
30
+ */
31
+ export declare function validateSpecCode(code: string): {
32
+ ok: boolean;
33
+ errors: string[];
34
+ };
35
+ /** Promote an optimization candidate to the real spec (overwriting it) and
36
+ * remove the candidate. Returns the written spec path. The human's "Use
37
+ * optimized" / `mv` action. */
38
+ export declare function promoteOptimized(devRoot: string, slug: string): Promise<string>;
39
+ /** Discard an optimization candidate (delete the .draft, leave the spec). The
40
+ * human's "Keep original". */
41
+ export declare function discardOptimized(devRoot: string, slug: string): Promise<void>;
42
+ //# sourceMappingURL=optimizeSpec.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"optimizeSpec.d.ts","sourceRoot":"","sources":["../../src/specs/optimizeSpec.ts"],"names":[],"mappings":"AAeA,OAAO,EAAc,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AAC5D,OAAO,EAA4B,KAAK,QAAQ,EAAE,MAAM,YAAY,CAAC;AAGrE,qBAAa,aAAc,SAAQ,KAAK;gBAC1B,OAAO,EAAE,MAAM;CAI5B;AAED,wEAAwE;AACxE,MAAM,MAAM,UAAU,GAAG,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;AAE7D,MAAM,WAAW,cAAc;IAC7B,wEAAwE;IACxE,aAAa,EAAE,MAAM,CAAC;IACtB,sCAAsC;IACtC,IAAI,EAAE,MAAM,CAAC;IACb;2EACuE;IACvE,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,wBAAsB,YAAY,CAChC,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EACZ,UAAU,EAAE,UAAU,GACrB,OAAO,CAAC,cAAc,CAAC,CA2CzB;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,WAAW,GAAG,IAAI,EAC3B,KAAK,GAAE,QAAQ,EAAO,GACrB,MAAM,CAmDR;AAED,kEAAkE;AAClE,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAI/C;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,EAAE,CAAA;CAAE,CAWhF;AAyBD;;gCAEgC;AAChC,wBAAsB,gBAAgB,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAYrF;AAED;+BAC+B;AAC/B,wBAAsB,gBAAgB,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAEnF"}
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Stage 7 (F7): the optional LLM optimization pass.
3
+ *
4
+ * Reads a deterministic draft spec + its sidecar, asks an LLM (the codegen
5
+ * mode — no browser, no MCP) to improve it (chiefly: add assertions for the
6
+ * feedback the session observed), validates the result, and writes it as a
7
+ * CANDIDATE at `.hover/optimized/<slug>.spec.ts.draft` — never overwriting the
8
+ * original (D10). A human promotes or discards it via diff.
9
+ *
10
+ * The LLM call is injected (`runCodegen`) so callers wire their own agent and
11
+ * tests run deterministically without spawning anything.
12
+ */
13
+ import { readFile, mkdir, writeFile, rm } from 'node:fs/promises';
14
+ import { join } from 'node:path';
15
+ import { Project } from 'ts-morph';
16
+ import { sidecarDir } from './sidecar.js';
17
+ import { readSeeds, relevantSeeds } from './seeds.js';
18
+ import { softBatch } from './softBatch.js';
19
+ export class OptimizeError extends Error {
20
+ constructor(message) {
21
+ super(message);
22
+ this.name = 'OptimizeError';
23
+ }
24
+ }
25
+ export async function optimizeSpec(devRoot, slug, runCodegen) {
26
+ const specPath = join(devRoot, '__vibe_tests__', `${slug}.spec.ts`);
27
+ let draft;
28
+ try {
29
+ draft = await readFile(specPath, 'utf-8');
30
+ }
31
+ catch {
32
+ throw new OptimizeError(`spec not found: ${slug} (looked at ${specPath})`);
33
+ }
34
+ let sidecar = null;
35
+ try {
36
+ sidecar = JSON.parse(await readFile(join(sidecarDir(devRoot), `${slug}.json`), 'utf-8'));
37
+ }
38
+ catch {
39
+ /* no sidecar — optimize from the draft alone */
40
+ }
41
+ const specTools = new Set((sidecar?.steps ?? [])
42
+ .filter(s => s.kind === 'step' && s.tool)
43
+ .map(s => s.tool));
44
+ const seeds = relevantSeeds(await readSeeds(devRoot), specTools);
45
+ const raw = await runCodegen(buildOptimizePrompt(draft, sidecar, seeds));
46
+ const llmCode = extractCode(raw);
47
+ const check = validateSpecCode(llmCode);
48
+ if (!check.ok) {
49
+ throw new OptimizeError(`optimization rejected — ${check.errors.join('; ')}`);
50
+ }
51
+ // Deterministic finishing step: the LLM decided WHAT to assert; soft-batch
52
+ // applies the safe mechanical rewrite (trailing run of independent assertions
53
+ // → expect.soft) surgically on its output. See softBatch.ts for the guard.
54
+ const code = softBatch(llmCode).code;
55
+ const dir = join(devRoot, '__vibe_tests__', '.hover', 'optimized');
56
+ await mkdir(dir, { recursive: true });
57
+ // `.spec.ts.draft`, never `*.spec.ts` — Playwright's glob must not collect a
58
+ // candidate before a human reviews it.
59
+ const candidatePath = join(dir, `${slug}.spec.ts.draft`);
60
+ await writeFile(candidatePath, code.endsWith('\n') ? code : `${code}\n`, 'utf-8');
61
+ return { candidatePath, code, original: draft };
62
+ }
63
+ /**
64
+ * Build the codegen prompt: the current spec + the observed session, plus the
65
+ * same rules the deterministic path enforces (semantic selectors, no XPath, no
66
+ * waitForTimeout, keep the test.step shape).
67
+ */
68
+ export function buildOptimizePrompt(draft, sidecar, seeds = []) {
69
+ const done = sidecar?.steps.find(s => s.kind === 'done');
70
+ const stepsJson = sidecar
71
+ ? JSON.stringify(sidecar.steps.filter(s => s.kind === 'step'), null, 2)
72
+ : '(no sidecar captured)';
73
+ return [
74
+ `You are improving an already-correct, generated Playwright spec. You are`,
75
+ `given the current deterministic spec and the structured browser session it`,
76
+ `was crystallized from.`,
77
+ ``,
78
+ `Improve it WITHOUT changing what it tests:`,
79
+ ` - Add assertions for the success/error feedback the session OBSERVED —`,
80
+ ` e.g. await expect(page.getByText('Invalid email')).toBeVisible(), a`,
81
+ ` success toast, a counter value. Use the captured steps + the outcome`,
82
+ ` summary below to know what to assert.`,
83
+ ` - Keep semantic selectors: getByRole / getByLabel / getByText. NEVER emit`,
84
+ ` XPath or CSS-id selectors. NEVER use waitForTimeout (Playwright`,
85
+ ` auto-waits).`,
86
+ ` - Keep the existing import line and the test.step(...) structure.`,
87
+ ` - Do not invent steps the session did not perform.`,
88
+ ` - If an observed outcome looks like a BUG (it contradicts what a correct`,
89
+ ` app should do — a stale error that never clears, the wrong message, a`,
90
+ ` value that should have changed but didn't), STILL assert the observed`,
91
+ ` reality (Hover records what actually happened), but put a comment`,
92
+ ` "// KNOWN BUG: <one line>" on the line directly above that assertion so a`,
93
+ ` human can find it and so the test breaks loudly once the app is fixed.`,
94
+ ` Never silently lock buggy behavior into a normal-looking assertion.`,
95
+ ``,
96
+ `Output ONLY the complete .ts file contents — no markdown fences, no prose,`,
97
+ `no explanation.`,
98
+ ``,
99
+ `=== CURRENT SPEC ===`,
100
+ draft,
101
+ ``,
102
+ `=== OBSERVED OUTCOME ===`,
103
+ done?.summary?.trim() || '(none)',
104
+ ``,
105
+ `=== CAPTURED STEPS ===`,
106
+ stepsJson,
107
+ ...(seeds.length > 0
108
+ ? [
109
+ ``,
110
+ `=== WORKED EXAMPLES (apply a pattern ONLY if the steps genuinely match it) ===`,
111
+ ...seeds.map(s => `# ${s.name}${s.note ? ` — ${s.note}` : ''}\n` +
112
+ `WHEN steps look like: ${JSON.stringify(s.example.steps)}\n` +
113
+ `EMIT something like:\n${s.example.code}`),
114
+ ]
115
+ : []),
116
+ ].join('\n');
117
+ }
118
+ /** Strip a ```ts fence if the model wrapped its output in one. */
119
+ export function extractCode(raw) {
120
+ const t = raw.trim();
121
+ const fence = t.match(/```(?:ts|typescript|tsx|javascript|js)?\s*\n([\s\S]*?)```/);
122
+ return (fence ? fence[1] : t).trim();
123
+ }
124
+ /**
125
+ * Code-level guardrails — the same constraints the deterministic path keeps,
126
+ * enforced on the LLM's output so an optimization can't drift off-policy. This
127
+ * is what lets us allow an LLM to author here without a markdown constitution.
128
+ */
129
+ export function validateSpecCode(code) {
130
+ const errors = [];
131
+ if (!code.trim())
132
+ errors.push('empty output');
133
+ if (/\bwaitForTimeout\b/.test(code))
134
+ errors.push('uses waitForTimeout');
135
+ if (/xpath\s*=|locator\(\s*['"`]\/\//i.test(code))
136
+ errors.push('uses an XPath selector');
137
+ if (!/\btest\s*\(/.test(code))
138
+ errors.push('no test() block');
139
+ if (!/from\s+['"](@playwright\/test|\.\/fixtures)['"]/.test(code)) {
140
+ errors.push('missing @playwright/test (or ./fixtures) import');
141
+ }
142
+ if (hasSyntaxError(code))
143
+ errors.push('has a syntax error');
144
+ return { ok: errors.length === 0, errors };
145
+ }
146
+ /**
147
+ * Real syntax check via the TypeScript parser (the same ts-morph the soft-batch
148
+ * step uses). Replaces a naive `{`/`}` count that mis-flagged a valid spec
149
+ * asserting on a string like 'a { b' — braces inside string literals are not
150
+ * structural. We look at SYNTACTIC diagnostics only: a candidate references
151
+ * `page` / `expect` / `@playwright/test` that aren't resolvable in this throwaway
152
+ * project, so SEMANTIC ("cannot find module", "implicitly any") diagnostics are
153
+ * expected and must be ignored — only a genuine parse error (an unbalanced
154
+ * brace, a stray token) should reject the optimization.
155
+ */
156
+ function hasSyntaxError(code) {
157
+ const project = new Project({
158
+ useInMemoryFileSystem: true,
159
+ compilerOptions: { allowJs: true },
160
+ });
161
+ const sf = project.createSourceFile('__candidate.ts', code, { overwrite: true });
162
+ return project.getProgram().getSyntacticDiagnostics(sf).length > 0;
163
+ }
164
+ function candidatePathFor(devRoot, slug) {
165
+ return join(devRoot, '__vibe_tests__', '.hover', 'optimized', `${slug}.spec.ts.draft`);
166
+ }
167
+ /** Promote an optimization candidate to the real spec (overwriting it) and
168
+ * remove the candidate. Returns the written spec path. The human's "Use
169
+ * optimized" / `mv` action. */
170
+ export async function promoteOptimized(devRoot, slug) {
171
+ const candidate = candidatePathFor(devRoot, slug);
172
+ const specPath = join(devRoot, '__vibe_tests__', `${slug}.spec.ts`);
173
+ let code;
174
+ try {
175
+ code = await readFile(candidate, 'utf-8');
176
+ }
177
+ catch {
178
+ throw new OptimizeError(`no optimization candidate to promote for "${slug}"`);
179
+ }
180
+ await writeFile(specPath, code, 'utf-8');
181
+ await rm(candidate, { force: true });
182
+ return specPath;
183
+ }
184
+ /** Discard an optimization candidate (delete the .draft, leave the spec). The
185
+ * human's "Keep original". */
186
+ export async function discardOptimized(devRoot, slug) {
187
+ await rm(candidatePathFor(devRoot, slug), { force: true });
188
+ }
@@ -0,0 +1,11 @@
1
+ import { type OptimizeResult } from './optimizeSpec.js';
2
+ export interface OptimizeAgentOptions {
3
+ agentId: string;
4
+ model?: string;
5
+ maxBudgetUsd?: number;
6
+ /** Optional model API key, injected into the spawned CLI's env. */
7
+ apiKey?: string;
8
+ signal?: AbortSignal;
9
+ }
10
+ export declare function optimizeSpecWithAgent(devRoot: string, slug: string, opts: OptimizeAgentOptions): Promise<OptimizeResult>;
11
+ //# sourceMappingURL=optimizeSpecWithAgent.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"optimizeSpecWithAgent.d.ts","sourceRoot":"","sources":["../../src/specs/optimizeSpecWithAgent.ts"],"names":[],"mappings":"AAUA,OAAO,EAAgB,KAAK,cAAc,EAAmB,MAAM,mBAAmB,CAAC;AAEvF,MAAM,WAAW,oBAAoB;IACnC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,mEAAmE;IACnE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,wBAAsB,qBAAqB,CACzC,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,oBAAoB,GACzB,OAAO,CAAC,cAAc,CAAC,CA4BzB"}
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Wires optimizeSpec's injected codegen call to a real agent via invokeAgent,
3
+ * in "codegen mode": no MCP, no browser tools, the agent's own built-in tools
4
+ * disallowed — it just reads the prompt and emits the improved spec as text.
5
+ *
6
+ * Kept separate from optimizeSpec.ts so the core (prompt / extract / validate /
7
+ * write) stays a pure, spawn-free module that tests import directly.
8
+ */
9
+ import { invokeAgent } from '../agents/invoke.js';
10
+ import { getAgent } from '../agents/registry.js';
11
+ import { optimizeSpec } from './optimizeSpec.js';
12
+ export async function optimizeSpecWithAgent(devRoot, slug, opts) {
13
+ const descriptor = getAgent(opts.agentId);
14
+ // Codegen mode: deny the agent's built-in tools so it answers with text only;
15
+ // pass no mcpConfig / allowedTools so it never reaches a browser.
16
+ const disallowedTools = descriptor?.defaultDisallowedTools
17
+ ? [...descriptor.defaultDisallowedTools]
18
+ : undefined;
19
+ const runCodegen = async (prompt) => {
20
+ let streamed = '';
21
+ let summary = '';
22
+ for await (const ev of invokeAgent({
23
+ agentId: opts.agentId,
24
+ prompt,
25
+ model: opts.model,
26
+ maxBudgetUsd: opts.maxBudgetUsd,
27
+ apiKey: opts.apiKey,
28
+ signal: opts.signal,
29
+ disallowedTools,
30
+ })) {
31
+ if (ev.kind === 'text' && ev.text)
32
+ streamed += `${ev.text}\n`;
33
+ else if (ev.kind === 'session_end' && ev.summary)
34
+ summary = ev.summary;
35
+ }
36
+ // Prefer the final result summary; fall back to streamed text blocks.
37
+ return summary || streamed;
38
+ };
39
+ return optimizeSpec(devRoot, slug, runCodegen);
40
+ }
@@ -0,0 +1,20 @@
1
+ export declare const MANIFEST_VERSION = 1;
2
+ export interface PageObjectEntry {
3
+ className: string;
4
+ methodName: string;
5
+ /** Fixture key in fixtures.ts, e.g. `loginPage`. */
6
+ fixtureName: string;
7
+ fileName: string;
8
+ /** The signature prefix this Page Object's method replays (one per step). */
9
+ signatures: string[];
10
+ /** Slugs of the specs the Page Object was lifted from. */
11
+ specs: string[];
12
+ }
13
+ export interface PageObjectManifest {
14
+ version: number;
15
+ pages: PageObjectEntry[];
16
+ }
17
+ export declare function writePageObjectManifest(devRoot: string, pages: PageObjectEntry[]): Promise<string>;
18
+ /** Read the manifest, or null when none exists (no extraction has run). */
19
+ export declare function readPageObjectManifest(devRoot: string): Promise<PageObjectManifest | null>;
20
+ //# sourceMappingURL=pageObjectManifest.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pageObjectManifest.d.ts","sourceRoot":"","sources":["../../src/specs/pageObjectManifest.ts"],"names":[],"mappings":"AAiBA,eAAO,MAAM,gBAAgB,IAAI,CAAC;AAElC,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,oDAAoD;IACpD,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,6EAA6E;IAC7E,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,0DAA0D;IAC1D,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,eAAe,EAAE,CAAC;CAC1B;AAMD,wBAAsB,uBAAuB,CAC3C,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,eAAe,EAAE,GACvB,OAAO,CAAC,MAAM,CAAC,CAOjB;AAED,2EAA2E;AAC3E,wBAAsB,sBAAsB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC,CAQhG"}
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Page Object manifest — the link between extraction (Stage 3b) and
3
+ * consumption (Stage 3c).
4
+ *
5
+ * extractPageObjects writes `.hover/page-objects.json` describing each emitted
6
+ * Page Object: its class/method/fixture names and the signature prefix it
7
+ * replays. writeSpec reads it to decide whether a freshly-saved spec's prefix
8
+ * matches a Page Object — if so it consumes `await loginPage.login(…)` and
9
+ * imports from `./fixtures` instead of re-emitting the steps inline.
10
+ *
11
+ * Kept separate from extractPageObjects so writeSpec can read the manifest
12
+ * without importing the detection/generation chain.
13
+ */
14
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
15
+ import { join } from 'node:path';
16
+ import { sidecarDir } from './sidecar.js';
17
+ export const MANIFEST_VERSION = 1;
18
+ function manifestPath(devRoot) {
19
+ return join(sidecarDir(devRoot), 'page-objects.json');
20
+ }
21
+ export async function writePageObjectManifest(devRoot, pages) {
22
+ const dir = sidecarDir(devRoot);
23
+ await mkdir(dir, { recursive: true });
24
+ const path = manifestPath(devRoot);
25
+ const manifest = { version: MANIFEST_VERSION, pages };
26
+ await writeFile(path, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
27
+ return path;
28
+ }
29
+ /** Read the manifest, or null when none exists (no extraction has run). */
30
+ export async function readPageObjectManifest(devRoot) {
31
+ try {
32
+ const m = JSON.parse(await readFile(manifestPath(devRoot), 'utf-8'));
33
+ if (Array.isArray(m.pages))
34
+ return m;
35
+ }
36
+ catch {
37
+ /* no manifest / malformed — treat as none */
38
+ }
39
+ return null;
40
+ }