@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
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { selectorFromDescription, selectorForFormField, emitInteraction, blockScope, } from './writeSpec.js';
|
|
2
|
+
const PAGE_VAR = 'this.page';
|
|
3
|
+
export function generatePageObject(steps, override = {}) {
|
|
4
|
+
const derived = deriveNames(steps);
|
|
5
|
+
const className = override.className ?? derived.className;
|
|
6
|
+
const methodName = override.methodName ?? derived.methodName;
|
|
7
|
+
const params = [];
|
|
8
|
+
const used = new Set();
|
|
9
|
+
const body = [];
|
|
10
|
+
for (const s of steps) {
|
|
11
|
+
if (s.kind !== 'step' || !s.tool)
|
|
12
|
+
continue;
|
|
13
|
+
const i = (s.input ?? {});
|
|
14
|
+
switch (s.tool) {
|
|
15
|
+
case 'browser_navigate':
|
|
16
|
+
body.push(`await ${PAGE_VAR}.goto(${JSON.stringify(navPath(String(i.url ?? '')))});`);
|
|
17
|
+
break;
|
|
18
|
+
case 'browser_click':
|
|
19
|
+
body.push(...blockScope(emitInteraction(selectorFromDescription(String(i.element ?? ''), PAGE_VAR), 'click()')));
|
|
20
|
+
break;
|
|
21
|
+
case 'browser_double_click':
|
|
22
|
+
body.push(...blockScope(emitInteraction(selectorFromDescription(String(i.element ?? ''), PAGE_VAR), 'dblclick()')));
|
|
23
|
+
break;
|
|
24
|
+
case 'browser_hover':
|
|
25
|
+
body.push(...blockScope(emitInteraction(selectorFromDescription(String(i.element ?? ''), PAGE_VAR), 'hover()')));
|
|
26
|
+
break;
|
|
27
|
+
case 'browser_type': {
|
|
28
|
+
const p = uniqueParam(paramName(String(i.element ?? '')), used);
|
|
29
|
+
params.push(p);
|
|
30
|
+
body.push(...blockScope(emitInteraction(selectorFromDescription(String(i.element ?? ''), PAGE_VAR), `fill(${p})`)));
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
case 'browser_select_option': {
|
|
34
|
+
const p = uniqueParam(paramName(String(i.element ?? '')), used);
|
|
35
|
+
params.push(p);
|
|
36
|
+
body.push(...blockScope(emitInteraction(selectorFromDescription(String(i.element ?? ''), PAGE_VAR), `selectOption(${p})`)));
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
case 'browser_fill_form': {
|
|
40
|
+
const fields = i.fields ?? [];
|
|
41
|
+
for (const f of fields) {
|
|
42
|
+
const target = String(f.name ?? f.element ?? '');
|
|
43
|
+
const p = uniqueParam(paramName(target), used);
|
|
44
|
+
params.push(p);
|
|
45
|
+
body.push(...blockScope(emitInteraction(selectorForFormField(target, f.type, PAGE_VAR), `fill(${p})`)));
|
|
46
|
+
}
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
case 'browser_press_key':
|
|
50
|
+
body.push(`await ${PAGE_VAR}.keyboard.press(${JSON.stringify(String(i.key ?? ''))});`);
|
|
51
|
+
break;
|
|
52
|
+
default:
|
|
53
|
+
break; // diagnostics / non-replayable — skipped
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const paramList = params.map(p => `${p}: string`).join(', ');
|
|
57
|
+
return {
|
|
58
|
+
className,
|
|
59
|
+
methodName,
|
|
60
|
+
fileName: `${className}.ts`,
|
|
61
|
+
source: renderClass(className, methodName, paramList, body),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function renderClass(className, methodName, paramList, body) {
|
|
65
|
+
const out = body.length > 0 ? body : ['// (no replayable steps)'];
|
|
66
|
+
const lines = [];
|
|
67
|
+
lines.push(`import { expect, type Page } from '@playwright/test';`);
|
|
68
|
+
lines.push('');
|
|
69
|
+
lines.push('/**');
|
|
70
|
+
lines.push(' * Generated by Hover from a flow shared across saved specs.');
|
|
71
|
+
lines.push(' * Selectors live here so a UI change is a one-file edit; data values are');
|
|
72
|
+
lines.push(' * method parameters. Plain Playwright — no Hover runtime.');
|
|
73
|
+
lines.push(' */');
|
|
74
|
+
lines.push(`export class ${className} {`);
|
|
75
|
+
lines.push(` constructor(private readonly page: Page) {}`);
|
|
76
|
+
lines.push('');
|
|
77
|
+
lines.push(` async ${methodName}(${paramList}): Promise<void> {`);
|
|
78
|
+
for (const b of out)
|
|
79
|
+
lines.push(` ${b}`);
|
|
80
|
+
lines.push(` }`);
|
|
81
|
+
lines.push(`}`);
|
|
82
|
+
lines.push('');
|
|
83
|
+
return lines.join('\n');
|
|
84
|
+
}
|
|
85
|
+
function deriveNames(steps) {
|
|
86
|
+
for (const s of steps) {
|
|
87
|
+
if (s.kind === 'step' && s.tool === 'browser_navigate') {
|
|
88
|
+
const seg = lastSegment(String((s.input ?? {}).url ?? ''));
|
|
89
|
+
if (seg)
|
|
90
|
+
return { className: pascal(seg) + 'Page', methodName: camel(seg) };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { className: 'FlowPage', methodName: 'run' };
|
|
94
|
+
}
|
|
95
|
+
function lastSegment(url) {
|
|
96
|
+
let path = url;
|
|
97
|
+
try {
|
|
98
|
+
if (/^https?:\/\//.test(url))
|
|
99
|
+
path = new URL(url).pathname;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* keep raw */
|
|
103
|
+
}
|
|
104
|
+
const segs = path.split('/').filter(Boolean);
|
|
105
|
+
return segs.length ? segs[segs.length - 1].replace(/[^a-zA-Z0-9]+/g, ' ').trim() : '';
|
|
106
|
+
}
|
|
107
|
+
/** Tokenize a label into words, dropping a trailing MCP role keyword. */
|
|
108
|
+
function words(raw) {
|
|
109
|
+
return raw
|
|
110
|
+
.replace(/\s+(button|link|textbox|checkbox|radio|combobox|switch|field|input)$/i, '')
|
|
111
|
+
.replace(/[^a-zA-Z0-9]+/g, ' ')
|
|
112
|
+
.trim()
|
|
113
|
+
.split(/\s+/)
|
|
114
|
+
.filter(Boolean);
|
|
115
|
+
}
|
|
116
|
+
function camel(raw) {
|
|
117
|
+
const w = words(raw);
|
|
118
|
+
if (w.length === 0)
|
|
119
|
+
return 'value';
|
|
120
|
+
const c = w[0].toLowerCase() +
|
|
121
|
+
w.slice(1).map(x => x[0].toUpperCase() + x.slice(1).toLowerCase()).join('');
|
|
122
|
+
return /^[a-zA-Z_]/.test(c) ? c : `v${c}`;
|
|
123
|
+
}
|
|
124
|
+
function pascal(raw) {
|
|
125
|
+
const c = camel(raw);
|
|
126
|
+
return c.charAt(0).toUpperCase() + c.slice(1);
|
|
127
|
+
}
|
|
128
|
+
/** Parameter name derived from a field/element label. */
|
|
129
|
+
const paramName = camel;
|
|
130
|
+
function uniqueParam(base, used) {
|
|
131
|
+
const root = base || 'value';
|
|
132
|
+
let name = root;
|
|
133
|
+
let n = 2;
|
|
134
|
+
while (used.has(name))
|
|
135
|
+
name = `${root}${n++}`;
|
|
136
|
+
used.add(name);
|
|
137
|
+
return name;
|
|
138
|
+
}
|
|
139
|
+
function navPath(url) {
|
|
140
|
+
if (!/^https?:\/\//.test(url))
|
|
141
|
+
return url;
|
|
142
|
+
try {
|
|
143
|
+
const u = new URL(url);
|
|
144
|
+
return u.pathname + u.search + u.hash || '/';
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return url;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type OptimizationSuggestion } from './optimizationSuggestion.js';
|
|
1
2
|
export interface SpecSummary {
|
|
2
3
|
/** Path-relative slug, e.g. `login-and-counter`. Identifies the spec. */
|
|
3
4
|
slug: string;
|
|
@@ -13,6 +14,17 @@ export interface SpecSummary {
|
|
|
13
14
|
stepCount: number;
|
|
14
15
|
/** File mtime in ms — used to show "saved 2 hours ago" in the UI. */
|
|
15
16
|
mtimeMs: number;
|
|
17
|
+
/** Whether a structured `.hover/<slug>.json` sidecar exists. The widget
|
|
18
|
+
* gates the optimization pass on this — without a captured session there's
|
|
19
|
+
* no observed feedback for the LLM to add assertions from. */
|
|
20
|
+
hasSidecar: boolean;
|
|
21
|
+
/** Count of `// hover:optimizable` markers the deterministic translator left
|
|
22
|
+
* — interactions it couldn't fully translate single-step. >0 is a strong
|
|
23
|
+
* signal to run the optimization pass (or add a seed). */
|
|
24
|
+
optimizableCount: number;
|
|
25
|
+
/** The default-off "review optimization?" nudge (F7/D10): suggested + reasons,
|
|
26
|
+
* derived from optimizable markers + relevant seeds. */
|
|
27
|
+
optimization: OptimizationSuggestion;
|
|
16
28
|
}
|
|
17
29
|
export interface SpecHeader {
|
|
18
30
|
/** Raw text of `Original prompt:` line, or null when absent. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"listSpecs.d.ts","sourceRoot":"","sources":["../../src/specs/listSpecs.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"listSpecs.d.ts","sourceRoot":"","sources":["../../src/specs/listSpecs.ts"],"names":[],"mappings":"AAoBA,OAAO,EAA0B,KAAK,sBAAsB,EAAE,MAAM,6BAA6B,CAAC;AAGlG,MAAM,WAAW,WAAW;IAC1B,yEAAyE;IACzE,IAAI,EAAE,MAAM,CAAC;IACb,0CAA0C;IAC1C,IAAI,EAAE,MAAM,CAAC;IACb;;qCAEiC;IACjC,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,kEAAkE;IAClE,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,4DAA4D;IAC5D,SAAS,EAAE,MAAM,CAAC;IAClB,qEAAqE;IACrE,OAAO,EAAE,MAAM,CAAC;IAChB;;mEAE+D;IAC/D,UAAU,EAAE,OAAO,CAAC;IACpB;;+DAE2D;IAC3D,gBAAgB,EAAE,MAAM,CAAC;IACzB;6DACyD;IACzD,YAAY,EAAE,sBAAsB,CAAC;CACtC;AAED,MAAM,WAAW,UAAU;IACzB,gEAAgE;IAChE,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,gCAAgC;IAChC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,oDAAoD;IACpD,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,kDAAkD;IAClD,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,UAAU,CAsB1D;AA4BD;;;;GAIG;AACH,wBAAsB,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CA2DvE"}
|
package/dist/specs/listSpecs.js
CHANGED
|
@@ -10,11 +10,15 @@
|
|
|
10
10
|
* `originalPrompt: null` — the UI / CLI surfaces that "this spec can't be
|
|
11
11
|
* re-recorded automatically; the natural-language intent isn't recorded."
|
|
12
12
|
*
|
|
13
|
-
*
|
|
13
|
+
* Shares the SpecSummary row shape the widget's Specs tab renders.
|
|
14
14
|
*/
|
|
15
15
|
import { readdir, readFile } from 'node:fs/promises';
|
|
16
16
|
import { stat } from 'node:fs/promises';
|
|
17
|
+
import { existsSync } from 'node:fs';
|
|
17
18
|
import { join } from 'node:path';
|
|
19
|
+
import { countOptimizableMarkers } from './writeSpec.js';
|
|
20
|
+
import { readSeeds, relevantSeeds } from './seeds.js';
|
|
21
|
+
import { optimizationSuggestion } from './optimizationSuggestion.js';
|
|
18
22
|
/**
|
|
19
23
|
* Parse the JSDoc header that `writeSpec.ts` emits. Tolerant of:
|
|
20
24
|
* - Specs without any JSDoc (returns all-null).
|
|
@@ -84,6 +88,8 @@ export async function listSpecs(devRoot) {
|
|
|
84
88
|
catch {
|
|
85
89
|
return [];
|
|
86
90
|
}
|
|
91
|
+
// Seeds are devRoot-wide; read once and reuse for every spec's suggestion.
|
|
92
|
+
const seeds = await readSeeds(devRoot);
|
|
87
93
|
const summaries = [];
|
|
88
94
|
for (const entry of entries) {
|
|
89
95
|
if (!entry.endsWith('.spec.ts'))
|
|
@@ -100,13 +106,32 @@ export async function listSpecs(devRoot) {
|
|
|
100
106
|
continue;
|
|
101
107
|
}
|
|
102
108
|
const header = parseSpecHeader(content);
|
|
109
|
+
const slug = entry.replace(/\.spec\.ts$/, '');
|
|
110
|
+
const sidecarPath = join(root, '.hover', `${slug}.json`);
|
|
111
|
+
const hasSidecar = existsSync(sidecarPath);
|
|
112
|
+
const optimizableCount = countOptimizableMarkers(content);
|
|
113
|
+
// Which seeds could plausibly apply, from the sidecar's captured tools.
|
|
114
|
+
let relevantSeedNames = [];
|
|
115
|
+
if (hasSidecar && seeds.length > 0) {
|
|
116
|
+
try {
|
|
117
|
+
const sc = JSON.parse(await readFile(sidecarPath, 'utf-8'));
|
|
118
|
+
const tools = new Set((sc.steps ?? []).filter(s => s.kind === 'step' && s.tool).map(s => s.tool));
|
|
119
|
+
relevantSeedNames = relevantSeeds(seeds, tools).map(s => s.name);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
/* malformed sidecar — treat as no relevant seeds */
|
|
123
|
+
}
|
|
124
|
+
}
|
|
103
125
|
summaries.push({
|
|
104
|
-
slug
|
|
126
|
+
slug,
|
|
105
127
|
path,
|
|
106
128
|
originalPrompt: header.originalPrompt,
|
|
107
129
|
outcome: header.outcome,
|
|
108
130
|
stepCount: header.steps.length,
|
|
109
131
|
mtimeMs,
|
|
132
|
+
hasSidecar,
|
|
133
|
+
optimizableCount,
|
|
134
|
+
optimization: optimizationSuggestion({ hasSidecar, optimizableCount, relevantSeedNames }),
|
|
110
135
|
});
|
|
111
136
|
}
|
|
112
137
|
summaries.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The default-off "should we nudge the user to optimize this spec?" signal
|
|
3
|
+
* (F7 / D10). Optimization never runs automatically; instead, when a saved spec
|
|
4
|
+
* has a clear improvable shape, the widget surfaces a "review optimization?"
|
|
5
|
+
* prompt. This computes that decision + human-readable reasons.
|
|
6
|
+
*
|
|
7
|
+
* Pure function so it's trivially testable; `listSpecs` gathers the inputs
|
|
8
|
+
* (optimizable-marker count, sidecar presence, relevant seed names) and attaches
|
|
9
|
+
* the result to each SpecSummary.
|
|
10
|
+
*/
|
|
11
|
+
export interface OptimizationSuggestion {
|
|
12
|
+
/** Whether to nudge the user to run the optimization pass on this spec. */
|
|
13
|
+
suggested: boolean;
|
|
14
|
+
/** Human-readable reasons, for the widget tooltip / prompt. Empty when not
|
|
15
|
+
* suggested. */
|
|
16
|
+
reasons: string[];
|
|
17
|
+
}
|
|
18
|
+
export declare function optimizationSuggestion(args: {
|
|
19
|
+
/** Whether a `.hover/<slug>.json` sidecar exists. */
|
|
20
|
+
hasSidecar: boolean;
|
|
21
|
+
/** Count of `// hover:optimizable` markers in the spec. */
|
|
22
|
+
optimizableCount: number;
|
|
23
|
+
/** Names of seeds whose signature is relevant to this spec's tools. */
|
|
24
|
+
relevantSeedNames: string[];
|
|
25
|
+
}): OptimizationSuggestion;
|
|
26
|
+
//# sourceMappingURL=optimizationSuggestion.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"optimizationSuggestion.d.ts","sourceRoot":"","sources":["../../src/specs/optimizationSuggestion.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,MAAM,WAAW,sBAAsB;IACrC,2EAA2E;IAC3E,SAAS,EAAE,OAAO,CAAC;IACnB;qBACiB;IACjB,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAED,wBAAgB,sBAAsB,CAAC,IAAI,EAAE;IAC3C,qDAAqD;IACrD,UAAU,EAAE,OAAO,CAAC;IACpB,2DAA2D;IAC3D,gBAAgB,EAAE,MAAM,CAAC;IACzB,uEAAuE;IACvE,iBAAiB,EAAE,MAAM,EAAE,CAAC;CAC7B,GAAG,sBAAsB,CAqBzB"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The default-off "should we nudge the user to optimize this spec?" signal
|
|
3
|
+
* (F7 / D10). Optimization never runs automatically; instead, when a saved spec
|
|
4
|
+
* has a clear improvable shape, the widget surfaces a "review optimization?"
|
|
5
|
+
* prompt. This computes that decision + human-readable reasons.
|
|
6
|
+
*
|
|
7
|
+
* Pure function so it's trivially testable; `listSpecs` gathers the inputs
|
|
8
|
+
* (optimizable-marker count, sidecar presence, relevant seed names) and attaches
|
|
9
|
+
* the result to each SpecSummary.
|
|
10
|
+
*/
|
|
11
|
+
export function optimizationSuggestion(args) {
|
|
12
|
+
const { hasSidecar, optimizableCount, relevantSeedNames } = args;
|
|
13
|
+
const reasons = [];
|
|
14
|
+
// The optimization pass reads the sidecar (observed feedback, captured steps);
|
|
15
|
+
// without one there's nothing to optimize from. Matches the widget's Optimize
|
|
16
|
+
// gate, so we never suggest what can't be acted on.
|
|
17
|
+
if (!hasSidecar)
|
|
18
|
+
return { suggested: false, reasons };
|
|
19
|
+
if (optimizableCount > 0) {
|
|
20
|
+
const n = optimizableCount;
|
|
21
|
+
reasons.push(`${n} interaction${n === 1 ? '' : 's'} couldn't be fully translated — the optimization pass can complete ${n === 1 ? 'it' : 'them'}`);
|
|
22
|
+
}
|
|
23
|
+
if (relevantSeedNames.length > 0) {
|
|
24
|
+
const k = relevantSeedNames.length;
|
|
25
|
+
reasons.push(`${k} seed${k === 1 ? '' : 's'} may apply: ${relevantSeedNames.join(', ')}`);
|
|
26
|
+
}
|
|
27
|
+
return { suggested: reasons.length > 0, reasons };
|
|
28
|
+
}
|
|
@@ -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
|
+
}
|