@hover-dev/core 0.16.0 → 0.18.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 +26 -55
- package/dist/agentDirectives.d.ts +55 -0
- package/dist/agentDirectives.d.ts.map +1 -0
- package/dist/agentDirectives.js +276 -0
- package/dist/engine.d.ts +28 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +27 -0
- package/dist/memory/businessMemory.d.ts +29 -0
- package/dist/memory/businessMemory.d.ts.map +1 -0
- package/dist/memory/businessMemory.js +125 -0
- package/dist/playwright/launchChrome.d.ts +18 -0
- package/dist/playwright/launchChrome.d.ts.map +1 -1
- package/dist/playwright/launchChrome.js +46 -3
- package/dist/qa/candidates.d.ts +32 -0
- package/dist/qa/candidates.d.ts.map +1 -0
- package/dist/qa/candidates.js +20 -0
- package/dist/qa/intensity.d.ts +33 -0
- package/dist/qa/intensity.d.ts.map +1 -0
- package/dist/qa/intensity.js +25 -0
- package/dist/qa/qaReport.d.ts +19 -0
- package/dist/qa/qaReport.d.ts.map +1 -0
- package/dist/qa/qaReport.js +50 -0
- package/dist/sessions/sessions.d.ts +125 -0
- package/dist/sessions/sessions.d.ts.map +1 -0
- package/dist/sessions/sessions.js +175 -0
- package/dist/specs/authFixture.d.ts +30 -0
- package/dist/specs/authFixture.d.ts.map +1 -0
- package/dist/specs/authFixture.js +145 -0
- package/dist/specs/detectSharedFlows.d.ts +1 -1
- package/dist/specs/detectSharedFlows.d.ts.map +1 -1
- package/dist/specs/detectSharedFlows.js +20 -21
- package/dist/specs/generatePageObject.d.ts +1 -1
- package/dist/specs/generatePageObject.d.ts.map +1 -1
- package/dist/specs/healPrompt.d.ts +19 -0
- package/dist/specs/healPrompt.d.ts.map +1 -0
- package/dist/specs/healPrompt.js +48 -0
- package/dist/specs/humanSteps.d.ts +4 -8
- package/dist/specs/humanSteps.d.ts.map +1 -1
- package/dist/specs/humanSteps.js +6 -1
- package/dist/specs/optimizeSpec.d.ts +15 -8
- package/dist/specs/optimizeSpec.d.ts.map +1 -1
- package/dist/specs/optimizeSpec.js +71 -41
- package/dist/specs/pageObjectManifest.d.ts +3 -1
- package/dist/specs/pageObjectManifest.d.ts.map +1 -1
- package/dist/specs/pageObjectManifest.js +24 -19
- package/dist/specs/replayGrounded.d.ts +45 -0
- package/dist/specs/replayGrounded.d.ts.map +1 -0
- package/dist/specs/replayGrounded.js +155 -0
- package/dist/specs/runFailures.d.ts +34 -0
- package/dist/specs/runFailures.d.ts.map +1 -0
- package/dist/specs/runFailures.js +93 -0
- package/dist/specs/seeds.d.ts +16 -15
- package/dist/specs/seeds.d.ts.map +1 -1
- package/dist/specs/seeds.js +86 -54
- package/dist/specs/sidecar.d.ts +34 -6
- package/dist/specs/sidecar.d.ts.map +1 -1
- package/dist/specs/sidecar.js +79 -9
- package/dist/specs/specStep.d.ts +21 -0
- package/dist/specs/specStep.d.ts.map +1 -0
- package/dist/specs/specStep.js +1 -0
- package/dist/specs/text.d.ts +8 -6
- package/dist/specs/text.d.ts.map +1 -1
- package/dist/specs/text.js +10 -7
- package/dist/specs/writeSpec.d.ts +62 -1
- package/dist/specs/writeSpec.d.ts.map +1 -1
- package/dist/specs/writeSpec.js +596 -21
- package/package.json +9 -29
- package/dist/agents/aider.d.ts +0 -16
- package/dist/agents/aider.d.ts.map +0 -1
- package/dist/agents/aider.js +0 -161
- package/dist/agents/argv.d.ts +0 -11
- package/dist/agents/argv.d.ts.map +0 -1
- package/dist/agents/argv.js +0 -23
- package/dist/agents/claude.d.ts +0 -3
- package/dist/agents/claude.d.ts.map +0 -1
- package/dist/agents/claude.js +0 -195
- package/dist/agents/codex.d.ts +0 -19
- package/dist/agents/codex.d.ts.map +0 -1
- package/dist/agents/codex.js +0 -216
- package/dist/agents/cursor.d.ts +0 -18
- package/dist/agents/cursor.d.ts.map +0 -1
- package/dist/agents/cursor.js +0 -220
- package/dist/agents/detect.d.ts +0 -46
- package/dist/agents/detect.d.ts.map +0 -1
- package/dist/agents/detect.js +0 -80
- package/dist/agents/gemini.d.ts +0 -17
- package/dist/agents/gemini.d.ts.map +0 -1
- package/dist/agents/gemini.js +0 -186
- package/dist/agents/index.d.ts +0 -6
- package/dist/agents/index.d.ts.map +0 -1
- package/dist/agents/index.js +0 -5
- package/dist/agents/invoke.d.ts +0 -12
- package/dist/agents/invoke.d.ts.map +0 -1
- package/dist/agents/invoke.js +0 -96
- package/dist/agents/qwen.d.ts +0 -17
- package/dist/agents/qwen.d.ts.map +0 -1
- package/dist/agents/qwen.js +0 -172
- package/dist/agents/registry.d.ts +0 -19
- package/dist/agents/registry.d.ts.map +0 -1
- package/dist/agents/registry.js +0 -34
- package/dist/agents/shared.d.ts +0 -28
- package/dist/agents/shared.d.ts.map +0 -1
- package/dist/agents/shared.js +0 -35
- package/dist/agents/types.d.ts +0 -186
- package/dist/agents/types.d.ts.map +0 -1
- package/dist/agents/types.js +0 -23
- package/dist/index.d.ts +0 -3
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -2
- package/dist/mcp/sourceFence.d.ts +0 -23
- package/dist/mcp/sourceFence.d.ts.map +0 -1
- package/dist/mcp/sourceFence.js +0 -75
- package/dist/mcp/sourceServer.d.ts +0 -3
- package/dist/mcp/sourceServer.d.ts.map +0 -1
- package/dist/mcp/sourceServer.js +0 -116
- package/dist/playwright/cdpStatus.d.ts +0 -29
- package/dist/playwright/cdpStatus.d.ts.map +0 -1
- package/dist/playwright/cdpStatus.js +0 -119
- package/dist/playwright/preflight.d.ts +0 -31
- package/dist/playwright/preflight.d.ts.map +0 -1
- package/dist/playwright/preflight.js +0 -82
- package/dist/playwright/preflightCache.d.ts +0 -27
- package/dist/playwright/preflightCache.d.ts.map +0 -1
- package/dist/playwright/preflightCache.js +0 -21
- package/dist/playwright/raiseWindow.d.ts +0 -10
- package/dist/playwright/raiseWindow.d.ts.map +0 -1
- package/dist/playwright/raiseWindow.js +0 -158
- package/dist/playwright/resolveMcpConfig.d.ts +0 -55
- package/dist/playwright/resolveMcpConfig.d.ts.map +0 -1
- package/dist/playwright/resolveMcpConfig.js +0 -66
- package/dist/plugin-api.d.ts +0 -235
- package/dist/plugin-api.d.ts.map +0 -1
- package/dist/plugin-api.js +0 -52
- package/dist/runSession.d.ts +0 -42
- package/dist/runSession.d.ts.map +0 -1
- package/dist/runSession.js +0 -81
- package/dist/scripts/bench-multi-tab.d.ts +0 -2
- package/dist/scripts/bench-multi-tab.d.ts.map +0 -1
- package/dist/scripts/bench-multi-tab.js +0 -192
- package/dist/scripts/bench-ttfb.d.ts +0 -2
- package/dist/scripts/bench-ttfb.d.ts.map +0 -1
- package/dist/scripts/bench-ttfb.js +0 -127
- package/dist/scripts/start-chrome.d.ts +0 -3
- package/dist/scripts/start-chrome.d.ts.map +0 -1
- package/dist/scripts/start-chrome.js +0 -23
- package/dist/service/cdpHandlers.d.ts +0 -44
- package/dist/service/cdpHandlers.d.ts.map +0 -1
- package/dist/service/cdpHandlers.js +0 -85
- package/dist/service/cdpHint.d.ts +0 -48
- package/dist/service/cdpHint.d.ts.map +0 -1
- package/dist/service/cdpHint.js +0 -216
- package/dist/service/conventions.d.ts +0 -8
- package/dist/service/conventions.d.ts.map +0 -1
- package/dist/service/conventions.js +0 -42
- package/dist/service/saveHandlers.d.ts +0 -52
- package/dist/service/saveHandlers.d.ts.map +0 -1
- package/dist/service/saveHandlers.js +0 -75
- package/dist/service/types.d.ts +0 -58
- package/dist/service/types.d.ts.map +0 -1
- package/dist/service/types.js +0 -26
- package/dist/service.d.ts +0 -50
- package/dist/service.d.ts.map +0 -1
- package/dist/service.js +0 -1065
- package/dist/skills/writeSkill.d.ts +0 -27
- package/dist/skills/writeSkill.d.ts.map +0 -1
- package/dist/skills/writeSkill.js +0 -13
- package/dist/specs/extractPageObjects.d.ts +0 -18
- package/dist/specs/extractPageObjects.d.ts.map +0 -1
- package/dist/specs/extractPageObjects.js +0 -98
- package/dist/specs/listSpecs.d.ts +0 -52
- package/dist/specs/listSpecs.d.ts.map +0 -1
- package/dist/specs/listSpecs.js +0 -139
- package/dist/specs/optimizationSuggestion.d.ts +0 -26
- package/dist/specs/optimizationSuggestion.d.ts.map +0 -1
- package/dist/specs/optimizationSuggestion.js +0 -28
- package/dist/specs/optimizeSpecWithAgent.d.ts +0 -11
- package/dist/specs/optimizeSpecWithAgent.d.ts.map +0 -1
- package/dist/specs/optimizeSpecWithAgent.js +0 -40
- package/dist/specs/writeCaseCsv.d.ts +0 -28
- package/dist/specs/writeCaseCsv.d.ts.map +0 -1
- package/dist/specs/writeCaseCsv.js +0 -134
package/dist/specs/writeSpec.js
CHANGED
|
@@ -17,13 +17,15 @@
|
|
|
17
17
|
* `assertions` field on the input.
|
|
18
18
|
*/
|
|
19
19
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
20
|
-
import { existsSync } from 'node:fs';
|
|
20
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
21
21
|
import { join } from 'node:path';
|
|
22
22
|
import { humanSteps, humanStep } from './humanSteps.js';
|
|
23
23
|
import { writeSidecar } from './sidecar.js';
|
|
24
24
|
import { readPageObjectManifest, } from './pageObjectManifest.js';
|
|
25
25
|
import { stepSignature } from './detectSharedFlows.js';
|
|
26
26
|
import { slugify, firstSentence } from './text.js';
|
|
27
|
+
import { markSessionSaved } from '../sessions/sessions.js';
|
|
28
|
+
import { authPrefixLength, addSetupProjectToConfig } from './authFixture.js';
|
|
27
29
|
/**
|
|
28
30
|
* Marker the deterministic translator leaves where a captured action is a real
|
|
29
31
|
* interaction but has no single-step Playwright translation (e.g. file upload,
|
|
@@ -39,6 +41,50 @@ export const OPTIMIZABLE_MARKER = '// hover:optimizable';
|
|
|
39
41
|
export function countOptimizableMarkers(source) {
|
|
40
42
|
return source.split('\n').filter(l => l.trimStart().startsWith(OPTIMIZABLE_MARKER)).length;
|
|
41
43
|
}
|
|
44
|
+
/** Strip the `mcp__<server>__` prefix off a Hover-MCP tool name. The server
|
|
45
|
+
* segment is kebab-case (`hover-source`), so the class includes `-`; lazy so
|
|
46
|
+
* the tool name (which may contain `_`) is preserved. */
|
|
47
|
+
function bareTool(rawTool) {
|
|
48
|
+
return rawTool.replace(/^mcp__[a-z0-9_-]+?__/, '');
|
|
49
|
+
}
|
|
50
|
+
/** Tools that never belong in a crystallized spec: read-only exploration the
|
|
51
|
+
* agent does to understand the page/code, and meta interactions like asking
|
|
52
|
+
* the user a question. Dropped at the filter so they don't reach the body or
|
|
53
|
+
* the prose. (browser_* read tools are also dropped inside translateStep.) */
|
|
54
|
+
function isExploratoryTool(rawTool) {
|
|
55
|
+
const tool = bareTool(rawTool);
|
|
56
|
+
// take_screenshot is the grounded-mode viewport screenshot (perceive-only,
|
|
57
|
+
// like browser_take_screenshot) — never a replayable spec step.
|
|
58
|
+
return tool === 'list_source' || tool === 'read_source' || tool === 'ask_user' || tool === 'take_screenshot';
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Dirty-recording cleanup. An agent run is exploratory: it makes failed
|
|
62
|
+
* attempts and reads source to orient itself. Those are captured as steps (and
|
|
63
|
+
* kept in the sidecar), but the runnable spec must reflect only
|
|
64
|
+
* the working flow. Drop step-kind entries that errored or are pure
|
|
65
|
+
* exploration; keep everything else (user/done/ai markers and successful
|
|
66
|
+
* actions) untouched. Returns the filtered steps plus how many were omitted.
|
|
67
|
+
*/
|
|
68
|
+
function filterDirtySteps(steps) {
|
|
69
|
+
let omitted = 0;
|
|
70
|
+
const clean = steps.filter(s => {
|
|
71
|
+
if (s.kind !== 'step' || !s.tool)
|
|
72
|
+
return true; // non-action entries pass through
|
|
73
|
+
if (isFlowMarker(s))
|
|
74
|
+
return false; // mark_flow is a split boundary, not an action — drop, don't count
|
|
75
|
+
// record_candidate is a QA capture signal, not a replayable browser action —
|
|
76
|
+
// drop it (silently, like mark_flow) so it never renders as a junk
|
|
77
|
+
// `hover:optimizable` step in the crystallized spec.
|
|
78
|
+
if (bareTool(s.tool) === 'record_candidate')
|
|
79
|
+
return false;
|
|
80
|
+
if (s.isError || isExploratoryTool(s.tool)) {
|
|
81
|
+
omitted++;
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
return true;
|
|
85
|
+
});
|
|
86
|
+
return { clean, omitted };
|
|
87
|
+
}
|
|
42
88
|
export class SpecExistsError extends Error {
|
|
43
89
|
slug;
|
|
44
90
|
path;
|
|
@@ -49,37 +95,191 @@ export class SpecExistsError extends Error {
|
|
|
49
95
|
this.name = 'SpecExistsError';
|
|
50
96
|
}
|
|
51
97
|
}
|
|
98
|
+
/** Stored-step form of a redacted credential — a code expression, so it both
|
|
99
|
+
* renders as `fill(process.env.X ?? '')` and survives JSON (the sidecar)
|
|
100
|
+
* without ever holding the secret. */
|
|
101
|
+
function envExpr(envVar) {
|
|
102
|
+
return `process.env.${envVar} ?? ''`;
|
|
103
|
+
}
|
|
104
|
+
/** Replace credential fill values with `process.env.<envVar>` expressions,
|
|
105
|
+
* ONCE, before both rendering and sidecar persistence. Pure — clones touched
|
|
106
|
+
* steps, leaves the rest untouched. */
|
|
107
|
+
function redactSteps(steps, redactions) {
|
|
108
|
+
const map = new Map(redactions.filter(r => r.value).map(r => [r.value, r.envVar]));
|
|
109
|
+
if (map.size === 0)
|
|
110
|
+
return steps;
|
|
111
|
+
return steps.map(s => {
|
|
112
|
+
if (s.kind !== 'step' || !s.input)
|
|
113
|
+
return s;
|
|
114
|
+
const input = s.input;
|
|
115
|
+
// Match on the BARE tool name — grounded fills arrive as
|
|
116
|
+
// `mcp__hover-control__fill_control`, playwright ones as bare `browser_type`.
|
|
117
|
+
const tool = (s.tool ?? '').replace(/^mcp__[a-z0-9_-]+?__/, '');
|
|
118
|
+
// A single typed/filled value: browser_type uses `text`, the grounded
|
|
119
|
+
// fill_control uses `value`. WITHOUT the fill_control case, credentials typed
|
|
120
|
+
// via grounded actuation (the default mode) leaked into the spec unredacted.
|
|
121
|
+
const valueKey = tool === 'browser_type' ? 'text' : tool === 'fill_control' ? 'value' : null;
|
|
122
|
+
if (valueKey && typeof input[valueKey] === 'string' && map.has(input[valueKey])) {
|
|
123
|
+
return { ...s, input: { ...input, [valueKey]: envExpr(map.get(input[valueKey])) } };
|
|
124
|
+
}
|
|
125
|
+
if (tool === 'browser_fill_form' && Array.isArray(input.fields)) {
|
|
126
|
+
let changed = false;
|
|
127
|
+
const fields = input.fields.map(f => {
|
|
128
|
+
if (f && typeof f.value === 'string' && map.has(f.value)) {
|
|
129
|
+
changed = true;
|
|
130
|
+
return { ...f, value: envExpr(map.get(f.value)) };
|
|
131
|
+
}
|
|
132
|
+
return f;
|
|
133
|
+
});
|
|
134
|
+
if (changed)
|
|
135
|
+
return { ...s, input: { ...input, fields } };
|
|
136
|
+
}
|
|
137
|
+
return s;
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
/** Render a fill value: a redacted `process.env.…` expression emits as CODE;
|
|
141
|
+
* anything else as a string literal. */
|
|
142
|
+
function renderFillValue(value) {
|
|
143
|
+
return /^process\.env\b/.test(value) ? value : JSON.stringify(value);
|
|
144
|
+
}
|
|
145
|
+
/** True for a `mark_flow` boundary step (the agent's per-feature split marker).
|
|
146
|
+
* Matches the raw tool name with or without the `mcp__<server>__` prefix. */
|
|
147
|
+
function isFlowMarker(s) {
|
|
148
|
+
return s.kind === 'step' && /(^|__)mark_flow$/.test(String(s.tool ?? ''));
|
|
149
|
+
}
|
|
52
150
|
export async function writeSpec(opts) {
|
|
53
|
-
|
|
54
|
-
if (!slug)
|
|
151
|
+
if (!slugify(opts.name))
|
|
55
152
|
throw new Error('spec name must contain at least one alphanumeric character');
|
|
56
|
-
if (!opts.steps.some(s => s.kind === 'step')) {
|
|
153
|
+
if (!opts.steps.some((s) => s.kind === 'step' && !isFlowMarker(s))) {
|
|
57
154
|
throw new Error('spec must contain at least one tool step to replay');
|
|
58
155
|
}
|
|
156
|
+
// One run → one file. Frontend runs are NOT auto-split: a single user journey
|
|
157
|
+
// (especially a multi-step single-page form) is stateful and sequential — each
|
|
158
|
+
// step depends on the prior steps' state, so chopping it into per-section files
|
|
159
|
+
// yields fragments that each fail when run standalone. Splitting into truly
|
|
160
|
+
// independent journeys is a deliberate refactor (the architecture pass), not
|
|
161
|
+
// something the agent improvises mid-run. (API checks ARE split by module in
|
|
162
|
+
// writeSecuritySpec — those are stateless and independently replayable.)
|
|
163
|
+
return writeOneSpec(opts, slugify(opts.name), opts.name, opts.steps);
|
|
164
|
+
}
|
|
165
|
+
/** Write ONE spec file from a (sub)set of steps. The single-file path and each
|
|
166
|
+
* per-flow file both go through here, so rendering / sidecar / config logic is
|
|
167
|
+
* identical whether or not the run was split. */
|
|
168
|
+
async function writeOneSpec(opts, slug, displayName, rawSteps) {
|
|
169
|
+
if (!slug)
|
|
170
|
+
throw new Error('spec name must contain at least one alphanumeric character');
|
|
59
171
|
const dir = join(opts.devRoot, '__vibe_tests__');
|
|
60
172
|
const path = join(dir, `${slug}.spec.ts`);
|
|
61
173
|
if (!opts.overwrite && existsSync(path)) {
|
|
62
174
|
throw new SpecExistsError(slug, path);
|
|
63
175
|
}
|
|
64
176
|
await mkdir(dir, { recursive: true });
|
|
177
|
+
// Redact credentials ONCE, up front, so every downstream artifact (spec
|
|
178
|
+
// source, JSDoc header, sidecar) sees only `process.env.…` references — the
|
|
179
|
+
// literal password/username is never written anywhere.
|
|
180
|
+
const steps = redactSteps(rawSteps, opts.redactions ?? []);
|
|
65
181
|
// Stage 3c: if a prior extraction left a Page Object whose flow prefixes
|
|
66
182
|
// this spec, consume it (await loginPage.login(…)) instead of re-emitting
|
|
67
183
|
// the steps inline. No manifest (extraction never ran) → plain spec.
|
|
184
|
+
// Dirty-recording cleanup: the agent's failed attempts (isError) and
|
|
185
|
+
// read-only exploration (list_source / read_source) are real captured steps
|
|
186
|
+
// but must NOT land in the runnable spec — only the working flow should. They
|
|
187
|
+
// stay in `steps` (hence the sidecar) as the full-fidelity record the
|
|
188
|
+
// optimization pass reads; the spec renders from the filtered view, with a
|
|
189
|
+
// JSDoc note of how many were omitted.
|
|
190
|
+
const { clean: cleanSteps, omitted } = filterDirtySteps(steps);
|
|
68
191
|
const manifest = await readPageObjectManifest(opts.devRoot);
|
|
69
|
-
|
|
70
|
-
|
|
192
|
+
let match = manifest ? matchPageObject(cleanSteps, manifest) : null;
|
|
193
|
+
// Auth-as-fixture (debt 3): when the recorded login is detectable (credentials
|
|
194
|
+
// were redacted to process.env refs), lift it into auth.setup.ts and start
|
|
195
|
+
// specs authenticated via storageState — login then runs ONCE, not per test.
|
|
196
|
+
// Auto-on is gated to the scaffold case: with NO existing playwright.config we
|
|
197
|
+
// write one that registers the setup project, so it's self-contained. With an
|
|
198
|
+
// existing user config we can't register the setup project without editing
|
|
199
|
+
// their file (the Stage-4 approval flow), so keep today's inline login there.
|
|
200
|
+
const cleanActions = cleanSteps.filter(s => s.kind === 'step' && !!s.tool);
|
|
201
|
+
const envVars = (opts.redactions ?? []).map(r => r.envVar);
|
|
202
|
+
const detectedPrefix = authPrefixLength(cleanActions, envVars);
|
|
203
|
+
const userConfigName = PLAYWRIGHT_CONFIG_NAMES.find(n => existsSync(join(opts.devRoot, n)));
|
|
204
|
+
// Already opted in: auth.setup.ts exists from a prior approval (and the config
|
|
205
|
+
// already registers it), so engage AUTOMATICALLY — don't re-ask or re-edit.
|
|
206
|
+
const authSetupExists = existsSync(join(dir, 'auth.setup.ts'));
|
|
207
|
+
// Engage the fixture when a login is detected AND we can register the setup
|
|
208
|
+
// project: we scaffold the config (no user config), the caller approved editing
|
|
209
|
+
// it (opts.authFixture, Stage 4), or the fixture was already set up earlier.
|
|
210
|
+
const engage = detectedPrefix > 0 && (!userConfigName || opts.authFixture === true || authSetupExists);
|
|
211
|
+
const authPrefix = engage ? detectedPrefix : 0;
|
|
212
|
+
const authFile = engage ? AUTH_STATE_FILE : undefined;
|
|
213
|
+
let authFixtureOffer;
|
|
214
|
+
if (authFile) {
|
|
215
|
+
// Login lifted to setup.ts → a login Page Object fold would double it up.
|
|
216
|
+
match = null;
|
|
217
|
+
try {
|
|
218
|
+
await writeFile(join(dir, 'auth.setup.ts'), renderAuthSetup(cleanActions.slice(0, authPrefix), authFile, opts.startUrl), 'utf-8');
|
|
219
|
+
}
|
|
220
|
+
catch { /* auth.setup generation is best-effort, never breaks Save */ }
|
|
221
|
+
// Approved edit to an EXISTING user config → register the setup project.
|
|
222
|
+
if (userConfigName && opts.authFixture) {
|
|
223
|
+
try {
|
|
224
|
+
const p = join(opts.devRoot, userConfigName);
|
|
225
|
+
const edited = addSetupProjectToConfig(readFileSync(p, 'utf-8'));
|
|
226
|
+
if (edited)
|
|
227
|
+
await writeFile(p, edited, 'utf-8');
|
|
228
|
+
}
|
|
229
|
+
catch { /* config edit is best-effort; the spec still has the paste hint */ }
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
else if (detectedPrefix > 0 && userConfigName && !opts.authFixture) {
|
|
233
|
+
// Login detected but a user config exists and edit wasn't approved → keep
|
|
234
|
+
// login inline, and surface the proposed config edit for the UI to offer.
|
|
235
|
+
try {
|
|
236
|
+
const proposed = addSetupProjectToConfig(readFileSync(join(opts.devRoot, userConfigName), 'utf-8'));
|
|
237
|
+
// Absolute path so the extension can read the file directly (for the diff
|
|
238
|
+
// preview) without knowing the project root.
|
|
239
|
+
if (proposed)
|
|
240
|
+
authFixtureOffer = { configPath: join(opts.devRoot, userConfigName), proposedConfig: proposed };
|
|
241
|
+
}
|
|
242
|
+
catch { /* offer is best-effort */ }
|
|
243
|
+
}
|
|
244
|
+
// Debt-2: a Tier-1 (client-resettable) recipe → generate the shared
|
|
245
|
+
// resetState() helper and call it in a beforeEach so the spec re-enters from a
|
|
246
|
+
// clean state every run. Tier 2/3 emit no reset (backend state isn't
|
|
247
|
+
// client-resettable). Best-effort: helper generation must never break Save.
|
|
248
|
+
const emitReset = opts.resetRecipe?.tier === 1;
|
|
249
|
+
if (emitReset) {
|
|
250
|
+
try {
|
|
251
|
+
await ensureResetStateHelper(opts.devRoot, opts.resetRecipe.storageKeys ?? []);
|
|
252
|
+
}
|
|
253
|
+
catch { /* helper generation is best-effort */ }
|
|
254
|
+
}
|
|
255
|
+
const source = renderSpec(slug, displayName, opts.description ?? '', cleanSteps, opts.assertions ?? [], match, omitted, opts.startUrl, emitReset, authPrefix, authFile);
|
|
71
256
|
await writeFile(path, source, 'utf-8');
|
|
257
|
+
// Specs use relative URLs (page.goto("/")), which need a `baseURL` in the
|
|
258
|
+
// project's Playwright config. If the project has NO config at all, the saved
|
|
259
|
+
// spec fails on the first goto with "Cannot navigate to invalid URL" — which
|
|
260
|
+
// breaks Hover's core promise that the saved artifact is plain Playwright that
|
|
261
|
+
// just runs. Scaffold a minimal config in that case. Best-effort: it must
|
|
262
|
+
// never break Save-as-spec, and it never overwrites an existing config.
|
|
263
|
+
try {
|
|
264
|
+
await ensurePlaywrightConfig(opts.devRoot, steps, opts.startUrl, authFile);
|
|
265
|
+
}
|
|
266
|
+
catch { /* config scaffolding is best-effort */ }
|
|
72
267
|
// Persist the structured session next to the spec so cross-session
|
|
73
268
|
// extraction (F4) and the optimization pass (F7) read real SpecStep[]
|
|
74
269
|
// instead of parsing the generated code. Lands in .hover/, which
|
|
75
270
|
// Playwright's *.spec.ts glob never collects.
|
|
76
271
|
await writeSidecar(opts.devRoot, {
|
|
77
272
|
slug,
|
|
78
|
-
name:
|
|
79
|
-
steps
|
|
273
|
+
name: displayName,
|
|
274
|
+
steps,
|
|
80
275
|
assertions: opts.assertions ?? [],
|
|
81
276
|
});
|
|
82
|
-
|
|
277
|
+
// Session-ledger patch, best-effort by contract: markSessionSaved swallows
|
|
278
|
+
// its own failures — it must never break Save-as-spec.
|
|
279
|
+
const promptText = rawSteps.find(s => s.kind === 'user')?.text;
|
|
280
|
+
if (promptText)
|
|
281
|
+
await markSessionSaved(opts.devRoot, promptText, slug);
|
|
282
|
+
return { path, slug, files: [{ path, slug, flow: displayName }], authFixtureOffer };
|
|
83
283
|
}
|
|
84
284
|
// Escape sequences that would prematurely terminate the JSDoc block.
|
|
85
285
|
// (Backtick literal of close-comment sequence omitted on purpose — see how
|
|
@@ -106,13 +306,16 @@ function collectExpected(assertions, doneSummary) {
|
|
|
106
306
|
}
|
|
107
307
|
return [];
|
|
108
308
|
}
|
|
109
|
-
function renderSpec(slug, displayName, description, steps, assertions, match
|
|
309
|
+
function renderSpec(slug, displayName, description, steps, assertions, match, omitted = 0, startUrl, emitReset = false,
|
|
310
|
+
// Auth-as-fixture: number of leading ACTION steps that form the login flow
|
|
311
|
+
// (lifted into auth.setup.ts) and the storageState path the spec reuses. When
|
|
312
|
+
// authFile is set, the login prefix is skipped from the body and the spec
|
|
313
|
+
// starts already authenticated via `test.use({ storageState })`.
|
|
314
|
+
authPrefix = 0, authFile) {
|
|
110
315
|
const userMsg = steps.find(s => s.kind === 'user');
|
|
111
316
|
const doneMsg = [...steps].reverse().find(s => s.kind === 'done');
|
|
112
317
|
// Plain-English step + expected blocks for the JSDoc header. QA / PMs
|
|
113
|
-
// can read these without grokking Playwright API
|
|
114
|
-
// populates the Step column when the user exports the session to Xray
|
|
115
|
-
// CSV via writeCaseCsv.
|
|
318
|
+
// can read these without grokking the Playwright API.
|
|
116
319
|
const proseSteps = humanSteps(steps);
|
|
117
320
|
const expectedLines = collectExpected(assertions, doneMsg?.summary);
|
|
118
321
|
// ── Walk the steps into the test body first, so we know whether any F6
|
|
@@ -126,7 +329,12 @@ function renderSpec(slug, displayName, description, steps, assertions, match) {
|
|
|
126
329
|
let pageVar = 'page';
|
|
127
330
|
let popupCount = 0;
|
|
128
331
|
let usesContext = false;
|
|
129
|
-
|
|
332
|
+
// Auth-as-fixture: drop the leading login steps from the business spec — they
|
|
333
|
+
// run once in auth.setup.ts, and `test.use({ storageState })` (added below)
|
|
334
|
+
// makes this spec start authenticated. Slicing keeps the popup/tab-pairing
|
|
335
|
+
// logic below operating on the business flow only.
|
|
336
|
+
const allActions = steps.filter(s => s.kind === 'step' && !!s.tool);
|
|
337
|
+
const actions = authFile && authPrefix > 0 ? allActions.slice(authPrefix) : allActions;
|
|
130
338
|
for (let i = 0; i < actions.length; i++) {
|
|
131
339
|
const s = actions[i];
|
|
132
340
|
const next = actions[i + 1];
|
|
@@ -174,6 +382,24 @@ function renderSpec(slug, displayName, description, steps, assertions, match) {
|
|
|
174
382
|
if (s.tool !== 'browser_navigate')
|
|
175
383
|
sawInteraction = true;
|
|
176
384
|
}
|
|
385
|
+
// Guarantee the spec opens the app. The agent often connects to an
|
|
386
|
+
// already-open debug-Chrome tab and never calls browser_navigate, so the
|
|
387
|
+
// captured session can lack any navigation — a spec with no page.goto() runs
|
|
388
|
+
// against about:blank and every locator fails. When no navigation was
|
|
389
|
+
// captured, synthesize a leading goto from the run's target URL.
|
|
390
|
+
// Check the BUSINESS actions (login prefix already sliced out): when auth was
|
|
391
|
+
// lifted to setup.ts, its navigate goes with it, so the spec must synthesize
|
|
392
|
+
// its own goto to land on the (now authenticated) app.
|
|
393
|
+
const hasNavigate = actions.some(s => s.tool === 'browser_navigate');
|
|
394
|
+
// Fall back to the lifted login's navigate URL: with auth-as-fixture the app's
|
|
395
|
+
// goto went into auth.setup.ts, so the business spec would otherwise start on
|
|
396
|
+
// about:blank. (storageState restores the session but does NOT navigate.)
|
|
397
|
+
const gotoTarget = startUrl ?? (authFile ? firstNavigateUrl(steps) : null);
|
|
398
|
+
if (!hasNavigate && gotoTarget) {
|
|
399
|
+
const gotoBlock = [];
|
|
400
|
+
pushTestStep(gotoBlock, `Given · Open ${gotoTarget}`, [`await page.goto(${JSON.stringify(stripBaseUrl(gotoTarget))});`]);
|
|
401
|
+
body.unshift(...gotoBlock);
|
|
402
|
+
}
|
|
177
403
|
// Then: Alt-click assertions group under the report's final stage.
|
|
178
404
|
if (assertions.length > 0 && body.length > 0)
|
|
179
405
|
body.push('');
|
|
@@ -186,6 +412,22 @@ function renderSpec(slug, displayName, description, steps, assertions, match) {
|
|
|
186
412
|
lines.push(match
|
|
187
413
|
? `import { test, expect } from './fixtures';`
|
|
188
414
|
: `import { test, expect } from '@playwright/test';`);
|
|
415
|
+
// Auth-as-fixture: reuse the session captured once by auth.setup.ts, so this
|
|
416
|
+
// spec starts already logged in (the recorded login steps live in the setup
|
|
417
|
+
// project, not inline here).
|
|
418
|
+
if (authFile) {
|
|
419
|
+
lines.push('');
|
|
420
|
+
lines.push(`test.use({ storageState: ${JSON.stringify(authFile)} });`);
|
|
421
|
+
}
|
|
422
|
+
// Debt-2: shared reset helper + a beforeEach so every run starts from a clean
|
|
423
|
+
// client state (the recipe was confirmed reproducible during recon).
|
|
424
|
+
if (emitReset) {
|
|
425
|
+
lines.push(`import { resetState } from './support/resetState';`);
|
|
426
|
+
lines.push('');
|
|
427
|
+
lines.push(`test.beforeEach(async ({ page, context }) => {`);
|
|
428
|
+
lines.push(` await resetState(page, context);`);
|
|
429
|
+
lines.push(`});`);
|
|
430
|
+
}
|
|
189
431
|
lines.push('');
|
|
190
432
|
lines.push('/**');
|
|
191
433
|
lines.push(` * Generated by Hover on ${new Date().toISOString().slice(0, 10)}.`);
|
|
@@ -201,8 +443,20 @@ function renderSpec(slug, displayName, description, steps, assertions, match) {
|
|
|
201
443
|
if (expectedLines.length > 0) {
|
|
202
444
|
lines.push(' *');
|
|
203
445
|
lines.push(' * Expected:');
|
|
204
|
-
for (const e of expectedLines)
|
|
205
|
-
|
|
446
|
+
for (const e of expectedLines) {
|
|
447
|
+
// Prefix EVERY line — a multi-line entry must not break out of the JSDoc
|
|
448
|
+
// block (an unprefixed continuation line escapes the comment).
|
|
449
|
+
const [head, ...rest] = jsdocEscape(e).split('\n');
|
|
450
|
+
lines.push(` * • ${head}`);
|
|
451
|
+
for (const cont of rest)
|
|
452
|
+
lines.push(` * ${cont}`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (omitted > 0) {
|
|
456
|
+
lines.push(' *');
|
|
457
|
+
lines.push(` * Note: ${omitted} exploratory/failed step${omitted === 1 ? '' : 's'} from the session`);
|
|
458
|
+
lines.push(' * were omitted from this runnable flow (the full capture is kept in');
|
|
459
|
+
lines.push(' * .hover/sidecars for the optimization pass).');
|
|
206
460
|
}
|
|
207
461
|
lines.push(' *');
|
|
208
462
|
lines.push(' * Selectors prefer getByRole / getByLabel / getByTestId — generated from');
|
|
@@ -227,6 +481,58 @@ function renderSpec(slug, displayName, description, steps, assertions, match) {
|
|
|
227
481
|
lines.push('');
|
|
228
482
|
return lines.join('\n');
|
|
229
483
|
}
|
|
484
|
+
/** Where the auth-fixture saves/reuses the authenticated session. */
|
|
485
|
+
const AUTH_STATE_FILE = 'playwright/.auth/user.json';
|
|
486
|
+
/**
|
|
487
|
+
* Auth-as-fixture (debt 3): render the `auth.setup.ts` Playwright setup project
|
|
488
|
+
* from the recorded login prefix. It replays the login ONCE and saves
|
|
489
|
+
* `storageState`, which every spec then reuses via `test.use({ storageState })`
|
|
490
|
+
* — so login isn't re-run per test. `authActions` are the leading (already
|
|
491
|
+
* redacted) login steps; `startUrl` synthesizes a leading goto when the captured
|
|
492
|
+
* login lacked its own navigation (agent connected to an open tab).
|
|
493
|
+
*/
|
|
494
|
+
function renderAuthSetup(authActions, authFile, startUrl) {
|
|
495
|
+
const body = [];
|
|
496
|
+
if (!authActions.some(s => s.tool === 'browser_navigate') && startUrl) {
|
|
497
|
+
body.push(` await page.goto(${JSON.stringify(stripBaseUrl(startUrl))});`);
|
|
498
|
+
}
|
|
499
|
+
for (const s of authActions) {
|
|
500
|
+
const lines = translateStep(s.tool, s.input, 'page');
|
|
501
|
+
if (lines.length === 0)
|
|
502
|
+
continue;
|
|
503
|
+
// Block-scope each step: every translated interaction declares its own
|
|
504
|
+
// `const el`, so without a block the second step redeclares it (a JS error).
|
|
505
|
+
body.push(' {');
|
|
506
|
+
for (const line of lines)
|
|
507
|
+
body.push(` ${line}`);
|
|
508
|
+
body.push(' }');
|
|
509
|
+
}
|
|
510
|
+
body.push(` await context.storageState({ path: authFile });`);
|
|
511
|
+
return [
|
|
512
|
+
// `expect` is used by each step's visibility prelude — import it too.
|
|
513
|
+
`import { test as setup, expect } from '@playwright/test';`,
|
|
514
|
+
``,
|
|
515
|
+
`/**`,
|
|
516
|
+
` * Generated by Hover — authenticates ONCE, then specs reuse the saved`,
|
|
517
|
+
` * session via test.use({ storageState }). Login was lifted out of the specs`,
|
|
518
|
+
` * so it no longer re-runs per test.`,
|
|
519
|
+
` *`,
|
|
520
|
+
` * If you have your OWN playwright.config, register this setup project so it`,
|
|
521
|
+
` * runs before your specs:`,
|
|
522
|
+
` *`,
|
|
523
|
+
` * projects: [`,
|
|
524
|
+
` * { name: 'setup', testMatch: /.*\\.setup\\.ts$/ },`,
|
|
525
|
+
` * { name: 'chromium', dependencies: ['setup'] },`,
|
|
526
|
+
` * ]`,
|
|
527
|
+
` */`,
|
|
528
|
+
`const authFile = ${JSON.stringify(authFile)};`,
|
|
529
|
+
``,
|
|
530
|
+
`setup('authenticate', async ({ page, context }) => {`,
|
|
531
|
+
...body,
|
|
532
|
+
`});`,
|
|
533
|
+
``,
|
|
534
|
+
].join('\n');
|
|
535
|
+
}
|
|
230
536
|
/** Push one `await test.step('<label>', async () => { … })` block (4-space
|
|
231
537
|
* body indent) onto the assembled spec lines. */
|
|
232
538
|
function pushTestStep(out, label, inner) {
|
|
@@ -315,9 +621,73 @@ function flowArgValues(steps) {
|
|
|
315
621
|
}
|
|
316
622
|
return out;
|
|
317
623
|
}
|
|
318
|
-
function translateStep(
|
|
624
|
+
function translateStep(rawTool, rawInput, pageVar = 'page') {
|
|
319
625
|
const input = (rawInput ?? {});
|
|
626
|
+
// Non-playwright Hover MCP tools keep their mcp__<server>__ prefix; strip it
|
|
627
|
+
// so the switch matches (playwright tools are already bare `browser_*`).
|
|
628
|
+
// The server-name segment is kebab-case (`hover-control`, `hover-source`), so
|
|
629
|
+
// the class MUST include `-`; a lazy quantifier stops at the first `__` so the
|
|
630
|
+
// tool name (which may contain `_`) is preserved. Missing the hyphen here used
|
|
631
|
+
// to drop every Hover-MCP step (e.g. check_control) to an optimizable marker.
|
|
632
|
+
const tool = rawTool.replace(/^mcp__[a-z0-9_-]+?__/, '');
|
|
320
633
|
switch (tool) {
|
|
634
|
+
// Hover control-actuation tools → deterministic, grounded role/testid/text
|
|
635
|
+
// selectors (the agent passed these from the snapshot, so they replay).
|
|
636
|
+
case 'check_control': {
|
|
637
|
+
const role = String(input.role ?? 'radio');
|
|
638
|
+
const name = String(input.name ?? '');
|
|
639
|
+
const action = input.checked === false ? 'uncheck' : 'check';
|
|
640
|
+
// { force: true } mirrors what check_control did at record time — these
|
|
641
|
+
// are sr-only inputs behind a styled label, so a normal .check() fails
|
|
642
|
+
// the actionability hit-test ("<span> intercepts pointer events"). Force
|
|
643
|
+
// skips it, the way a label click forwards to the hidden input.
|
|
644
|
+
return [`await ${pageVar}.getByRole(${JSON.stringify(role)}, { name: ${JSON.stringify(name)}, exact: true }).${action}({ force: true });`];
|
|
645
|
+
}
|
|
646
|
+
case 'click_control':
|
|
647
|
+
return emitInteraction(groundedSelector(input, pageVar), 'click()');
|
|
648
|
+
case 'fill_control':
|
|
649
|
+
return emitInteraction(groundedSelector(input, pageVar), `fill(${renderFillValue(String(input.value ?? ''))})`);
|
|
650
|
+
case 'select_control': {
|
|
651
|
+
// A <select> is role 'combobox'; default it so a name-only step resolves.
|
|
652
|
+
const withRole = input.role ? input : { ...input, role: input.name ? 'combobox' : undefined };
|
|
653
|
+
return emitInteraction(groundedSelector(withRole, pageVar), `selectOption(${JSON.stringify(String(input.value ?? ''))})`);
|
|
654
|
+
}
|
|
655
|
+
case 'upload_file': {
|
|
656
|
+
// setInputFiles directly on the file <input> (mirrors fileInput() in
|
|
657
|
+
// mcp/actuateServer.ts) — no filechooser dialog. placeholder mode
|
|
658
|
+
// references the committed fixture; otherwise the user-supplied path.
|
|
659
|
+
const sel = fileInputSelector(input, pageVar);
|
|
660
|
+
const rel = input.placeholder ? '__vibe_tests__/fixtures/hover-placeholder.png' : String(input.path ?? '');
|
|
661
|
+
return [`await ${sel}.setInputFiles(${JSON.stringify(rel)});`];
|
|
662
|
+
}
|
|
663
|
+
case 'assert_visible': {
|
|
664
|
+
// A captured verification → an expect(...). groundedSelector already
|
|
665
|
+
// swaps a dynamic name/text for a stable anchor, so the locator is sound;
|
|
666
|
+
// here we pick the MATCHER by volatility — a dynamic value never freezes
|
|
667
|
+
// to a literal even if the agent passed matcher 'text-exact'.
|
|
668
|
+
// groundedSelector ALREADY appends `.first()` for text / dynamic-role
|
|
669
|
+
// anchors, so only add one when it didn't (avoid `.first().first()`).
|
|
670
|
+
const groundExpr = groundedSelector(input, pageVar);
|
|
671
|
+
const sel = groundExpr.endsWith('.first()') ? groundExpr : `${groundExpr}.first()`;
|
|
672
|
+
const dynamic = input.dynamic === true;
|
|
673
|
+
const expected = input.expected != null ? String(input.expected)
|
|
674
|
+
: input.observed != null ? String(input.observed) : '';
|
|
675
|
+
switch (String(input.matcher ?? 'visible')) {
|
|
676
|
+
case 'non-empty':
|
|
677
|
+
return [`await expect(${sel}).not.toHaveText('');`];
|
|
678
|
+
case 'text-contains':
|
|
679
|
+
return [`await expect(${sel}).toContainText(${JSON.stringify(expected)});`];
|
|
680
|
+
case 'text-exact':
|
|
681
|
+
return dynamic
|
|
682
|
+
? [`await expect(${sel}).not.toHaveText('');`]
|
|
683
|
+
: [`await expect(${sel}).toHaveText(${JSON.stringify(expected)});`];
|
|
684
|
+
case 'count':
|
|
685
|
+
return [`await expect(${groundedSelector(input, pageVar)}).toHaveCount(${Number(input.count ?? 1)});`];
|
|
686
|
+
case 'visible':
|
|
687
|
+
default:
|
|
688
|
+
return [`await expect(${sel}).toBeVisible();`];
|
|
689
|
+
}
|
|
690
|
+
}
|
|
321
691
|
case 'browser_navigate': {
|
|
322
692
|
const url = String(input.url ?? '');
|
|
323
693
|
const path = stripBaseUrl(url);
|
|
@@ -337,19 +707,19 @@ function translateStep(tool, rawInput, pageVar = 'page') {
|
|
|
337
707
|
const target = f.name ?? f.element ?? '';
|
|
338
708
|
// Each field gets its own block scope so the per-field `const el`
|
|
339
709
|
// declarations don't collide inside the step's shared test.step closure.
|
|
340
|
-
return blockScope(emitInteraction(selectorForFormField(target, f.type, pageVar), `fill(${
|
|
710
|
+
return blockScope(emitInteraction(selectorForFormField(target, f.type, pageVar), `fill(${renderFillValue(value)})`));
|
|
341
711
|
});
|
|
342
712
|
}
|
|
343
713
|
case 'browser_type': {
|
|
344
714
|
const text = String(input.text ?? '');
|
|
345
715
|
const target = String(input.element ?? '');
|
|
346
|
-
return emitInteraction(selectorFromDescription(target, pageVar), `fill(${
|
|
716
|
+
return emitInteraction(selectorFromDescription(target, pageVar), `fill(${renderFillValue(text)})`);
|
|
347
717
|
}
|
|
348
718
|
case 'browser_select_option': {
|
|
349
719
|
const target = String(input.element ?? '');
|
|
350
720
|
const values = input.values;
|
|
351
721
|
const val = (values && values.length > 0 ? values[0] : input.value) ?? '';
|
|
352
|
-
return emitInteraction(
|
|
722
|
+
return emitInteraction(selectorForSelect(target, pageVar), `selectOption(${JSON.stringify(String(val))})`);
|
|
353
723
|
}
|
|
354
724
|
case 'browser_press_key': {
|
|
355
725
|
const key = String(input.key ?? '');
|
|
@@ -371,7 +741,7 @@ function translateStep(tool, rawInput, pageVar = 'page') {
|
|
|
371
741
|
// A real action with no single-step translation. Leave a structured
|
|
372
742
|
// marker (not a TODO) so the optimization pass / seed library can
|
|
373
743
|
// complete it; the deterministic draft stays runnable around it.
|
|
374
|
-
return [`${OPTIMIZABLE_MARKER}: ${tool} — no single-step translation; the optimization pass
|
|
744
|
+
return [`${OPTIMIZABLE_MARKER}: ${tool} — no single-step translation; the optimization pass can complete this`];
|
|
375
745
|
}
|
|
376
746
|
}
|
|
377
747
|
/**
|
|
@@ -430,6 +800,82 @@ export function selectorFromDescription(desc, pageVar = 'page') {
|
|
|
430
800
|
return `${pageVar}.getByText(${JSON.stringify(quoted[1])})`;
|
|
431
801
|
return `${pageVar}.getByText(${JSON.stringify(trimmed)})`;
|
|
432
802
|
}
|
|
803
|
+
/**
|
|
804
|
+
* Selector for a Hover control-actuation step (click/fill/select_control). The
|
|
805
|
+
* agent supplied these fields straight from the snapshot, in the same priority
|
|
806
|
+
* order the actuation server resolves them — role+name → testId → text — so the
|
|
807
|
+
* crystallized selector is exactly the one that drove the action at record time
|
|
808
|
+
* (no free-form description, hence no confabulation). Mirrors
|
|
809
|
+
* `locate()` in `mcp/actuateServer.ts`.
|
|
810
|
+
*/
|
|
811
|
+
function groundedSelector(input, pageVar = 'page') {
|
|
812
|
+
const role = typeof input.role === 'string' ? input.role : '';
|
|
813
|
+
const name = typeof input.name === 'string' ? input.name : '';
|
|
814
|
+
const testId = typeof input.testId === 'string' ? input.testId : '';
|
|
815
|
+
const text = typeof input.text === 'string' ? input.text : '';
|
|
816
|
+
// `within` scopes to a container first (e.g. getByRole('radiogroup', { name:
|
|
817
|
+
// 'pep' })) so a repeated option label / a display:none input resolves to one
|
|
818
|
+
// match inside the right group. Mirrors locate() in mcp/actuateServer.ts.
|
|
819
|
+
const w = input.within;
|
|
820
|
+
const base = w && typeof w.role === 'string' && typeof w.name === 'string'
|
|
821
|
+
? `${pageVar}.getByRole(${JSON.stringify(w.role)}, { name: ${JSON.stringify(w.name)}, exact: true })`
|
|
822
|
+
: pageVar;
|
|
823
|
+
// dynamic: the agent flagged `name`/`text` as content that varies run-to-run
|
|
824
|
+
// (a drawn word, a generated id), so freezing it as an exact-name selector
|
|
825
|
+
// would miss next run. Anchor on something stable instead: testId, then a
|
|
826
|
+
// content-free role (scoped by `within` when present), then `.first()`.
|
|
827
|
+
if (input.dynamic === true) {
|
|
828
|
+
if (testId)
|
|
829
|
+
return `${base}.getByTestId(${JSON.stringify(testId)})`;
|
|
830
|
+
if (role)
|
|
831
|
+
return `${base}.getByRole(${JSON.stringify(role)}).first()`;
|
|
832
|
+
// No stable anchor available — fall through to the literal logic below; the
|
|
833
|
+
// step is still recorded but brittle (a later anchor pass can harden it).
|
|
834
|
+
}
|
|
835
|
+
// exact: true — the agent passed the exact accessible name from the snapshot,
|
|
836
|
+
// so match it exactly. Without it, getByRole's default substring match makes
|
|
837
|
+
// "street" also resolve "previous street" → strict-mode violation on replay.
|
|
838
|
+
if (role && name)
|
|
839
|
+
return `${base}.getByRole(${JSON.stringify(role)}, { name: ${JSON.stringify(name)}, exact: true })`;
|
|
840
|
+
if (testId)
|
|
841
|
+
return `${base}.getByTestId(${JSON.stringify(testId)})`;
|
|
842
|
+
// .first(): a label-wrapped control's text matches the <label> AND its inner
|
|
843
|
+
// <span> → strict-mode violation; the first (outer label) is clickable.
|
|
844
|
+
if (text)
|
|
845
|
+
return `${base}.getByText(${JSON.stringify(text)}).first()`;
|
|
846
|
+
return `${base}.locator('body')`;
|
|
847
|
+
}
|
|
848
|
+
/** Selector for upload_file's target file <input> — by label, testId, or the
|
|
849
|
+
* single file input (optionally `within`-scoped). Mirrors fileInput() in
|
|
850
|
+
* mcp/actuateServer.ts. */
|
|
851
|
+
function fileInputSelector(input, pageVar = 'page') {
|
|
852
|
+
const name = typeof input.name === 'string' ? input.name : '';
|
|
853
|
+
const testId = typeof input.testId === 'string' ? input.testId : '';
|
|
854
|
+
const w = input.within;
|
|
855
|
+
const base = w && typeof w.role === 'string' && typeof w.name === 'string'
|
|
856
|
+
? `${pageVar}.getByRole(${JSON.stringify(w.role)}, { name: ${JSON.stringify(w.name)}, exact: true })`
|
|
857
|
+
: pageVar;
|
|
858
|
+
if (name)
|
|
859
|
+
return `${base}.getByLabel(${JSON.stringify(name)})`;
|
|
860
|
+
if (testId)
|
|
861
|
+
return `${base}.getByTestId(${JSON.stringify(testId)})`;
|
|
862
|
+
return `${base}.locator('input[type="file"]')`;
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* browser_select_option always targets a native `<select>` — whose ARIA role
|
|
866
|
+
* is `combobox`. The agent's description is usually the label ("marital
|
|
867
|
+
* status"), with no role keyword, so selectorFromDescription would fall back to
|
|
868
|
+
* getByText and match the *label text*, not the control — and `.selectOption()`
|
|
869
|
+
* on a text node throws. Force the combobox role by accessible name instead.
|
|
870
|
+
*/
|
|
871
|
+
export function selectorForSelect(desc, pageVar = 'page') {
|
|
872
|
+
const name = desc.trim()
|
|
873
|
+
.replace(/\s+(combobox|select|dropdown|listbox)$/i, '') // drop a trailing role keyword
|
|
874
|
+
.replace(/^"|"$/g, '');
|
|
875
|
+
if (!name)
|
|
876
|
+
return `${pageVar}.locator('select')`;
|
|
877
|
+
return `${pageVar}.getByRole('combobox', { name: ${JSON.stringify(name)} })`;
|
|
878
|
+
}
|
|
433
879
|
/**
|
|
434
880
|
* Form fields from browser_fill_form have a `name` that's typically the
|
|
435
881
|
* accessible name / label / aria-label. getByLabel is the right primitive.
|
|
@@ -466,6 +912,135 @@ function mapInputType(type) {
|
|
|
466
912
|
default: return null;
|
|
467
913
|
}
|
|
468
914
|
}
|
|
915
|
+
/** Playwright config filenames Playwright itself recognizes. If any exists we
|
|
916
|
+
* assume the user owns baseURL config and never scaffold. */
|
|
917
|
+
const PLAYWRIGHT_CONFIG_NAMES = [
|
|
918
|
+
'playwright.config.ts', 'playwright.config.js', 'playwright.config.mjs',
|
|
919
|
+
'playwright.config.cjs', 'playwright.config.mts', 'playwright.config.cts',
|
|
920
|
+
];
|
|
921
|
+
/** Parse a URL's origin, or null if it isn't an absolute http(s) URL. */
|
|
922
|
+
function originOf(url) {
|
|
923
|
+
if (!url || !/^https?:\/\//.test(url))
|
|
924
|
+
return null;
|
|
925
|
+
try {
|
|
926
|
+
return new URL(url).origin;
|
|
927
|
+
}
|
|
928
|
+
catch {
|
|
929
|
+
return null;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
/** Origin of the first real navigation captured in the session, e.g.
|
|
933
|
+
* http://localhost:5175 — the natural baseURL for the scaffolded config. */
|
|
934
|
+
function firstNavigateOrigin(steps) {
|
|
935
|
+
for (const s of steps) {
|
|
936
|
+
if (s.kind !== 'step' || s.tool !== 'browser_navigate')
|
|
937
|
+
continue;
|
|
938
|
+
const url = String(s.input?.url ?? '');
|
|
939
|
+
if (/^https?:\/\//.test(url)) {
|
|
940
|
+
try {
|
|
941
|
+
return new URL(url).origin;
|
|
942
|
+
}
|
|
943
|
+
catch { /* not a parseable URL */ }
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
return null;
|
|
947
|
+
}
|
|
948
|
+
/** The FULL url of the first browser_navigate in the session (not just origin).
|
|
949
|
+
* Used to seed the business spec's goto when auth-as-fixture lifted the login's
|
|
950
|
+
* navigation into auth.setup.ts and no explicit startUrl was supplied. */
|
|
951
|
+
function firstNavigateUrl(steps) {
|
|
952
|
+
for (const s of steps) {
|
|
953
|
+
if (s.kind === 'step' && s.tool === 'browser_navigate') {
|
|
954
|
+
const url = String(s.input?.url ?? '');
|
|
955
|
+
if (url)
|
|
956
|
+
return url;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
return null;
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* Bug A fix: crystallized specs use relative URLs, so a project with no
|
|
963
|
+
* Playwright config (hence no baseURL) can't run them — `page.goto("/")` throws
|
|
964
|
+
* "Cannot navigate to invalid URL". When no config exists, scaffold a minimal
|
|
965
|
+
* one with baseURL inferred from the session's first navigation. Never touches
|
|
966
|
+
* an existing config (the user owns it), and skips silently if no origin can be
|
|
967
|
+
* inferred (leaves it to the user rather than guessing).
|
|
968
|
+
*/
|
|
969
|
+
/**
|
|
970
|
+
* Debt-2 (reproducible state isolation): write the shared `support/resetState.ts`
|
|
971
|
+
* helper that crystallized specs call in a beforeEach. It navigates to the app
|
|
972
|
+
* (baseURL), clears client state, and reloads — so each run starts clean. The
|
|
973
|
+
* goto-first ordering matters: localStorage is per-origin, so clearing it on the
|
|
974
|
+
* initial about:blank would be a no-op. `keys` (the recipe's storageKeys) scopes
|
|
975
|
+
* the localStorage clear when only some keys gate state (leaving e.g. an auth
|
|
976
|
+
* token); empty = clear all web storage. Regenerated on every save so it tracks
|
|
977
|
+
* the current recipe. User-facing Playwright code → lives under __vibe_tests__/.
|
|
978
|
+
*/
|
|
979
|
+
async function ensureResetStateHelper(devRoot, keys) {
|
|
980
|
+
const dir = join(devRoot, '__vibe_tests__', 'support');
|
|
981
|
+
await mkdir(dir, { recursive: true });
|
|
982
|
+
const source = [
|
|
983
|
+
`import type { Page, BrowserContext } from '@playwright/test';`,
|
|
984
|
+
``,
|
|
985
|
+
`/**`,
|
|
986
|
+
` * Generated by Hover — resets the app to a clean client-side state before`,
|
|
987
|
+
` * each test, so runs are reproducible. The reset recipe was discovered (and`,
|
|
988
|
+
` * verified) during exploration and lives in .hover/environments.json;`,
|
|
989
|
+
` * re-crystallize to regenerate this file.`,
|
|
990
|
+
` */`,
|
|
991
|
+
`const KEYS: string[] = ${JSON.stringify(keys)};`,
|
|
992
|
+
``,
|
|
993
|
+
`export async function resetState(page: Page, context: BrowserContext): Promise<void> {`,
|
|
994
|
+
` // goto first: localStorage is per-origin, so it can only be cleared once`,
|
|
995
|
+
` // the app's origin is loaded (baseURL comes from the Playwright config).`,
|
|
996
|
+
` await page.goto('/');`,
|
|
997
|
+
` await context.clearCookies();`,
|
|
998
|
+
` await page.evaluate((keys) => {`,
|
|
999
|
+
` if (keys.length) { for (const k of keys) localStorage.removeItem(k); }`,
|
|
1000
|
+
` else { localStorage.clear(); sessionStorage.clear(); }`,
|
|
1001
|
+
` }, KEYS);`,
|
|
1002
|
+
` await page.reload();`,
|
|
1003
|
+
`}`,
|
|
1004
|
+
``,
|
|
1005
|
+
].join('\n');
|
|
1006
|
+
await writeFile(join(dir, 'resetState.ts'), source, 'utf-8');
|
|
1007
|
+
}
|
|
1008
|
+
async function ensurePlaywrightConfig(devRoot, steps, startUrl, authFile) {
|
|
1009
|
+
if (PLAYWRIGHT_CONFIG_NAMES.some(n => existsSync(join(devRoot, n))))
|
|
1010
|
+
return;
|
|
1011
|
+
const origin = firstNavigateOrigin(steps) ?? originOf(startUrl);
|
|
1012
|
+
if (!origin)
|
|
1013
|
+
return;
|
|
1014
|
+
// Auth-as-fixture: register a `setup` project (matches auth.setup.ts) that the
|
|
1015
|
+
// main project depends on, so login runs ONCE before the specs. Only emitted
|
|
1016
|
+
// when scaffolding our own config (we never touch a user's existing one).
|
|
1017
|
+
const projects = authFile
|
|
1018
|
+
? [
|
|
1019
|
+
` projects: [`,
|
|
1020
|
+
` { name: 'setup', testMatch: /.*\\.setup\\.ts$/ },`,
|
|
1021
|
+
` { name: 'chromium', dependencies: ['setup'] },`,
|
|
1022
|
+
` ],`,
|
|
1023
|
+
]
|
|
1024
|
+
: [];
|
|
1025
|
+
const source = [
|
|
1026
|
+
`import { defineConfig } from '@playwright/test';`,
|
|
1027
|
+
``,
|
|
1028
|
+
`/**`,
|
|
1029
|
+
` * Scaffolded by Hover so crystallized specs (which use relative URLs like`,
|
|
1030
|
+
` * page.goto("/")) resolve against a base. Override HOVER_BASE_URL in CI to`,
|
|
1031
|
+
` * point the same specs at staging/prod.`,
|
|
1032
|
+
` */`,
|
|
1033
|
+
`export default defineConfig({`,
|
|
1034
|
+
` testDir: './__vibe_tests__',`,
|
|
1035
|
+
` use: {`,
|
|
1036
|
+
` baseURL: process.env.HOVER_BASE_URL ?? ${JSON.stringify(origin)},`,
|
|
1037
|
+
` },`,
|
|
1038
|
+
...projects,
|
|
1039
|
+
`});`,
|
|
1040
|
+
``,
|
|
1041
|
+
].join('\n');
|
|
1042
|
+
await writeFile(join(devRoot, 'playwright.config.ts'), source, 'utf-8');
|
|
1043
|
+
}
|
|
469
1044
|
function stripBaseUrl(url) {
|
|
470
1045
|
// http://localhost:5173/checkout → /checkout, http://localhost:5173/ → /
|
|
471
1046
|
if (!/^https?:\/\//.test(url))
|