@hover-dev/core 0.14.1 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -1
- package/dist/agents/aider.d.ts.map +1 -1
- package/dist/agents/aider.js +6 -14
- package/dist/agents/claude.d.ts.map +1 -1
- package/dist/agents/claude.js +14 -0
- package/dist/agents/codex.d.ts.map +1 -1
- package/dist/agents/codex.js +10 -4
- package/dist/agents/cursor.d.ts.map +1 -1
- package/dist/agents/cursor.js +8 -17
- package/dist/agents/gemini.d.ts.map +1 -1
- package/dist/agents/gemini.js +3 -14
- package/dist/agents/invoke.d.ts.map +1 -1
- package/dist/agents/invoke.js +10 -1
- package/dist/agents/qwen.d.ts.map +1 -1
- package/dist/agents/qwen.js +3 -14
- package/dist/agents/shared.d.ts +28 -0
- package/dist/agents/shared.d.ts.map +1 -0
- package/dist/agents/shared.js +35 -0
- package/dist/agents/types.d.ts +11 -0
- package/dist/agents/types.d.ts.map +1 -1
- package/dist/mcp/sourceFence.d.ts +23 -0
- package/dist/mcp/sourceFence.d.ts.map +1 -0
- package/dist/mcp/sourceFence.js +75 -0
- package/dist/mcp/sourceServer.d.ts +3 -0
- package/dist/mcp/sourceServer.d.ts.map +1 -0
- package/dist/mcp/sourceServer.js +116 -0
- package/dist/playwright/preflight.d.ts.map +1 -1
- package/dist/playwright/preflight.js +6 -1
- package/dist/playwright/raiseWindow.d.ts.map +1 -1
- package/dist/playwright/raiseWindow.js +22 -3
- package/dist/playwright/resolveMcpConfig.d.ts +11 -0
- package/dist/playwright/resolveMcpConfig.d.ts.map +1 -1
- package/dist/playwright/resolveMcpConfig.js +17 -3
- package/dist/plugin-api.d.ts +7 -0
- package/dist/plugin-api.d.ts.map +1 -1
- package/dist/runSession.d.ts +42 -0
- package/dist/runSession.d.ts.map +1 -0
- package/dist/runSession.js +81 -0
- package/dist/service/cdpHandlers.d.ts +3 -7
- package/dist/service/cdpHandlers.d.ts.map +1 -1
- package/dist/service/cdpHandlers.js +4 -16
- package/dist/service/cdpHint.d.ts.map +1 -1
- package/dist/service/cdpHint.js +30 -14
- package/dist/service/conventions.d.ts +8 -0
- package/dist/service/conventions.d.ts.map +1 -0
- package/dist/service/conventions.js +42 -0
- package/dist/service/saveHandlers.d.ts +10 -13
- package/dist/service/saveHandlers.d.ts.map +1 -1
- package/dist/service/saveHandlers.js +9 -25
- package/dist/service/types.d.ts +5 -0
- package/dist/service/types.d.ts.map +1 -1
- package/dist/service.d.ts +13 -4
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +264 -148
- package/dist/skills/writeSkill.d.ts +12 -35
- package/dist/skills/writeSkill.d.ts.map +1 -1
- package/dist/skills/writeSkill.js +10 -166
- package/dist/specs/detectSharedFlows.d.ts +35 -0
- package/dist/specs/detectSharedFlows.d.ts.map +1 -0
- package/dist/specs/detectSharedFlows.js +171 -0
- package/dist/specs/extractPageObjects.d.ts +18 -0
- package/dist/specs/extractPageObjects.d.ts.map +1 -0
- package/dist/specs/extractPageObjects.js +98 -0
- package/dist/specs/generatePageObject.d.ts +29 -0
- package/dist/specs/generatePageObject.d.ts.map +1 -0
- package/dist/specs/generatePageObject.js +149 -0
- package/dist/specs/listSpecs.d.ts +12 -0
- package/dist/specs/listSpecs.d.ts.map +1 -1
- package/dist/specs/listSpecs.js +27 -2
- package/dist/specs/optimizationSuggestion.d.ts +26 -0
- package/dist/specs/optimizationSuggestion.d.ts.map +1 -0
- package/dist/specs/optimizationSuggestion.js +28 -0
- package/dist/specs/optimizeSpec.d.ts +42 -0
- package/dist/specs/optimizeSpec.d.ts.map +1 -0
- package/dist/specs/optimizeSpec.js +188 -0
- package/dist/specs/optimizeSpecWithAgent.d.ts +11 -0
- package/dist/specs/optimizeSpecWithAgent.d.ts.map +1 -0
- package/dist/specs/optimizeSpecWithAgent.js +40 -0
- package/dist/specs/pageObjectManifest.d.ts +20 -0
- package/dist/specs/pageObjectManifest.d.ts.map +1 -0
- package/dist/specs/pageObjectManifest.js +40 -0
- package/dist/specs/seeds.d.ts +36 -0
- package/dist/specs/seeds.d.ts.map +1 -0
- package/dist/specs/seeds.js +74 -0
- package/dist/specs/sidecar.d.ts +25 -0
- package/dist/specs/sidecar.d.ts.map +1 -0
- package/dist/specs/sidecar.js +38 -0
- package/dist/specs/softBatch.d.ts +14 -0
- package/dist/specs/softBatch.d.ts.map +1 -0
- package/dist/specs/softBatch.js +177 -0
- package/dist/specs/text.d.ts +17 -0
- package/dist/specs/text.d.ts.map +1 -0
- package/dist/specs/text.js +24 -0
- package/dist/specs/writeCaseCsv.d.ts.map +1 -1
- package/dist/specs/writeCaseCsv.js +2 -8
- package/dist/specs/writeSpec.d.ts +50 -0
- package/dist/specs/writeSpec.d.ts.map +1 -1
- package/dist/specs/writeSpec.js +251 -84
- package/package.json +5 -3
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface SeedRule {
|
|
2
|
+
/** Identifier, e.g. `download`. */
|
|
3
|
+
name: string;
|
|
4
|
+
/** Rough match signature — tool names (optionally `tool:detail`), used only
|
|
5
|
+
* to pick relevant seeds for a spec, NOT for exact matching. */
|
|
6
|
+
signature: string[];
|
|
7
|
+
/** One-line human note: what the pattern is / when it applies. */
|
|
8
|
+
note?: string;
|
|
9
|
+
/** A concrete worked example the LLM generalizes from. */
|
|
10
|
+
example: {
|
|
11
|
+
steps: unknown[];
|
|
12
|
+
code: string;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Built-in seeds ship with core and feed EVERY project's optimization pass, so
|
|
17
|
+
* the bar is high: a pattern qualifies as built-in ONLY if it's a *highly
|
|
18
|
+
* certain* optimization — a fixed, app-agnostic translation whose output is
|
|
19
|
+
* deterministic and can't mislead (e.g. download → waitForEvent pairing).
|
|
20
|
+
*
|
|
21
|
+
* Deliberately NOT built-in:
|
|
22
|
+
* - Semantic / judgement-based optimizations (e.g. WHICH feedback text to
|
|
23
|
+
* assert) — those are already standing instructions in buildOptimizePrompt,
|
|
24
|
+
* and a bad generalization would pollute every user's spec.
|
|
25
|
+
* - Popup/new-tab — hardcoded in the translator (writeSpec), not a seed.
|
|
26
|
+
* Project-specific or speculative patterns live in <root>/.hover/rules/, where
|
|
27
|
+
* the bar is the user's own call.
|
|
28
|
+
*/
|
|
29
|
+
export declare const BUILTIN_SEEDS: SeedRule[];
|
|
30
|
+
/** Built-in seeds + any in <projectRoot>/.hover/rules/*.json. Malformed files
|
|
31
|
+
* are skipped rather than failing the whole read. */
|
|
32
|
+
export declare function readSeeds(projectRoot: string): Promise<SeedRule[]>;
|
|
33
|
+
/** Pick seeds whose signature's base tool appears in the spec — a cheap
|
|
34
|
+
* relevance filter so the prompt only carries plausibly-applicable examples. */
|
|
35
|
+
export declare function relevantSeeds(seeds: SeedRule[], specTools: Set<string>, cap?: number): SeedRule[];
|
|
36
|
+
//# sourceMappingURL=seeds.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"seeds.d.ts","sourceRoot":"","sources":["../../src/specs/seeds.ts"],"names":[],"mappings":"AAgBA,MAAM,WAAW,QAAQ;IACvB,mCAAmC;IACnC,IAAI,EAAE,MAAM,CAAC;IACb;qEACiE;IACjE,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,kEAAkE;IAClE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,0DAA0D;IAC1D,OAAO,EAAE;QAAE,KAAK,EAAE,OAAO,EAAE,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;CAC7C;AAED;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,aAAa,EAAE,QAAQ,EAenC,CAAC;AAEF;sDACsD;AACtD,wBAAsB,SAAS,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC,CAiBxE;AAED;iFACiF;AACjF,wBAAgB,aAAa,CAAC,KAAK,EAAE,QAAQ,EAAE,EAAE,SAAS,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,GAAG,SAAI,GAAG,QAAQ,EAAE,CAG5F"}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Community translation seeds (Stage 6, approach A): human-written worked
|
|
3
|
+
* examples that teach the optimization pass (F7) new patterns by few-shot,
|
|
4
|
+
* NOT by deterministic match+template. A seed is a rough `signature` (tool
|
|
5
|
+
* names, used only to pick relevant seeds) + a concrete `example`
|
|
6
|
+
* (input steps → output code) the LLM generalizes from.
|
|
7
|
+
*
|
|
8
|
+
* Sources: a small built-in set + the project's <projectRoot>/.hover/rules/.
|
|
9
|
+
* Adding a pattern = dropping an example JSON in .hover/rules/ — no core change.
|
|
10
|
+
*
|
|
11
|
+
* Built-in seeds deliberately cover patterns the deterministic translator does
|
|
12
|
+
* NOT hardcode (popup is already hardcoded in writeSpec, so it's not here).
|
|
13
|
+
*/
|
|
14
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
/**
|
|
17
|
+
* Built-in seeds ship with core and feed EVERY project's optimization pass, so
|
|
18
|
+
* the bar is high: a pattern qualifies as built-in ONLY if it's a *highly
|
|
19
|
+
* certain* optimization — a fixed, app-agnostic translation whose output is
|
|
20
|
+
* deterministic and can't mislead (e.g. download → waitForEvent pairing).
|
|
21
|
+
*
|
|
22
|
+
* Deliberately NOT built-in:
|
|
23
|
+
* - Semantic / judgement-based optimizations (e.g. WHICH feedback text to
|
|
24
|
+
* assert) — those are already standing instructions in buildOptimizePrompt,
|
|
25
|
+
* and a bad generalization would pollute every user's spec.
|
|
26
|
+
* - Popup/new-tab — hardcoded in the translator (writeSpec), not a seed.
|
|
27
|
+
* Project-specific or speculative patterns live in <root>/.hover/rules/, where
|
|
28
|
+
* the bar is the user's own call.
|
|
29
|
+
*/
|
|
30
|
+
export const BUILTIN_SEEDS = [
|
|
31
|
+
{
|
|
32
|
+
name: 'download',
|
|
33
|
+
signature: ['browser_click'],
|
|
34
|
+
note: 'a click that triggers a file download → pair with waitForEvent("download")',
|
|
35
|
+
example: {
|
|
36
|
+
steps: [{ tool: 'browser_click', element: 'Export CSV button' }],
|
|
37
|
+
code: "const [download] = await Promise.all([\n" +
|
|
38
|
+
" page.waitForEvent('download'),\n" +
|
|
39
|
+
" page.getByRole('button', { name: 'Export CSV' }).click(),\n" +
|
|
40
|
+
"]);\n" +
|
|
41
|
+
"expect(await download.suggestedFilename()).toContain('.csv');",
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
/** Built-in seeds + any in <projectRoot>/.hover/rules/*.json. Malformed files
|
|
46
|
+
* are skipped rather than failing the whole read. */
|
|
47
|
+
export async function readSeeds(projectRoot) {
|
|
48
|
+
const out = [...BUILTIN_SEEDS];
|
|
49
|
+
try {
|
|
50
|
+
const dir = join(projectRoot, '.hover', 'rules');
|
|
51
|
+
for (const f of await readdir(dir)) {
|
|
52
|
+
if (!f.endsWith('.json'))
|
|
53
|
+
continue;
|
|
54
|
+
try {
|
|
55
|
+
const s = JSON.parse(await readFile(join(dir, f), 'utf-8'));
|
|
56
|
+
if (s && s.name && Array.isArray(s.signature) && s.example?.code)
|
|
57
|
+
out.push(s);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
/* skip malformed seed file */
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
/* no .hover/rules/ directory */
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
/** Pick seeds whose signature's base tool appears in the spec — a cheap
|
|
70
|
+
* relevance filter so the prompt only carries plausibly-applicable examples. */
|
|
71
|
+
export function relevantSeeds(seeds, specTools, cap = 6) {
|
|
72
|
+
const hits = seeds.filter(s => s.signature.some(sig => specTools.has(sig.split(':')[0])));
|
|
73
|
+
return hits.slice(0, cap);
|
|
74
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { SkillStep } from '../skills/writeSkill.js';
|
|
2
|
+
import type { SpecAssertion } from './writeSpec.js';
|
|
3
|
+
/** Current sidecar schema version. Bump when the shape changes so readers
|
|
4
|
+
* (Stage 2 detection, Stage 7 optimization) can migrate or skip cleanly. */
|
|
5
|
+
export declare const SIDECAR_VERSION = 1;
|
|
6
|
+
export interface SpecSidecar {
|
|
7
|
+
version: number;
|
|
8
|
+
slug: string;
|
|
9
|
+
name: string;
|
|
10
|
+
/** ISO timestamp the sidecar was written. */
|
|
11
|
+
createdAt: string;
|
|
12
|
+
/** The full captured session, structured and verbatim — never re-derived
|
|
13
|
+
* from the generated `.spec.ts`. */
|
|
14
|
+
steps: SkillStep[];
|
|
15
|
+
/** Alt-click assertions captured alongside the session. */
|
|
16
|
+
assertions: SpecAssertion[];
|
|
17
|
+
}
|
|
18
|
+
/** Sidecar directory under the spec output dir. Dot-prefixed on purpose:
|
|
19
|
+
* Playwright's default `*.spec.ts` glob never reaches into `.hover/`. */
|
|
20
|
+
export declare function sidecarDir(devRoot: string): string;
|
|
21
|
+
/** Write the structured-session sidecar at `.hover/<slug>.json`. Caller passes
|
|
22
|
+
* the data minus the stamped fields (`version`, `createdAt`), which this
|
|
23
|
+
* function fills. Returns the absolute path written. */
|
|
24
|
+
export declare function writeSidecar(devRoot: string, data: Omit<SpecSidecar, 'version' | 'createdAt'>): Promise<string>;
|
|
25
|
+
//# sourceMappingURL=sidecar.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sidecar.d.ts","sourceRoot":"","sources":["../../src/specs/sidecar.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;AACzD,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;yCACqC;IACrC,KAAK,EAAE,SAAS,EAAE,CAAC;IACnB,2DAA2D;IAC3D,UAAU,EAAE,aAAa,EAAE,CAAC;CAC7B;AAED;0EAC0E;AAC1E,wBAAgB,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAElD;AAED;;yDAEyD;AACzD,wBAAsB,YAAY,CAChC,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,IAAI,CAAC,WAAW,EAAE,SAAS,GAAG,WAAW,CAAC,GAC/C,OAAO,CAAC,MAAM,CAAC,CAWjB"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured-session sidecar for a generated spec.
|
|
3
|
+
*
|
|
4
|
+
* Every saved spec gets a companion `__vibe_tests__/.hover/<slug>.json` holding
|
|
5
|
+
* the original structured `SpecStep[]` (plus assertions + metadata). The
|
|
6
|
+
* `.spec.ts` is the human / CI artifact; this sidecar is the machine record
|
|
7
|
+
* that downstream work reads instead of parsing generated code:
|
|
8
|
+
* - F4 cross-session extraction signature-matches `steps` across sidecars.
|
|
9
|
+
* - F7 optimization pass feeds the draft + this sidecar to the LLM.
|
|
10
|
+
*
|
|
11
|
+
* It lands in a dot-prefixed `.hover/` dir so Playwright's default `*.spec.ts`
|
|
12
|
+
* glob never collects it, and it is pure data — no Hover runtime import.
|
|
13
|
+
*/
|
|
14
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
/** Current sidecar schema version. Bump when the shape changes so readers
|
|
17
|
+
* (Stage 2 detection, Stage 7 optimization) can migrate or skip cleanly. */
|
|
18
|
+
export const SIDECAR_VERSION = 1;
|
|
19
|
+
/** Sidecar directory under the spec output dir. Dot-prefixed on purpose:
|
|
20
|
+
* Playwright's default `*.spec.ts` glob never reaches into `.hover/`. */
|
|
21
|
+
export function sidecarDir(devRoot) {
|
|
22
|
+
return join(devRoot, '__vibe_tests__', '.hover');
|
|
23
|
+
}
|
|
24
|
+
/** Write the structured-session sidecar at `.hover/<slug>.json`. Caller passes
|
|
25
|
+
* the data minus the stamped fields (`version`, `createdAt`), which this
|
|
26
|
+
* function fills. Returns the absolute path written. */
|
|
27
|
+
export async function writeSidecar(devRoot, data) {
|
|
28
|
+
const dir = sidecarDir(devRoot);
|
|
29
|
+
await mkdir(dir, { recursive: true });
|
|
30
|
+
const path = join(dir, `${data.slug}.json`);
|
|
31
|
+
const sidecar = {
|
|
32
|
+
version: SIDECAR_VERSION,
|
|
33
|
+
createdAt: new Date().toISOString(),
|
|
34
|
+
...data,
|
|
35
|
+
};
|
|
36
|
+
await writeFile(path, JSON.stringify(sidecar, null, 2) + '\n', 'utf-8');
|
|
37
|
+
return path;
|
|
38
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** A trailing run with fewer than this many assertions is left alone —
|
|
2
|
+
* `expect.soft` only earns its keep when ≥2 failures could be collected. */
|
|
3
|
+
export declare const MIN_RUN = 2;
|
|
4
|
+
export interface SoftBatchResult {
|
|
5
|
+
/** The (possibly) rewritten source. */
|
|
6
|
+
code: string;
|
|
7
|
+
/** Whether anything changed. */
|
|
8
|
+
changed: boolean;
|
|
9
|
+
/** How many assertions were softened across all tests. */
|
|
10
|
+
softened: number;
|
|
11
|
+
}
|
|
12
|
+
/** Run the soft-batch step over a spec's source text. Pure: text in, text out. */
|
|
13
|
+
export declare function softBatch(source: string): SoftBatchResult;
|
|
14
|
+
//# sourceMappingURL=softBatch.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"softBatch.d.ts","sourceRoot":"","sources":["../../src/specs/softBatch.ts"],"names":[],"mappings":"AAgCA;6EAC6E;AAC7E,eAAO,MAAM,OAAO,IAAI,CAAC;AAEzB,MAAM,WAAW,eAAe;IAC9B,uCAAuC;IACvC,IAAI,EAAE,MAAM,CAAC;IACb,gCAAgC;IAChC,OAAO,EAAE,OAAO,CAAC;IACjB,0DAA0D;IAC1D,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,kFAAkF;AAClF,wBAAgB,SAAS,CAAC,MAAM,EAAE,MAAM,GAAG,eAAe,CAuBzD"}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* soft-batch — a deterministic finishing step of the optimization pass.
|
|
3
|
+
*
|
|
4
|
+
* After the LLM optimize pass decides WHAT to assert (it reads the observed
|
|
5
|
+
* feedback and adds the trailing "Then" assertions), this step applies the safe
|
|
6
|
+
* mechanical rewrite: the maximal *trailing run* of independent assertions in
|
|
7
|
+
* each `test()` body goes from `expect(...)` to `expect.soft(...)`, so a
|
|
8
|
+
* field-audit spec reports every failing field in one run instead of halting on
|
|
9
|
+
* the first. This is the "LLM decides, AST executes" split — the LLM never
|
|
10
|
+
* reprints the file to do it, so it can't drift; ts-morph applies the soften
|
|
11
|
+
* surgically and every untouched node stays byte-identical.
|
|
12
|
+
*
|
|
13
|
+
* Two assertion shapes are recognised, because that is what specs look like
|
|
14
|
+
* after optimize:
|
|
15
|
+
* A. a bare `await expect(...)....` statement;
|
|
16
|
+
* B. a Hover `await test.step('Then · …', async () => { await expect(...) })`
|
|
17
|
+
* closure whose body is nothing but assertions — Hover emits one such step
|
|
18
|
+
* per observed-feedback assertion, AFTER all the action steps.
|
|
19
|
+
*
|
|
20
|
+
* Safety guard (why this is deterministic and never changes test semantics):
|
|
21
|
+
* we only ever soften an assertion that sits in the trailing run — the suffix
|
|
22
|
+
* of the test body that is assertions all the way down, with no action after
|
|
23
|
+
* it. A *gating* assertion — one a later action depends on — is by construction
|
|
24
|
+
* followed by that action, so it never lands in the trailing run and is never
|
|
25
|
+
* softened. Softening only changes how failures are *reported* (all collected
|
|
26
|
+
* vs. stop-on-first), never whether the test passes. We require ≥2 assertions
|
|
27
|
+
* in the run: soft buys nothing for a single one. `expect.soft` collects across
|
|
28
|
+
* the whole test regardless of `test.step` nesting, so softening step-wrapped
|
|
29
|
+
* assertions is sound.
|
|
30
|
+
*/
|
|
31
|
+
import { Project, SyntaxKind, Node } from 'ts-morph';
|
|
32
|
+
/** A trailing run with fewer than this many assertions is left alone —
|
|
33
|
+
* `expect.soft` only earns its keep when ≥2 failures could be collected. */
|
|
34
|
+
export const MIN_RUN = 2;
|
|
35
|
+
/** Run the soft-batch step over a spec's source text. Pure: text in, text out. */
|
|
36
|
+
export function softBatch(source) {
|
|
37
|
+
const project = new Project({
|
|
38
|
+
useInMemoryFileSystem: true,
|
|
39
|
+
compilerOptions: { allowJs: true },
|
|
40
|
+
});
|
|
41
|
+
const sf = project.createSourceFile('__spec.ts', source, { overwrite: true });
|
|
42
|
+
// Collect every assertion to soften first (across all test bodies), then
|
|
43
|
+
// apply — each edit is a localized identifier replace, so sibling targets
|
|
44
|
+
// stay valid.
|
|
45
|
+
const targets = [];
|
|
46
|
+
for (const body of testBodies(sf)) {
|
|
47
|
+
const run = trailingAssertionRun(body.getStatements());
|
|
48
|
+
const asserts = run.flatMap(bareAssertionsIn);
|
|
49
|
+
if (asserts.length >= MIN_RUN)
|
|
50
|
+
targets.push(...asserts);
|
|
51
|
+
}
|
|
52
|
+
let softened = 0;
|
|
53
|
+
for (const stmt of targets) {
|
|
54
|
+
if (soften(stmt))
|
|
55
|
+
softened++;
|
|
56
|
+
}
|
|
57
|
+
return { code: sf.getFullText(), changed: softened > 0, softened };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Yield the block body of every `test(...)` call (including `test.only` /
|
|
61
|
+
* `.skip` / `.fixme`), but NOT `test.describe` / hooks — those wrap tests, they
|
|
62
|
+
* aren't a test body.
|
|
63
|
+
*/
|
|
64
|
+
function testBodies(sf) {
|
|
65
|
+
const bodies = [];
|
|
66
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
67
|
+
if (!isTestCall(call.getExpression()))
|
|
68
|
+
continue;
|
|
69
|
+
const cb = call.getArguments().at(-1);
|
|
70
|
+
if (!cb)
|
|
71
|
+
continue;
|
|
72
|
+
if (Node.isArrowFunction(cb) || Node.isFunctionExpression(cb)) {
|
|
73
|
+
const block = cb.getBody();
|
|
74
|
+
if (Node.isBlock(block))
|
|
75
|
+
bodies.push(block);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return bodies;
|
|
79
|
+
}
|
|
80
|
+
/** `test` | `test.only` | `test.skip` | `test.fixme` — not `test.describe` or hooks. */
|
|
81
|
+
function isTestCall(callee) {
|
|
82
|
+
if (Node.isIdentifier(callee))
|
|
83
|
+
return callee.getText() === 'test';
|
|
84
|
+
if (Node.isPropertyAccessExpression(callee)) {
|
|
85
|
+
const base = callee.getExpression();
|
|
86
|
+
if (!Node.isIdentifier(base) || base.getText() !== 'test')
|
|
87
|
+
return false;
|
|
88
|
+
return ['only', 'skip', 'fixme'].includes(callee.getName());
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
/** The longest suffix of `statements` that are all assertion units (a bare
|
|
93
|
+
* assertion, or a `test.step` whose body is only assertions). */
|
|
94
|
+
function trailingAssertionRun(statements) {
|
|
95
|
+
const run = [];
|
|
96
|
+
for (let i = statements.length - 1; i >= 0; i--) {
|
|
97
|
+
if (!isAssertionUnit(statements[i]))
|
|
98
|
+
break;
|
|
99
|
+
run.unshift(statements[i]);
|
|
100
|
+
}
|
|
101
|
+
return run;
|
|
102
|
+
}
|
|
103
|
+
/** A statement that contributes only assertions: a bare assertion (case A) or
|
|
104
|
+
* an assertion-only `test.step` closure (case B). */
|
|
105
|
+
function isAssertionUnit(stmt) {
|
|
106
|
+
return isBareAssertion(stmt) || bareAssertionsInStep(stmt) !== null;
|
|
107
|
+
}
|
|
108
|
+
/** The bare-assertion statements a unit contains: itself (case A) or the
|
|
109
|
+
* assertions inside its `test.step` closure (case B). */
|
|
110
|
+
function bareAssertionsIn(stmt) {
|
|
111
|
+
if (isBareAssertion(stmt))
|
|
112
|
+
return [stmt];
|
|
113
|
+
return bareAssertionsInStep(stmt) ?? [];
|
|
114
|
+
}
|
|
115
|
+
/** True if the statement is `(await) expect(...)....` — its call chain bottoms
|
|
116
|
+
* out at an `expect` identifier. */
|
|
117
|
+
function isBareAssertion(stmt) {
|
|
118
|
+
if (!Node.isExpressionStatement(stmt))
|
|
119
|
+
return false;
|
|
120
|
+
let expr = stmt.getExpression();
|
|
121
|
+
if (Node.isAwaitExpression(expr))
|
|
122
|
+
expr = expr.getExpression();
|
|
123
|
+
return leftmostBase(expr) === 'expect';
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* If `stmt` is `await test.step(label, async () => { …only assertions… })`,
|
|
127
|
+
* return those inner assertion statements; otherwise null. The closure body
|
|
128
|
+
* must be non-empty and contain ONLY bare assertions — a step that also acts
|
|
129
|
+
* (a "When" with a check) is not a pure assertion unit and is left alone.
|
|
130
|
+
*/
|
|
131
|
+
function bareAssertionsInStep(stmt) {
|
|
132
|
+
if (!Node.isExpressionStatement(stmt))
|
|
133
|
+
return null;
|
|
134
|
+
let expr = stmt.getExpression();
|
|
135
|
+
if (Node.isAwaitExpression(expr))
|
|
136
|
+
expr = expr.getExpression();
|
|
137
|
+
if (!Node.isCallExpression(expr))
|
|
138
|
+
return null;
|
|
139
|
+
const callee = expr.getExpression();
|
|
140
|
+
if (!Node.isPropertyAccessExpression(callee) ||
|
|
141
|
+
callee.getName() !== 'step' ||
|
|
142
|
+
callee.getExpression().getText() !== 'test') {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
const cb = expr.getArguments().at(-1);
|
|
146
|
+
if (!cb || !(Node.isArrowFunction(cb) || Node.isFunctionExpression(cb)))
|
|
147
|
+
return null;
|
|
148
|
+
const block = cb.getBody();
|
|
149
|
+
if (!Node.isBlock(block))
|
|
150
|
+
return null;
|
|
151
|
+
const inner = block.getStatements();
|
|
152
|
+
if (inner.length === 0 || !inner.every(isBareAssertion))
|
|
153
|
+
return null;
|
|
154
|
+
return inner;
|
|
155
|
+
}
|
|
156
|
+
/** Descend a call/member chain to its leftmost identifier, e.g.
|
|
157
|
+
* `expect(x).toHaveText(y)` → "expect", `page.goto('/')` → "page". */
|
|
158
|
+
function leftmostBase(node) {
|
|
159
|
+
let cur = node;
|
|
160
|
+
while (cur && (Node.isCallExpression(cur) || Node.isPropertyAccessExpression(cur))) {
|
|
161
|
+
cur = cur.getExpression();
|
|
162
|
+
}
|
|
163
|
+
return cur && Node.isIdentifier(cur) ? cur.getText() : null;
|
|
164
|
+
}
|
|
165
|
+
/** Rewrite the `expect(` call inside a bare assertion statement to
|
|
166
|
+
* `expect.soft(`. Skips ones already soft (their callee is `expect.soft`, not
|
|
167
|
+
* the bare identifier `expect`). Returns whether a change was made. */
|
|
168
|
+
function soften(stmt) {
|
|
169
|
+
for (const call of stmt.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
170
|
+
const callee = call.getExpression();
|
|
171
|
+
if (Node.isIdentifier(callee) && callee.getText() === 'expect') {
|
|
172
|
+
callee.replaceWithText('expect.soft');
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small text helpers shared across the spec/CSV emitters.
|
|
3
|
+
*
|
|
4
|
+
* Hoisted here so the two crystallization outputs (writeSpec's JSDoc header and
|
|
5
|
+
* writeCaseCsv's Xray rows) derive a slug and a one-sentence "Expected" line the
|
|
6
|
+
* same way — they used to carry byte-identical copies of this logic.
|
|
7
|
+
*/
|
|
8
|
+
/** Lowercase, hyphenate, and trim a display name into a filesystem-safe slug. */
|
|
9
|
+
export declare function slugify(name: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* The first sentence of a done-summary, trimmed. Agents sometimes ramble; the
|
|
12
|
+
* Expected blocks only want the leading sentence. Splits on the gap that
|
|
13
|
+
* follows sentence-ending punctuation (`.`, `!`, `?`); a summary with no such
|
|
14
|
+
* punctuation comes back trimmed in full.
|
|
15
|
+
*/
|
|
16
|
+
export declare function firstSentence(summary: string): string;
|
|
17
|
+
//# sourceMappingURL=text.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"text.d.ts","sourceRoot":"","sources":["../../src/specs/text.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,iFAAiF;AACjF,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAM5C;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAErD"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small text helpers shared across the spec/CSV emitters.
|
|
3
|
+
*
|
|
4
|
+
* Hoisted here so the two crystallization outputs (writeSpec's JSDoc header and
|
|
5
|
+
* writeCaseCsv's Xray rows) derive a slug and a one-sentence "Expected" line the
|
|
6
|
+
* same way — they used to carry byte-identical copies of this logic.
|
|
7
|
+
*/
|
|
8
|
+
/** Lowercase, hyphenate, and trim a display name into a filesystem-safe slug. */
|
|
9
|
+
export function slugify(name) {
|
|
10
|
+
return name
|
|
11
|
+
.toLowerCase()
|
|
12
|
+
.trim()
|
|
13
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
14
|
+
.replace(/^-+|-+$/g, '');
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The first sentence of a done-summary, trimmed. Agents sometimes ramble; the
|
|
18
|
+
* Expected blocks only want the leading sentence. Splits on the gap that
|
|
19
|
+
* follows sentence-ending punctuation (`.`, `!`, `?`); a summary with no such
|
|
20
|
+
* punctuation comes back trimmed in full.
|
|
21
|
+
*/
|
|
22
|
+
export function firstSentence(summary) {
|
|
23
|
+
return summary.split(/(?<=[.!?])\s+/)[0]?.trim() ?? summary.trim();
|
|
24
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"writeCaseCsv.d.ts","sourceRoot":"","sources":["../../src/specs/writeCaseCsv.ts"],"names":[],"mappings":"AAgCA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;AAEzD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"writeCaseCsv.d.ts","sourceRoot":"","sources":["../../src/specs/writeCaseCsv.ts"],"names":[],"mappings":"AAgCA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;AAEzD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAGpD,qBAAa,kBAAmB,SAAQ,KAAK;aACf,IAAI,EAAE,MAAM;aAAkB,IAAI,EAAE,MAAM;gBAA1C,IAAI,EAAE,MAAM,EAAkB,IAAI,EAAE,MAAM;CAIvE;AAED,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,SAAS,EAAE,CAAC;IACnB,UAAU,CAAC,EAAE,aAAa,EAAE,CAAC;IAC7B;;uEAEmE;IACnE,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;+CAC2C;IAC3C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,MAAM,WAAW,kBAAkB;IAAG,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;CAAE;AAEnE,wBAAsB,YAAY,CAAC,IAAI,EAAE,mBAAmB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAiBzF"}
|
|
@@ -31,6 +31,7 @@ import { mkdir, writeFile } from 'node:fs/promises';
|
|
|
31
31
|
import { existsSync } from 'node:fs';
|
|
32
32
|
import { join } from 'node:path';
|
|
33
33
|
import { humanSteps } from './humanSteps.js';
|
|
34
|
+
import { slugify, firstSentence } from './text.js';
|
|
34
35
|
export class CaseCsvExistsError extends Error {
|
|
35
36
|
slug;
|
|
36
37
|
path;
|
|
@@ -59,13 +60,6 @@ export async function writeCaseCsv(opts) {
|
|
|
59
60
|
return { path, slug };
|
|
60
61
|
}
|
|
61
62
|
// ───────── helpers ─────────
|
|
62
|
-
function slugify(name) {
|
|
63
|
-
return name
|
|
64
|
-
.toLowerCase()
|
|
65
|
-
.trim()
|
|
66
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
67
|
-
.replace(/^-+|-+$/g, '');
|
|
68
|
-
}
|
|
69
63
|
function renderCsv(slug, opts) {
|
|
70
64
|
const rows = buildRows(slug, opts);
|
|
71
65
|
// CRLF row terminator — what Excel / Numbers / Xray's importer all
|
|
@@ -112,7 +106,7 @@ function expectedFor(assertions, steps) {
|
|
|
112
106
|
}
|
|
113
107
|
const done = [...steps].reverse().find(s => s.kind === 'done');
|
|
114
108
|
if (done?.summary) {
|
|
115
|
-
return
|
|
109
|
+
return firstSentence(done.summary);
|
|
116
110
|
}
|
|
117
111
|
return '';
|
|
118
112
|
}
|
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
import type { SkillStep } from '../skills/writeSkill.js';
|
|
2
2
|
export type SpecStep = SkillStep;
|
|
3
|
+
/**
|
|
4
|
+
* Marker the deterministic translator leaves where a captured action is a real
|
|
5
|
+
* interaction but has no single-step Playwright translation (e.g. file upload,
|
|
6
|
+
* drag, a dialog handler) — a shape that needs a multi-step pattern. It is a
|
|
7
|
+
* structured signal, not a `// TODO`: the optimization pass (F7) and the
|
|
8
|
+
* "seeds could complete this — review?" suggestion grep for it, and
|
|
9
|
+
* `countOptimizableMarkers` reads it back off a saved spec.
|
|
10
|
+
*/
|
|
11
|
+
export declare const OPTIMIZABLE_MARKER = "// hover:optimizable";
|
|
12
|
+
/** How many `// hover:optimizable` markers a generated spec carries. Used to
|
|
13
|
+
* surface "this spec has an interaction the deterministic pass couldn't fully
|
|
14
|
+
* translate — the optimization pass can complete it". */
|
|
15
|
+
export declare function countOptimizableMarkers(source: string): number;
|
|
3
16
|
export interface SpecAssertion {
|
|
4
17
|
/** Generated Playwright code (single line, no leading "await "). */
|
|
5
18
|
code: string;
|
|
@@ -24,4 +37,41 @@ export interface WriteSpecResult {
|
|
|
24
37
|
slug: string;
|
|
25
38
|
}
|
|
26
39
|
export declare function writeSpec(opts: WriteSpecOptions): Promise<WriteSpecResult>;
|
|
40
|
+
/**
|
|
41
|
+
* Emit an interaction (click / dblclick / hover / fill / selectOption) as a
|
|
42
|
+
* visibility-guarded prelude: hoist the locator to `el`, assert it's visible,
|
|
43
|
+
* then act.
|
|
44
|
+
*
|
|
45
|
+
* const el = page.getByRole('button', { name: 'Submit' });
|
|
46
|
+
* await expect(el).toBeVisible();
|
|
47
|
+
* await el.click();
|
|
48
|
+
*
|
|
49
|
+
* Why: `getByRole` is "visible OR attached" by default. A button that drifted
|
|
50
|
+
* behind a closed `<details>` / kebab menu / drawer is still in the role tree,
|
|
51
|
+
* so the locator stays green AND `.click()` may still fire — but the actual
|
|
52
|
+
* user flow has degraded. Asserting visibility first makes that drift fail
|
|
53
|
+
* loudly with "Locator expected to be visible" instead of silently passing.
|
|
54
|
+
*
|
|
55
|
+
* Scoping: renderSpec wraps each step in its own `test.step(… async () => {})`,
|
|
56
|
+
* whose closure already scopes `el`, so a single interaction needs no extra
|
|
57
|
+
* braces. browser_fill_form emits several fields into one step, so it wraps
|
|
58
|
+
* each field in `blockScope(...)` to keep the per-field `el` from colliding.
|
|
59
|
+
*/
|
|
60
|
+
export declare function emitInteraction(selectorExpr: string, action: string): string[];
|
|
61
|
+
/** Wrap lines in a `{ … }` block scope (2-space inner indent). Used by
|
|
62
|
+
* browser_fill_form so each field's `const el` lives in its own scope inside
|
|
63
|
+
* the shared test.step closure. */
|
|
64
|
+
export declare function blockScope(lines: string[]): string[];
|
|
65
|
+
/**
|
|
66
|
+
* Parse element descriptions like "Submit button" / "+1 button" / "Email
|
|
67
|
+
* textbox" / "Plan radio" into `getByRole(role, { name })` selectors. The
|
|
68
|
+
* trailing role keyword is the convention Playwright MCP uses.
|
|
69
|
+
*/
|
|
70
|
+
export declare function selectorFromDescription(desc: string, pageVar?: string): string;
|
|
71
|
+
/**
|
|
72
|
+
* Form fields from browser_fill_form have a `name` that's typically the
|
|
73
|
+
* accessible name / label / aria-label. getByLabel is the right primitive.
|
|
74
|
+
* Fall back to getByRole('textbox') if we have a hint.
|
|
75
|
+
*/
|
|
76
|
+
export declare function selectorForFormField(name: string, type?: string, pageVar?: string): string;
|
|
27
77
|
//# sourceMappingURL=writeSpec.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"writeSpec.d.ts","sourceRoot":"","sources":["../../src/specs/writeSpec.ts"],"names":[],"mappings":"AAqBA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;
|
|
1
|
+
{"version":3,"file":"writeSpec.d.ts","sourceRoot":"","sources":["../../src/specs/writeSpec.ts"],"names":[],"mappings":"AAqBA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;AAWzD,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;AAED,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,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;CACrB;AAED,MAAM,WAAW,eAAe;IAAG,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;CAAE;AAEhE,wBAAsB,SAAS,CAAC,IAAI,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,CAAC,CAiChF;AAuUD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,eAAe,CAAC,YAAY,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAM9E;AAED;;oCAEoC;AACpC,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAEpD;AAED;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,SAAS,GAAG,MAAM,CAqB9E;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,EAAE,OAAO,SAAS,GAAG,MAAM,CAQ1F"}
|