@hover-dev/core 0.20.0 → 0.22.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/dist/engine.d.ts +4 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +3 -0
- package/dist/specs/detectSharedFlows.d.ts.map +1 -1
- package/dist/specs/detectSharedFlows.js +18 -1
- package/dist/specs/extractPageObjects.d.ts +22 -0
- package/dist/specs/extractPageObjects.d.ts.map +1 -0
- package/dist/specs/extractPageObjects.js +118 -0
- package/dist/specs/generatePageObject.d.ts.map +1 -1
- package/dist/specs/generatePageObject.js +27 -2
- package/dist/specs/humanSteps.d.ts.map +1 -1
- package/dist/specs/humanSteps.js +29 -0
- package/dist/specs/sidecar.d.ts +16 -2
- package/dist/specs/sidecar.d.ts.map +1 -1
- package/dist/specs/sidecar.js +1 -1
- package/dist/specs/writeSpec.d.ts +22 -0
- package/dist/specs/writeSpec.d.ts.map +1 -1
- package/dist/specs/writeSpec.js +95 -18
- package/package.json +1 -1
package/dist/engine.d.ts
CHANGED
|
@@ -15,8 +15,12 @@
|
|
|
15
15
|
export { writeSpec } from './specs/writeSpec.js';
|
|
16
16
|
export type { WriteSpecOptions, WriteSpecResult, Redaction } from './specs/writeSpec.js';
|
|
17
17
|
export type { SkillStep } from './specs/specStep.js';
|
|
18
|
+
export { reRenderSpec } from './specs/writeSpec.js';
|
|
18
19
|
export { writeApiSpec } from './specs/writeApiSpec.js';
|
|
19
20
|
export type { ApiCheck, WriteApiSpecOptions, WriteApiSpecResult } from './specs/writeApiSpec.js';
|
|
21
|
+
export { extractPageObjects, detectExtractableFlows } from './specs/extractPageObjects.js';
|
|
22
|
+
export type { ExtractResult, ExtractedPage } from './specs/extractPageObjects.js';
|
|
23
|
+
export type { SharedFlow } from './specs/detectSharedFlows.js';
|
|
20
24
|
export { replayGroundedSteps, replayOnPage, applyGroundedStep, groundedLocate } from './specs/replayGrounded.js';
|
|
21
25
|
export type { ReplayResult, ReplayFailure, ReplayStep, GroundedTarget } from './specs/replayGrounded.js';
|
|
22
26
|
export { readSidecar } from './specs/sidecar.js';
|
package/dist/engine.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAGH,OAAO,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AACjD,YAAY,EAAE,gBAAgB,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AACzF,YAAY,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;
|
|
1
|
+
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAGH,OAAO,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AACjD,YAAY,EAAE,gBAAgB,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AACzF,YAAY,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AAEpD,OAAO,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAC;AACvD,YAAY,EAAE,QAAQ,EAAE,mBAAmB,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAEjG,OAAO,EAAE,kBAAkB,EAAE,sBAAsB,EAAE,MAAM,+BAA+B,CAAC;AAC3F,YAAY,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAC;AAClF,YAAY,EAAE,UAAU,EAAE,MAAM,8BAA8B,CAAC;AAE/D,OAAO,EAAE,mBAAmB,EAAE,YAAY,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AACjH,YAAY,EAAE,YAAY,EAAE,aAAa,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAEzG,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACjD,YAAY,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAGtD,OAAO,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;AACrG,YAAY,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AAGhF,OAAO,EAAE,UAAU,EAAE,qBAAqB,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAC;AACrG,YAAY,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAC;AAG/D,OAAO,EAAE,YAAY,EAAE,oBAAoB,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AACzG,YAAY,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC"}
|
package/dist/engine.js
CHANGED
|
@@ -14,8 +14,11 @@
|
|
|
14
14
|
*/
|
|
15
15
|
// ── crystallization + grounded replay ────────────────────────────────────────
|
|
16
16
|
export { writeSpec } from './specs/writeSpec.js';
|
|
17
|
+
export { reRenderSpec } from './specs/writeSpec.js';
|
|
17
18
|
// API-layer crystallizer — observed/replayed requests → *.api-test.spec.ts.
|
|
18
19
|
export { writeApiSpec } from './specs/writeApiSpec.js';
|
|
20
|
+
// Page-Object extraction — lift NON-login shared flows into pages/ + fixtures.
|
|
21
|
+
export { extractPageObjects, detectExtractableFlows } from './specs/extractPageObjects.js';
|
|
19
22
|
// Creation-verification + self-heal: replay a flow's grounded steps over CDP (no playwright test).
|
|
20
23
|
export { replayGroundedSteps, replayOnPage, applyGroundedStep, groundedLocate } from './specs/replayGrounded.js';
|
|
21
24
|
// Spec sidecar (recorded grounded steps) — read by self-heal to replay a saved spec.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"detectSharedFlows.d.ts","sourceRoot":"","sources":["../../src/specs/detectSharedFlows.ts"],"names":[],"mappings":"AAuBA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAEtD,MAAM,WAAW,UAAU;IACzB,uDAAuD;IACvD,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB;2CACuC;IACvC,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,yDAAyD;IACzD,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB;wDACoD;IACpD,WAAW,EAAE,SAAS,EAAE,CAAC;CAC1B;AAED,MAAM,WAAW,aAAa;IAC5B;2EACuE;IACvE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;sDACkD;IAClD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,
|
|
1
|
+
{"version":3,"file":"detectSharedFlows.d.ts","sourceRoot":"","sources":["../../src/specs/detectSharedFlows.ts"],"names":[],"mappings":"AAuBA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAEtD,MAAM,WAAW,UAAU;IACzB,uDAAuD;IACvD,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB;2CACuC;IACvC,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,yDAAyD;IACzD,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB;wDACoD;IACpD,WAAW,EAAE,SAAS,EAAE,CAAC;CAC1B;AAED,MAAM,WAAW,aAAa;IAC5B;2EACuE;IACvE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;sDACkD;IAClD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CA2C5E;AA+DD;;;;GAIG;AACH,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,MAAM,EACf,IAAI,GAAE,aAAkB,GACvB,OAAO,CAAC,UAAU,EAAE,CAAC,CAiCvB"}
|
|
@@ -55,11 +55,28 @@ export function stepSignature(tool, rawInput) {
|
|
|
55
55
|
}
|
|
56
56
|
case 'browser_press_key':
|
|
57
57
|
return `press:${String(i.key ?? '')}`;
|
|
58
|
+
// Grounded control tools (MCP-first): the target is role+name/testId/text on
|
|
59
|
+
// the input. The typed/selected value is data; only the target is structure.
|
|
60
|
+
case 'click_control':
|
|
61
|
+
return `click:${normGround(i)}`;
|
|
62
|
+
case 'fill_control':
|
|
63
|
+
return `type:${normGround(i)}`;
|
|
64
|
+
case 'select_control':
|
|
65
|
+
return `select:${normGround(i)}`;
|
|
66
|
+
case 'check_control':
|
|
67
|
+
return `check:${normGround(i)}`;
|
|
58
68
|
default:
|
|
59
|
-
// Diagnostics / browser_tabs / browser_wait_for — not
|
|
69
|
+
// Diagnostics / assert_visible / browser_tabs / browser_wait_for — not
|
|
70
|
+
// flow structure (an assertion isn't part of a reusable action prefix).
|
|
60
71
|
return null;
|
|
61
72
|
}
|
|
62
73
|
}
|
|
74
|
+
/** Normalize a GROUNDED target ({ role, name, testId, text }) to a stable
|
|
75
|
+
* signature fragment — the *_control sibling of normElement. */
|
|
76
|
+
function normGround(i) {
|
|
77
|
+
const parts = [i.role, i.name, i.testId, i.text].filter((v) => typeof v === 'string' && v.length > 0);
|
|
78
|
+
return normElement(parts.join('|'));
|
|
79
|
+
}
|
|
63
80
|
/** Read and parse every sidecar under `.hover/sidecars/`, unioned with any
|
|
64
81
|
* still in the legacy `__vibe_tests__/.hover/` home (current home wins on a
|
|
65
82
|
* slug collision). Malformed files are skipped (better to detect across the
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type SharedFlow } from './detectSharedFlows.js';
|
|
2
|
+
export interface ExtractedPage {
|
|
3
|
+
className: string;
|
|
4
|
+
methodName: string;
|
|
5
|
+
fileName: string;
|
|
6
|
+
path: string;
|
|
7
|
+
/** Slugs of the specs that share this flow. */
|
|
8
|
+
specs: string[];
|
|
9
|
+
}
|
|
10
|
+
export interface ExtractResult {
|
|
11
|
+
pages: ExtractedPage[];
|
|
12
|
+
fixturesPath: string | null;
|
|
13
|
+
/** Slugs re-rendered to consume the new Page Objects. */
|
|
14
|
+
folded: string[];
|
|
15
|
+
}
|
|
16
|
+
/** Detect shared flows worth extracting — NON-login prefixes shared by >= minSpecs
|
|
17
|
+
* specs. Read-only: the agent uses this to decide what to ASK the user about. */
|
|
18
|
+
export declare function detectExtractableFlows(devRoot: string, minSpecs?: number): Promise<SharedFlow[]>;
|
|
19
|
+
export declare function extractPageObjects(devRoot: string, opts?: {
|
|
20
|
+
minSpecs?: number;
|
|
21
|
+
}): Promise<ExtractResult>;
|
|
22
|
+
//# sourceMappingURL=extractPageObjects.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"extractPageObjects.d.ts","sourceRoot":"","sources":["../../src/specs/extractPageObjects.ts"],"names":[],"mappings":"AAeA,OAAO,EAAqB,KAAK,UAAU,EAAE,MAAM,wBAAwB,CAAC;AAK5E,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,+CAA+C;IAC/C,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,aAAa,EAAE,CAAC;IACvB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,yDAAyD;IACzD,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAcD;kFACkF;AAClF,wBAAsB,sBAAsB,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,SAAI,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC,CAGjG;AAED,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,MAAM,EACf,IAAI,GAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAO,GAC/B,OAAO,CAAC,aAAa,CAAC,CA+CxB"}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page Object extraction (F4) — lift flows shared across saved specs into
|
|
3
|
+
* `pages/<Name>.ts` + a `fixtures.ts` entry point, then FOLD the specs that use
|
|
4
|
+
* them (they consume `await xPage.x()` from `./fixtures` instead of repeating
|
|
5
|
+
* the steps). Fully deterministic — no LLM.
|
|
6
|
+
*
|
|
7
|
+
* MCP-first trigger: after `test_app` crystallizes the suite, the agent calls
|
|
8
|
+
* `detect_shared_flows`, ASKS the user, and on yes calls this. LOGIN prefixes
|
|
9
|
+
* are EXCLUDED — auth-fixture already lifts login into auth.setup.ts, and
|
|
10
|
+
* folding it here would double it. So this only extracts NON-login shared
|
|
11
|
+
* entry flows (the case that scales on large suites).
|
|
12
|
+
*/
|
|
13
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { detectSharedFlows } from './detectSharedFlows.js';
|
|
16
|
+
import { generatePageObject } from './generatePageObject.js';
|
|
17
|
+
import { writePageObjectManifest } from './pageObjectManifest.js';
|
|
18
|
+
import { reRenderSpec } from './writeSpec.js';
|
|
19
|
+
/** A shared prefix is a LOGIN (auth-fixture's job, not a POM) when it types a
|
|
20
|
+
* credential — the sidecar's fill value is a `process.env.<X>` ref. */
|
|
21
|
+
function isLoginPrefix(steps) {
|
|
22
|
+
return steps.some((s) => {
|
|
23
|
+
if (s.kind !== 'step')
|
|
24
|
+
return false;
|
|
25
|
+
const tool = (s.tool ?? '').replace(/^mcp__[a-z0-9_-]+?__/, '');
|
|
26
|
+
const input = (s.input ?? {});
|
|
27
|
+
const v = tool === 'browser_type' ? input.text : tool === 'fill_control' ? input.value : null;
|
|
28
|
+
return typeof v === 'string' && /process\.env\./.test(v);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/** Detect shared flows worth extracting — NON-login prefixes shared by >= minSpecs
|
|
32
|
+
* specs. Read-only: the agent uses this to decide what to ASK the user about. */
|
|
33
|
+
export async function detectExtractableFlows(devRoot, minSpecs = 2) {
|
|
34
|
+
const flows = await detectSharedFlows(devRoot, { minSpecs });
|
|
35
|
+
return flows.filter((f) => !isLoginPrefix(f.prefixSteps));
|
|
36
|
+
}
|
|
37
|
+
export async function extractPageObjects(devRoot, opts = {}) {
|
|
38
|
+
const flows = await detectExtractableFlows(devRoot, opts.minSpecs ?? 2);
|
|
39
|
+
if (flows.length === 0)
|
|
40
|
+
return { pages: [], fixturesPath: null, folded: [] };
|
|
41
|
+
const testsDir = join(devRoot, '__vibe_tests__');
|
|
42
|
+
const pagesDir = join(testsDir, 'pages');
|
|
43
|
+
await mkdir(pagesDir, { recursive: true });
|
|
44
|
+
const pages = [];
|
|
45
|
+
const entries = [];
|
|
46
|
+
const usedNames = new Set();
|
|
47
|
+
const affected = new Set();
|
|
48
|
+
for (const flow of flows) {
|
|
49
|
+
const probe = generatePageObject(flow.prefixSteps);
|
|
50
|
+
const className = uniqueName(probe.className, usedNames);
|
|
51
|
+
const po = className === probe.className ? probe : generatePageObject(flow.prefixSteps, { className });
|
|
52
|
+
const path = join(pagesDir, po.fileName);
|
|
53
|
+
await writeFile(path, po.source, 'utf-8');
|
|
54
|
+
pages.push({ className: po.className, methodName: po.methodName, fileName: po.fileName, path, specs: flow.specs });
|
|
55
|
+
entries.push({
|
|
56
|
+
className: po.className,
|
|
57
|
+
methodName: po.methodName,
|
|
58
|
+
fixtureName: fixtureName(po.className),
|
|
59
|
+
fileName: po.fileName,
|
|
60
|
+
signatures: flow.signatures,
|
|
61
|
+
specs: flow.specs,
|
|
62
|
+
});
|
|
63
|
+
flow.specs.forEach((s) => affected.add(s));
|
|
64
|
+
}
|
|
65
|
+
const fixturesPath = join(testsDir, 'fixtures.ts');
|
|
66
|
+
await writeFile(fixturesPath, renderFixtures(pages), 'utf-8');
|
|
67
|
+
// The manifest lets writeSpec's matchPageObject fold a matching prefix.
|
|
68
|
+
await writePageObjectManifest(devRoot, entries);
|
|
69
|
+
// Fold NOW: faithfully re-render each affected spec so it consumes the Page
|
|
70
|
+
// Object (via reRenderSpec, which re-applies auth-fixture/base-url from the
|
|
71
|
+
// sidecar — never regressing them). Non-login prefixes → no auth conflict.
|
|
72
|
+
const folded = [];
|
|
73
|
+
for (const slug of affected) {
|
|
74
|
+
try {
|
|
75
|
+
if (await reRenderSpec(devRoot, slug))
|
|
76
|
+
folded.push(slug);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
/* a fold is best-effort — the page/fixture still exist for future specs */
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { pages, fixturesPath, folded };
|
|
83
|
+
}
|
|
84
|
+
function renderFixtures(pages) {
|
|
85
|
+
const lines = [
|
|
86
|
+
`import { test as base } from '@playwright/test';`,
|
|
87
|
+
...pages.map((p) => `import { ${p.className} } from './pages/${p.className}';`),
|
|
88
|
+
``,
|
|
89
|
+
`/**`,
|
|
90
|
+
` * Generated by Hover — Page Object fixtures lifted from flows shared across`,
|
|
91
|
+
` * specs. In a spec: \`import { test, expect } from './fixtures';\` then consume`,
|
|
92
|
+
` * e.g. \`async ({ page, ${pages[0] ? fixtureName(pages[0].className) : 'somePage'} }) => …\`.`,
|
|
93
|
+
` */`,
|
|
94
|
+
`export const test = base.extend<{ ${pages.map((p) => `${fixtureName(p.className)}: ${p.className}`).join('; ')} }>({`,
|
|
95
|
+
...pages.flatMap((p) => [
|
|
96
|
+
` ${fixtureName(p.className)}: async ({ page }, use) => {`,
|
|
97
|
+
` await use(new ${p.className}(page));`,
|
|
98
|
+
` },`,
|
|
99
|
+
]),
|
|
100
|
+
`});`,
|
|
101
|
+
``,
|
|
102
|
+
`export { expect } from '@playwright/test';`,
|
|
103
|
+
``,
|
|
104
|
+
];
|
|
105
|
+
return lines.join('\n');
|
|
106
|
+
}
|
|
107
|
+
/** LoginPage -> loginPage. */
|
|
108
|
+
function fixtureName(className) {
|
|
109
|
+
return className.charAt(0).toLowerCase() + className.slice(1);
|
|
110
|
+
}
|
|
111
|
+
function uniqueName(base, used) {
|
|
112
|
+
let name = base;
|
|
113
|
+
let n = 2;
|
|
114
|
+
while (used.has(name))
|
|
115
|
+
name = `${base}${n++}`;
|
|
116
|
+
used.add(name);
|
|
117
|
+
return name;
|
|
118
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"generatePageObject.d.ts","sourceRoot":"","sources":["../../src/specs/generatePageObject.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"generatePageObject.d.ts","sourceRoot":"","sources":["../../src/specs/generatePageObject.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAWtD,MAAM,WAAW,gBAAgB;IAC/B,+CAA+C;IAC/C,SAAS,EAAE,MAAM,CAAC;IAClB,2CAA2C;IAC3C,UAAU,EAAE,MAAM,CAAC;IACnB,yDAAyD;IACzD,QAAQ,EAAE,MAAM,CAAC;IACjB,mCAAmC;IACnC,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,SAAS,EAAE,EAClB,QAAQ,GAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAAO,GACzD,gBAAgB,CA0FlB"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { selectorFromDescription, selectorForFormField, emitInteraction, blockScope, } from './writeSpec.js';
|
|
1
|
+
import { selectorFromDescription, selectorForFormField, emitInteraction, blockScope, groundedSelector, } from './writeSpec.js';
|
|
2
2
|
const PAGE_VAR = 'this.page';
|
|
3
3
|
export function generatePageObject(steps, override = {}) {
|
|
4
4
|
const derived = deriveNames(steps);
|
|
@@ -49,8 +49,29 @@ export function generatePageObject(steps, override = {}) {
|
|
|
49
49
|
case 'browser_press_key':
|
|
50
50
|
body.push(`await ${PAGE_VAR}.keyboard.press(${JSON.stringify(String(i.key ?? ''))});`);
|
|
51
51
|
break;
|
|
52
|
+
// Grounded control tools (MCP-first): target is role+name/testId/text on
|
|
53
|
+
// the input; the typed/selected value is data → a method parameter (D4).
|
|
54
|
+
case 'click_control':
|
|
55
|
+
body.push(...blockScope(emitInteraction(groundedSelector(i, PAGE_VAR), 'click()')));
|
|
56
|
+
break;
|
|
57
|
+
case 'fill_control': {
|
|
58
|
+
const p = uniqueParam(paramName(groundLabel(i)), used);
|
|
59
|
+
params.push(p);
|
|
60
|
+
body.push(...blockScope(emitInteraction(groundedSelector(i, PAGE_VAR), `fill(${p})`)));
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
case 'select_control': {
|
|
64
|
+
const withRole = i.role ? i : { ...i, role: i.name ? 'combobox' : undefined };
|
|
65
|
+
const p = uniqueParam(paramName(groundLabel(i)), used);
|
|
66
|
+
params.push(p);
|
|
67
|
+
body.push(...blockScope(emitInteraction(groundedSelector(withRole, PAGE_VAR), `selectOption(${p})`)));
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
case 'check_control':
|
|
71
|
+
body.push(...blockScope(emitInteraction(groundedSelector(i, PAGE_VAR), i.checked === false ? 'uncheck()' : 'check()')));
|
|
72
|
+
break;
|
|
52
73
|
default:
|
|
53
|
-
break; // diagnostics / non-replayable — skipped
|
|
74
|
+
break; // diagnostics / assert_visible / non-replayable — skipped
|
|
54
75
|
}
|
|
55
76
|
}
|
|
56
77
|
const paramList = params.map(p => `${p}: string`).join(', ');
|
|
@@ -127,6 +148,10 @@ function pascal(raw) {
|
|
|
127
148
|
}
|
|
128
149
|
/** Parameter name derived from a field/element label. */
|
|
129
150
|
const paramName = camel;
|
|
151
|
+
/** A grounded target's label (name → testId → text) for naming its data param. */
|
|
152
|
+
function groundLabel(i) {
|
|
153
|
+
return String(i.name ?? i.testId ?? i.text ?? 'value');
|
|
154
|
+
}
|
|
130
155
|
function uniqueParam(base, used) {
|
|
131
156
|
const root = base || 'value';
|
|
132
157
|
let name = root;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"humanSteps.d.ts","sourceRoot":"","sources":["../../src/specs/humanSteps.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAEtD,uEAAuE;AACvE,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,
|
|
1
|
+
{"version":3,"file":"humanSteps.d.ts","sourceRoot":"","sources":["../../src/specs/humanSteps.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAEtD,uEAAuE;AACvE,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAoExE;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,SAAS,EAAE,GAAG,MAAM,EAAE,CAmBvD"}
|
package/dist/specs/humanSteps.js
CHANGED
|
@@ -40,6 +40,18 @@ export function humanStep(tool, rawInput) {
|
|
|
40
40
|
const key = String(input.key ?? '');
|
|
41
41
|
return key ? `Press ${key}` : null;
|
|
42
42
|
}
|
|
43
|
+
// Grounded control tools (MCP-first) — the target is role+name/testId/text
|
|
44
|
+
// ON the input itself, not an `element` string.
|
|
45
|
+
case 'click_control':
|
|
46
|
+
return `Click ${describeGrounded(input)}`;
|
|
47
|
+
case 'fill_control':
|
|
48
|
+
return `Fill ${describeGrounded(input)} with ${quote(String(input.value ?? ''))}`;
|
|
49
|
+
case 'select_control':
|
|
50
|
+
return `Select ${quote(String(input.value ?? ''))} in ${describeGrounded(input)}`;
|
|
51
|
+
case 'check_control':
|
|
52
|
+
return `${input.checked === false ? 'Uncheck' : 'Check'} ${describeGrounded(input)}`;
|
|
53
|
+
case 'assert_visible':
|
|
54
|
+
return `Expect ${describeGrounded(input)} to be visible`;
|
|
43
55
|
// Diagnostic / read-only — same skip list as writeSpec.translateStep.
|
|
44
56
|
case 'browser_wait_for':
|
|
45
57
|
case 'browser_tabs':
|
|
@@ -91,6 +103,23 @@ function describe(raw) {
|
|
|
91
103
|
const s = String(raw ?? '').trim();
|
|
92
104
|
return s.length > 0 ? s : 'the target element';
|
|
93
105
|
}
|
|
106
|
+
/** Human phrase for a GROUNDED target ({ role, name, testId, text }) — the shape
|
|
107
|
+
* the *_control tools carry, vs the old browser_* tools' `element` string. */
|
|
108
|
+
function describeGrounded(input) {
|
|
109
|
+
const role = typeof input.role === 'string' ? input.role : '';
|
|
110
|
+
const name = typeof input.name === 'string' ? input.name : '';
|
|
111
|
+
const testId = typeof input.testId === 'string' ? input.testId : '';
|
|
112
|
+
const text = typeof input.text === 'string' ? input.text : '';
|
|
113
|
+
if (role && name)
|
|
114
|
+
return `${role} "${name}"`;
|
|
115
|
+
if (name)
|
|
116
|
+
return `"${name}"`;
|
|
117
|
+
if (testId)
|
|
118
|
+
return `testId "${testId}"`;
|
|
119
|
+
if (text)
|
|
120
|
+
return `"${text}"`;
|
|
121
|
+
return 'the target element';
|
|
122
|
+
}
|
|
94
123
|
/** Wrap in double-quotes for prose; escape internal quotes. A redacted
|
|
95
124
|
* credential (stored as a `process.env.X …` expression) shows as the masked
|
|
96
125
|
* `$X` instead — the prose, like the code, never reveals the secret. */
|
package/dist/specs/sidecar.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { SkillStep } from '../specs/specStep.js';
|
|
|
2
2
|
import type { SpecAssertion } from './writeSpec.js';
|
|
3
3
|
/** Current sidecar schema version. Bump when the shape changes so readers
|
|
4
4
|
* (Stage 2 detection, Stage 7 optimization) can migrate or skip cleanly. */
|
|
5
|
-
export declare const SIDECAR_VERSION =
|
|
5
|
+
export declare const SIDECAR_VERSION = 2;
|
|
6
6
|
export interface SpecSidecar {
|
|
7
7
|
version: number;
|
|
8
8
|
slug: string;
|
|
@@ -10,10 +10,24 @@ export interface SpecSidecar {
|
|
|
10
10
|
/** ISO timestamp the sidecar was written. */
|
|
11
11
|
createdAt: string;
|
|
12
12
|
/** The full captured session, structured and verbatim — never re-derived
|
|
13
|
-
* from the generated `.spec.ts`.
|
|
13
|
+
* from the generated `.spec.ts`. Steps are already REDACTED (credentials are
|
|
14
|
+
* `process.env.<X>` refs, never literals), so the sidecar carries no secret. */
|
|
14
15
|
steps: SkillStep[];
|
|
15
16
|
/** Alt-click assertions captured alongside the session. */
|
|
16
17
|
assertions: SpecAssertion[];
|
|
18
|
+
/** The env-var NAMES the run redacted credentials to (e.g. ['HOVER_PASSWORD']).
|
|
19
|
+
* Lets a re-render re-detect the login prefix without the literal secret. */
|
|
20
|
+
redactionEnvVars?: string[];
|
|
21
|
+
/** The run's target origin — re-render uses it for baseURL / goto synthesis. */
|
|
22
|
+
startUrl?: string;
|
|
23
|
+
/** Recon reset recipe (debt-2), so a re-render re-emits the reset beforeEach. */
|
|
24
|
+
resetRecipe?: {
|
|
25
|
+
tier: number;
|
|
26
|
+
storageKeys?: string[];
|
|
27
|
+
hook?: string;
|
|
28
|
+
};
|
|
29
|
+
/** Whether the run had approved editing an existing user config (Stage 4). */
|
|
30
|
+
authFixture?: boolean;
|
|
17
31
|
}
|
|
18
32
|
/** Project-root `.hover/` directory — the single home for Hover-derived data
|
|
19
33
|
* (sidecars, runs, rules, conventions). */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sidecar.d.ts","sourceRoot":"","sources":["../../src/specs/sidecar.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAEpD;6EAC6E;AAC7E,eAAO,MAAM,eAAe,IAAI,CAAC;AAEjC,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,6CAA6C;IAC7C,SAAS,EAAE,MAAM,CAAC;IAClB;
|
|
1
|
+
{"version":3,"file":"sidecar.d.ts","sourceRoot":"","sources":["../../src/specs/sidecar.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAEpD;6EAC6E;AAC7E,eAAO,MAAM,eAAe,IAAI,CAAC;AAEjC,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,6CAA6C;IAC7C,SAAS,EAAE,MAAM,CAAC;IAClB;;qFAEiF;IACjF,KAAK,EAAE,SAAS,EAAE,CAAC;IACnB,2DAA2D;IAC3D,UAAU,EAAE,aAAa,EAAE,CAAC;IAI5B;kFAC8E;IAC9E,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC5B,gFAAgF;IAChF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,iFAAiF;IACjF,WAAW,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACtE,8EAA8E;IAC9E,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED;4CAC4C;AAC5C,wBAAgB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEhD;AAED,kFAAkF;AAClF,wBAAgB,OAAO,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAEzC;AAED;;;;;gCAKgC;AAChC,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAExD;AACD,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,GAAG,MAAM,CAE/E;AACD,wBAAgB,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAErF;AAED;2EAC2E;AAC3E,wBAAgB,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAElD;AAED;mCACmC;AACnC,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAExD;AAED;;oEAEoE;AACpE,wBAAsB,YAAY,CAChC,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,IAAI,CAAC,WAAW,EAAE,SAAS,GAAG,WAAW,CAAC,GAC/C,OAAO,CAAC,MAAM,CAAC,CAWjB;AAED;;;;;GAKG;AACH,wBAAsB,WAAW,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAoB5F;AAED;;gEAEgE;AAChE,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAOhF"}
|
package/dist/specs/sidecar.js
CHANGED
|
@@ -19,7 +19,7 @@ import { mkdir, writeFile, readFile } from 'node:fs/promises';
|
|
|
19
19
|
import { join } from 'node:path';
|
|
20
20
|
/** Current sidecar schema version. Bump when the shape changes so readers
|
|
21
21
|
* (Stage 2 detection, Stage 7 optimization) can migrate or skip cleanly. */
|
|
22
|
-
export const SIDECAR_VERSION =
|
|
22
|
+
export const SIDECAR_VERSION = 2;
|
|
23
23
|
/** Project-root `.hover/` directory — the single home for Hover-derived data
|
|
24
24
|
* (sidecars, runs, rules, conventions). */
|
|
25
25
|
export function hoverDir(devRoot) {
|
|
@@ -90,6 +90,19 @@ export interface WriteSpecResult {
|
|
|
90
90
|
};
|
|
91
91
|
}
|
|
92
92
|
export declare function writeSpec(opts: WriteSpecOptions): Promise<WriteSpecResult>;
|
|
93
|
+
/**
|
|
94
|
+
* Re-crystallize an already-saved spec from its sidecar — FAITHFULLY. Used by
|
|
95
|
+
* self-heal (re-render the healed flow) and Page-Object extraction (fold a
|
|
96
|
+
* newly-extracted shared flow into the specs that use it). Reads the v2 sidecar
|
|
97
|
+
* context so the re-render re-applies the SAME auth-fixture + base URL + reset —
|
|
98
|
+
* NOT a degraded pass that would drop the login into the spec.
|
|
99
|
+
*
|
|
100
|
+
* Credentials never round-trip: the sidecar stores env-var NAMES only and its
|
|
101
|
+
* steps are already redacted, so we pass `{ value: '', envVar }` placeholders —
|
|
102
|
+
* enough for authPrefixLength to re-detect the login prefix, with no literal
|
|
103
|
+
* secret anywhere. Returns null if the spec has no sidecar.
|
|
104
|
+
*/
|
|
105
|
+
export declare function reRenderSpec(devRoot: string, slug: string): Promise<WriteSpecResult | null>;
|
|
93
106
|
/**
|
|
94
107
|
* Emit an interaction (click / dblclick / hover / fill / selectOption) as a
|
|
95
108
|
* visibility-guarded prelude: hoist the locator to `el`, assert it's visible,
|
|
@@ -121,6 +134,15 @@ export declare function blockScope(lines: string[]): string[];
|
|
|
121
134
|
* trailing role keyword is the convention Playwright MCP uses.
|
|
122
135
|
*/
|
|
123
136
|
export declare function selectorFromDescription(desc: string, pageVar?: string): string;
|
|
137
|
+
/**
|
|
138
|
+
* Selector for a Hover control-actuation step (click/fill/select_control). The
|
|
139
|
+
* agent supplied these fields straight from the snapshot, in the same priority
|
|
140
|
+
* order the actuation server resolves them — role+name → testId → text — so the
|
|
141
|
+
* crystallized selector is exactly the one that drove the action at record time
|
|
142
|
+
* (no free-form description, hence no confabulation). Mirrors
|
|
143
|
+
* `locate()` in `mcp/actuateServer.ts`.
|
|
144
|
+
*/
|
|
145
|
+
export declare function groundedSelector(input: Record<string, unknown>, pageVar?: string): string;
|
|
124
146
|
/**
|
|
125
147
|
* browser_select_option always targets a native `<select>` — whose ARIA role
|
|
126
148
|
* is `combobox`. The agent's description is usually the label ("marital
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"writeSpec.d.ts","sourceRoot":"","sources":["../../src/specs/writeSpec.ts"],"names":[],"mappings":"AAqBA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAatD,MAAM,MAAM,QAAQ,GAAG,SAAS,CAAC;AAEjC;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB,yBAAyB,CAAC;AAEzD;;0DAE0D;AAC1D,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAE9D;AA2CD,MAAM,WAAW,aAAa;IAC5B,oEAAoE;IACpE,IAAI,EAAE,MAAM,CAAC;IACb,qDAAqD;IACrD,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,qBAAa,eAAgB,SAAQ,KAAK;aACZ,IAAI,EAAE,MAAM;aAAkB,IAAI,EAAE,MAAM;gBAA1C,IAAI,EAAE,MAAM,EAAkB,IAAI,EAAE,MAAM;CAIvE;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,QAAQ,EAAE,CAAC;IAClB,UAAU,CAAC,EAAE,aAAa,EAAE,CAAC;IAC7B,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,0EAA0E;IAC1E,UAAU,CAAC,EAAE,SAAS,EAAE,CAAC;IACzB;;;;yEAIqE;IACrE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;sFAIkF;IAClF,WAAW,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACtE;;;;qEAIiE;IACjE,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAiDD,MAAM,WAAW,eAAe;IAC9B,8EAA8E;IAC9E,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,+EAA+E;IAC/E,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACtD;;;;;2DAKuD;IACvD,gBAAgB,CAAC,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,cAAc,EAAE,MAAM,CAAA;KAAE,CAAC;CACnE;AAQD,wBAAsB,SAAS,CAAC,IAAI,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,CAAC,CAchF;
|
|
1
|
+
{"version":3,"file":"writeSpec.d.ts","sourceRoot":"","sources":["../../src/specs/writeSpec.ts"],"names":[],"mappings":"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"}
|
package/dist/specs/writeSpec.js
CHANGED
|
@@ -20,7 +20,7 @@ import { mkdir, writeFile } from 'node:fs/promises';
|
|
|
20
20
|
import { existsSync, readFileSync } from 'node:fs';
|
|
21
21
|
import { join } from 'node:path';
|
|
22
22
|
import { humanSteps, humanStep } from './humanSteps.js';
|
|
23
|
-
import { writeSidecar } from './sidecar.js';
|
|
23
|
+
import { writeSidecar, readSidecar } 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';
|
|
@@ -162,6 +162,34 @@ export async function writeSpec(opts) {
|
|
|
162
162
|
// writeSecuritySpec — those are stateless and independently replayable.)
|
|
163
163
|
return writeOneSpec(opts, slugify(opts.name), opts.name, opts.steps);
|
|
164
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* Re-crystallize an already-saved spec from its sidecar — FAITHFULLY. Used by
|
|
167
|
+
* self-heal (re-render the healed flow) and Page-Object extraction (fold a
|
|
168
|
+
* newly-extracted shared flow into the specs that use it). Reads the v2 sidecar
|
|
169
|
+
* context so the re-render re-applies the SAME auth-fixture + base URL + reset —
|
|
170
|
+
* NOT a degraded pass that would drop the login into the spec.
|
|
171
|
+
*
|
|
172
|
+
* Credentials never round-trip: the sidecar stores env-var NAMES only and its
|
|
173
|
+
* steps are already redacted, so we pass `{ value: '', envVar }` placeholders —
|
|
174
|
+
* enough for authPrefixLength to re-detect the login prefix, with no literal
|
|
175
|
+
* secret anywhere. Returns null if the spec has no sidecar.
|
|
176
|
+
*/
|
|
177
|
+
export async function reRenderSpec(devRoot, slug) {
|
|
178
|
+
const sc = await readSidecar(devRoot, slug);
|
|
179
|
+
if (!sc)
|
|
180
|
+
return null;
|
|
181
|
+
return writeSpec({
|
|
182
|
+
devRoot,
|
|
183
|
+
name: sc.name,
|
|
184
|
+
steps: sc.steps,
|
|
185
|
+
assertions: sc.assertions,
|
|
186
|
+
startUrl: sc.startUrl,
|
|
187
|
+
resetRecipe: sc.resetRecipe,
|
|
188
|
+
authFixture: sc.authFixture,
|
|
189
|
+
redactions: (sc.redactionEnvVars ?? []).map((envVar) => ({ value: '', envVar })),
|
|
190
|
+
overwrite: true,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
165
193
|
/** Write ONE spec file from a (sub)set of steps. The single-file path and each
|
|
166
194
|
* per-flow file both go through here, so rendering / sidecar / config logic is
|
|
167
195
|
* identical whether or not the run was split. */
|
|
@@ -201,13 +229,17 @@ async function writeOneSpec(opts, slug, displayName, rawSteps) {
|
|
|
201
229
|
const envVars = (opts.redactions ?? []).map(r => r.envVar);
|
|
202
230
|
const detectedPrefix = authPrefixLength(cleanActions, envVars);
|
|
203
231
|
const userConfigName = PLAYWRIGHT_CONFIG_NAMES.find(n => existsSync(join(opts.devRoot, n)));
|
|
232
|
+
// A config Hover scaffolded earlier this run (e.g. a non-login spec wrote a
|
|
233
|
+
// plain one first) is OURS to upgrade — treat it like "no user config", not a
|
|
234
|
+
// hands-off user file. ensurePlaywrightConfig adds the setup project to it.
|
|
235
|
+
const ownScaffold = !!userConfigName && readFileSync(join(opts.devRoot, userConfigName), 'utf-8').includes(SCAFFOLD_MARKER);
|
|
204
236
|
// Already opted in: auth.setup.ts exists from a prior approval (and the config
|
|
205
237
|
// already registers it), so engage AUTOMATICALLY — don't re-ask or re-edit.
|
|
206
238
|
const authSetupExists = existsSync(join(dir, 'auth.setup.ts'));
|
|
207
239
|
// Engage the fixture when a login is detected AND we can register the setup
|
|
208
|
-
// project: we scaffold the config
|
|
209
|
-
//
|
|
210
|
-
const engage = detectedPrefix > 0 && (!userConfigName || opts.authFixture === true || authSetupExists);
|
|
240
|
+
// project: we scaffold/own the config, the caller approved editing a user
|
|
241
|
+
// config (opts.authFixture, Stage 4), or the fixture was already set up earlier.
|
|
242
|
+
const engage = detectedPrefix > 0 && (!userConfigName || ownScaffold || opts.authFixture === true || authSetupExists);
|
|
211
243
|
const authPrefix = engage ? detectedPrefix : 0;
|
|
212
244
|
const authFile = engage ? AUTH_STATE_FILE : undefined;
|
|
213
245
|
let authFixtureOffer;
|
|
@@ -273,6 +305,12 @@ async function writeOneSpec(opts, slug, displayName, rawSteps) {
|
|
|
273
305
|
name: displayName,
|
|
274
306
|
steps,
|
|
275
307
|
assertions: opts.assertions ?? [],
|
|
308
|
+
// v2 context — NAMES only (no literal creds), so a faithful re-render can
|
|
309
|
+
// re-apply auth-fixture + base URL + reset without re-driving the browser.
|
|
310
|
+
redactionEnvVars: (opts.redactions ?? []).map(r => r.envVar),
|
|
311
|
+
startUrl: opts.startUrl,
|
|
312
|
+
resetRecipe: opts.resetRecipe,
|
|
313
|
+
authFixture: opts.authFixture,
|
|
276
314
|
});
|
|
277
315
|
// Session-ledger patch, best-effort by contract: markSessionSaved swallows
|
|
278
316
|
// its own failures — it must never break Save-as-spec.
|
|
@@ -808,7 +846,7 @@ export function selectorFromDescription(desc, pageVar = 'page') {
|
|
|
808
846
|
* (no free-form description, hence no confabulation). Mirrors
|
|
809
847
|
* `locate()` in `mcp/actuateServer.ts`.
|
|
810
848
|
*/
|
|
811
|
-
function groundedSelector(input, pageVar = 'page') {
|
|
849
|
+
export function groundedSelector(input, pageVar = 'page') {
|
|
812
850
|
const role = typeof input.role === 'string' ? input.role : '';
|
|
813
851
|
const name = typeof input.name === 'string' ? input.name : '';
|
|
814
852
|
const testId = typeof input.testId === 'string' ? input.testId : '';
|
|
@@ -1005,28 +1043,24 @@ async function ensureResetStateHelper(devRoot, keys) {
|
|
|
1005
1043
|
].join('\n');
|
|
1006
1044
|
await writeFile(join(dir, 'resetState.ts'), source, 'utf-8');
|
|
1007
1045
|
}
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
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).
|
|
1046
|
+
const SCAFFOLD_MARKER = 'Scaffolded by Hover';
|
|
1047
|
+
/** The scaffolded config source. When `authFile` is set, a `setup` project runs
|
|
1048
|
+
* auth.setup.ts ONCE and the main `chromium` project reuses the saved
|
|
1049
|
+
* storageState — so EVERY spec starts authenticated, not just the login flow. */
|
|
1050
|
+
function renderScaffoldConfig(origin, authFile) {
|
|
1017
1051
|
const projects = authFile
|
|
1018
1052
|
? [
|
|
1019
1053
|
` projects: [`,
|
|
1020
1054
|
` { name: 'setup', testMatch: /.*\\.setup\\.ts$/ },`,
|
|
1021
|
-
` { name: 'chromium', dependencies: ['setup'] },`,
|
|
1055
|
+
` { name: 'chromium', dependencies: ['setup'], use: { storageState: ${JSON.stringify(authFile)} } },`,
|
|
1022
1056
|
` ],`,
|
|
1023
1057
|
]
|
|
1024
1058
|
: [];
|
|
1025
|
-
|
|
1059
|
+
return [
|
|
1026
1060
|
`import { defineConfig } from '@playwright/test';`,
|
|
1027
1061
|
``,
|
|
1028
1062
|
`/**`,
|
|
1029
|
-
` *
|
|
1063
|
+
` * ${SCAFFOLD_MARKER} so crystallized specs (which use relative URLs like`,
|
|
1030
1064
|
` * page.goto("/")) resolve against a base. Override HOVER_BASE_URL in CI to`,
|
|
1031
1065
|
` * point the same specs at staging/prod.`,
|
|
1032
1066
|
` */`,
|
|
@@ -1039,7 +1073,50 @@ async function ensurePlaywrightConfig(devRoot, steps, startUrl, authFile) {
|
|
|
1039
1073
|
`});`,
|
|
1040
1074
|
``,
|
|
1041
1075
|
].join('\n');
|
|
1042
|
-
|
|
1076
|
+
}
|
|
1077
|
+
async function ensurePlaywrightConfig(devRoot, steps, startUrl, authFile) {
|
|
1078
|
+
const origin = firstNavigateOrigin(steps) ?? originOf(startUrl);
|
|
1079
|
+
if (!origin)
|
|
1080
|
+
return;
|
|
1081
|
+
const existingName = PLAYWRIGHT_CONFIG_NAMES.find(n => existsSync(join(devRoot, n)));
|
|
1082
|
+
if (existingName) {
|
|
1083
|
+
// A config already exists. Only ever UPGRADE our OWN scaffold — and only to
|
|
1084
|
+
// add the auth `setup` project when a login was just lifted (authFile) and
|
|
1085
|
+
// it isn't there yet. This makes auth order-independent: whichever spec in
|
|
1086
|
+
// the run triggers auth-fixture upgrades the config, even if a non-login
|
|
1087
|
+
// spec scaffolded a plain config first. A user's own config is never touched
|
|
1088
|
+
// (that's the Stage-4 approval flow via authFixtureOffer).
|
|
1089
|
+
if (!authFile)
|
|
1090
|
+
return;
|
|
1091
|
+
try {
|
|
1092
|
+
const cur = readFileSync(join(devRoot, existingName), 'utf-8');
|
|
1093
|
+
if (!cur.includes(SCAFFOLD_MARKER) || cur.includes(`name: 'setup'`))
|
|
1094
|
+
return;
|
|
1095
|
+
await writeFile(join(devRoot, existingName), renderScaffoldConfig(origin, authFile), 'utf-8');
|
|
1096
|
+
}
|
|
1097
|
+
catch { /* upgrade is best-effort */ }
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
await writeFile(join(devRoot, 'playwright.config.ts'), renderScaffoldConfig(origin, authFile), 'utf-8');
|
|
1101
|
+
await ensurePlaywrightDep(devRoot);
|
|
1102
|
+
}
|
|
1103
|
+
/** When Hover scaffolds the config it also ensures `@playwright/test` is a
|
|
1104
|
+
* devDependency — otherwise `npx playwright test` can't run the specs locally.
|
|
1105
|
+
* Best-effort + idempotent: skips if already present (either dep list) or if
|
|
1106
|
+
* there's no package.json. Reformats to 2-space JSON (the npm norm). */
|
|
1107
|
+
const PLAYWRIGHT_TEST_RANGE = '^1.50.0';
|
|
1108
|
+
async function ensurePlaywrightDep(devRoot) {
|
|
1109
|
+
const pkgPath = join(devRoot, 'package.json');
|
|
1110
|
+
if (!existsSync(pkgPath))
|
|
1111
|
+
return;
|
|
1112
|
+
try {
|
|
1113
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
1114
|
+
if (pkg.dependencies?.['@playwright/test'] || pkg.devDependencies?.['@playwright/test'])
|
|
1115
|
+
return;
|
|
1116
|
+
pkg.devDependencies = { ...(pkg.devDependencies ?? {}), '@playwright/test': PLAYWRIGHT_TEST_RANGE };
|
|
1117
|
+
await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
|
|
1118
|
+
}
|
|
1119
|
+
catch { /* best-effort — never break Save */ }
|
|
1043
1120
|
}
|
|
1044
1121
|
function stripBaseUrl(url) {
|
|
1045
1122
|
// http://localhost:5173/checkout → /checkout, http://localhost:5173/ → /
|