@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.
Files changed (181) hide show
  1. package/README.md +26 -55
  2. package/dist/agentDirectives.d.ts +55 -0
  3. package/dist/agentDirectives.d.ts.map +1 -0
  4. package/dist/agentDirectives.js +276 -0
  5. package/dist/engine.d.ts +28 -0
  6. package/dist/engine.d.ts.map +1 -0
  7. package/dist/engine.js +27 -0
  8. package/dist/memory/businessMemory.d.ts +29 -0
  9. package/dist/memory/businessMemory.d.ts.map +1 -0
  10. package/dist/memory/businessMemory.js +125 -0
  11. package/dist/playwright/launchChrome.d.ts +18 -0
  12. package/dist/playwright/launchChrome.d.ts.map +1 -1
  13. package/dist/playwright/launchChrome.js +46 -3
  14. package/dist/qa/candidates.d.ts +32 -0
  15. package/dist/qa/candidates.d.ts.map +1 -0
  16. package/dist/qa/candidates.js +20 -0
  17. package/dist/qa/intensity.d.ts +33 -0
  18. package/dist/qa/intensity.d.ts.map +1 -0
  19. package/dist/qa/intensity.js +25 -0
  20. package/dist/qa/qaReport.d.ts +19 -0
  21. package/dist/qa/qaReport.d.ts.map +1 -0
  22. package/dist/qa/qaReport.js +50 -0
  23. package/dist/sessions/sessions.d.ts +125 -0
  24. package/dist/sessions/sessions.d.ts.map +1 -0
  25. package/dist/sessions/sessions.js +175 -0
  26. package/dist/specs/authFixture.d.ts +30 -0
  27. package/dist/specs/authFixture.d.ts.map +1 -0
  28. package/dist/specs/authFixture.js +145 -0
  29. package/dist/specs/detectSharedFlows.d.ts +1 -1
  30. package/dist/specs/detectSharedFlows.d.ts.map +1 -1
  31. package/dist/specs/detectSharedFlows.js +20 -21
  32. package/dist/specs/generatePageObject.d.ts +1 -1
  33. package/dist/specs/generatePageObject.d.ts.map +1 -1
  34. package/dist/specs/healPrompt.d.ts +19 -0
  35. package/dist/specs/healPrompt.d.ts.map +1 -0
  36. package/dist/specs/healPrompt.js +48 -0
  37. package/dist/specs/humanSteps.d.ts +4 -8
  38. package/dist/specs/humanSteps.d.ts.map +1 -1
  39. package/dist/specs/humanSteps.js +6 -1
  40. package/dist/specs/optimizeSpec.d.ts +15 -8
  41. package/dist/specs/optimizeSpec.d.ts.map +1 -1
  42. package/dist/specs/optimizeSpec.js +71 -41
  43. package/dist/specs/pageObjectManifest.d.ts +3 -1
  44. package/dist/specs/pageObjectManifest.d.ts.map +1 -1
  45. package/dist/specs/pageObjectManifest.js +24 -19
  46. package/dist/specs/replayGrounded.d.ts +45 -0
  47. package/dist/specs/replayGrounded.d.ts.map +1 -0
  48. package/dist/specs/replayGrounded.js +155 -0
  49. package/dist/specs/runFailures.d.ts +34 -0
  50. package/dist/specs/runFailures.d.ts.map +1 -0
  51. package/dist/specs/runFailures.js +93 -0
  52. package/dist/specs/seeds.d.ts +16 -15
  53. package/dist/specs/seeds.d.ts.map +1 -1
  54. package/dist/specs/seeds.js +86 -54
  55. package/dist/specs/sidecar.d.ts +34 -6
  56. package/dist/specs/sidecar.d.ts.map +1 -1
  57. package/dist/specs/sidecar.js +79 -9
  58. package/dist/specs/specStep.d.ts +21 -0
  59. package/dist/specs/specStep.d.ts.map +1 -0
  60. package/dist/specs/specStep.js +1 -0
  61. package/dist/specs/text.d.ts +8 -6
  62. package/dist/specs/text.d.ts.map +1 -1
  63. package/dist/specs/text.js +10 -7
  64. package/dist/specs/writeSpec.d.ts +62 -1
  65. package/dist/specs/writeSpec.d.ts.map +1 -1
  66. package/dist/specs/writeSpec.js +596 -21
  67. package/package.json +9 -29
  68. package/dist/agents/aider.d.ts +0 -16
  69. package/dist/agents/aider.d.ts.map +0 -1
  70. package/dist/agents/aider.js +0 -161
  71. package/dist/agents/argv.d.ts +0 -11
  72. package/dist/agents/argv.d.ts.map +0 -1
  73. package/dist/agents/argv.js +0 -23
  74. package/dist/agents/claude.d.ts +0 -3
  75. package/dist/agents/claude.d.ts.map +0 -1
  76. package/dist/agents/claude.js +0 -195
  77. package/dist/agents/codex.d.ts +0 -19
  78. package/dist/agents/codex.d.ts.map +0 -1
  79. package/dist/agents/codex.js +0 -216
  80. package/dist/agents/cursor.d.ts +0 -18
  81. package/dist/agents/cursor.d.ts.map +0 -1
  82. package/dist/agents/cursor.js +0 -220
  83. package/dist/agents/detect.d.ts +0 -46
  84. package/dist/agents/detect.d.ts.map +0 -1
  85. package/dist/agents/detect.js +0 -80
  86. package/dist/agents/gemini.d.ts +0 -17
  87. package/dist/agents/gemini.d.ts.map +0 -1
  88. package/dist/agents/gemini.js +0 -186
  89. package/dist/agents/index.d.ts +0 -6
  90. package/dist/agents/index.d.ts.map +0 -1
  91. package/dist/agents/index.js +0 -5
  92. package/dist/agents/invoke.d.ts +0 -12
  93. package/dist/agents/invoke.d.ts.map +0 -1
  94. package/dist/agents/invoke.js +0 -96
  95. package/dist/agents/qwen.d.ts +0 -17
  96. package/dist/agents/qwen.d.ts.map +0 -1
  97. package/dist/agents/qwen.js +0 -172
  98. package/dist/agents/registry.d.ts +0 -19
  99. package/dist/agents/registry.d.ts.map +0 -1
  100. package/dist/agents/registry.js +0 -34
  101. package/dist/agents/shared.d.ts +0 -28
  102. package/dist/agents/shared.d.ts.map +0 -1
  103. package/dist/agents/shared.js +0 -35
  104. package/dist/agents/types.d.ts +0 -186
  105. package/dist/agents/types.d.ts.map +0 -1
  106. package/dist/agents/types.js +0 -23
  107. package/dist/index.d.ts +0 -3
  108. package/dist/index.d.ts.map +0 -1
  109. package/dist/index.js +0 -2
  110. package/dist/mcp/sourceFence.d.ts +0 -23
  111. package/dist/mcp/sourceFence.d.ts.map +0 -1
  112. package/dist/mcp/sourceFence.js +0 -75
  113. package/dist/mcp/sourceServer.d.ts +0 -3
  114. package/dist/mcp/sourceServer.d.ts.map +0 -1
  115. package/dist/mcp/sourceServer.js +0 -116
  116. package/dist/playwright/cdpStatus.d.ts +0 -29
  117. package/dist/playwright/cdpStatus.d.ts.map +0 -1
  118. package/dist/playwright/cdpStatus.js +0 -119
  119. package/dist/playwright/preflight.d.ts +0 -31
  120. package/dist/playwright/preflight.d.ts.map +0 -1
  121. package/dist/playwright/preflight.js +0 -82
  122. package/dist/playwright/preflightCache.d.ts +0 -27
  123. package/dist/playwright/preflightCache.d.ts.map +0 -1
  124. package/dist/playwright/preflightCache.js +0 -21
  125. package/dist/playwright/raiseWindow.d.ts +0 -10
  126. package/dist/playwright/raiseWindow.d.ts.map +0 -1
  127. package/dist/playwright/raiseWindow.js +0 -158
  128. package/dist/playwright/resolveMcpConfig.d.ts +0 -55
  129. package/dist/playwright/resolveMcpConfig.d.ts.map +0 -1
  130. package/dist/playwright/resolveMcpConfig.js +0 -66
  131. package/dist/plugin-api.d.ts +0 -235
  132. package/dist/plugin-api.d.ts.map +0 -1
  133. package/dist/plugin-api.js +0 -52
  134. package/dist/runSession.d.ts +0 -42
  135. package/dist/runSession.d.ts.map +0 -1
  136. package/dist/runSession.js +0 -81
  137. package/dist/scripts/bench-multi-tab.d.ts +0 -2
  138. package/dist/scripts/bench-multi-tab.d.ts.map +0 -1
  139. package/dist/scripts/bench-multi-tab.js +0 -192
  140. package/dist/scripts/bench-ttfb.d.ts +0 -2
  141. package/dist/scripts/bench-ttfb.d.ts.map +0 -1
  142. package/dist/scripts/bench-ttfb.js +0 -127
  143. package/dist/scripts/start-chrome.d.ts +0 -3
  144. package/dist/scripts/start-chrome.d.ts.map +0 -1
  145. package/dist/scripts/start-chrome.js +0 -23
  146. package/dist/service/cdpHandlers.d.ts +0 -44
  147. package/dist/service/cdpHandlers.d.ts.map +0 -1
  148. package/dist/service/cdpHandlers.js +0 -85
  149. package/dist/service/cdpHint.d.ts +0 -48
  150. package/dist/service/cdpHint.d.ts.map +0 -1
  151. package/dist/service/cdpHint.js +0 -216
  152. package/dist/service/conventions.d.ts +0 -8
  153. package/dist/service/conventions.d.ts.map +0 -1
  154. package/dist/service/conventions.js +0 -42
  155. package/dist/service/saveHandlers.d.ts +0 -52
  156. package/dist/service/saveHandlers.d.ts.map +0 -1
  157. package/dist/service/saveHandlers.js +0 -75
  158. package/dist/service/types.d.ts +0 -58
  159. package/dist/service/types.d.ts.map +0 -1
  160. package/dist/service/types.js +0 -26
  161. package/dist/service.d.ts +0 -50
  162. package/dist/service.d.ts.map +0 -1
  163. package/dist/service.js +0 -1065
  164. package/dist/skills/writeSkill.d.ts +0 -27
  165. package/dist/skills/writeSkill.d.ts.map +0 -1
  166. package/dist/skills/writeSkill.js +0 -13
  167. package/dist/specs/extractPageObjects.d.ts +0 -18
  168. package/dist/specs/extractPageObjects.d.ts.map +0 -1
  169. package/dist/specs/extractPageObjects.js +0 -98
  170. package/dist/specs/listSpecs.d.ts +0 -52
  171. package/dist/specs/listSpecs.d.ts.map +0 -1
  172. package/dist/specs/listSpecs.js +0 -139
  173. package/dist/specs/optimizationSuggestion.d.ts +0 -26
  174. package/dist/specs/optimizationSuggestion.d.ts.map +0 -1
  175. package/dist/specs/optimizationSuggestion.js +0 -28
  176. package/dist/specs/optimizeSpecWithAgent.d.ts +0 -11
  177. package/dist/specs/optimizeSpecWithAgent.d.ts.map +0 -1
  178. package/dist/specs/optimizeSpecWithAgent.js +0 -40
  179. package/dist/specs/writeCaseCsv.d.ts +0 -28
  180. package/dist/specs/writeCaseCsv.d.ts.map +0 -1
  181. package/dist/specs/writeCaseCsv.js +0 -134
@@ -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
- const slug = slugify(opts.name);
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
- const match = manifest ? matchPageObject(opts.steps, manifest) : null;
70
- const source = renderSpec(slug, opts.name, opts.description ?? '', opts.steps, opts.assertions ?? [], match);
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: opts.name,
79
- steps: opts.steps,
273
+ name: displayName,
274
+ steps,
80
275
  assertions: opts.assertions ?? [],
81
276
  });
82
- return { path, slug };
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; the same prose also
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
- const actions = steps.filter(s => s.kind === 'step' && !!s.tool);
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
- lines.push(` * • ${jsdocEscape(e)}`);
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(tool, rawInput, pageVar = 'page') {
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(${JSON.stringify(value)})`));
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(${JSON.stringify(text)})`);
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(selectorFromDescription(target, pageVar), `selectOption(${JSON.stringify(String(val))})`);
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 or a .hover/rules/ seed can complete this`];
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))