@mnapoli/exspec 0.1.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/LICENSE +21 -0
- package/README.md +100 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +129 -0
- package/dist/discovery.d.ts +2 -0
- package/dist/discovery.js +26 -0
- package/dist/discovery.test.d.ts +1 -0
- package/dist/discovery.test.js +14 -0
- package/dist/env.d.ts +2 -0
- package/dist/env.js +17 -0
- package/dist/env.test.d.ts +1 -0
- package/dist/env.test.js +28 -0
- package/dist/gherkin.d.ts +4 -0
- package/dist/gherkin.js +41 -0
- package/dist/gherkin.test.d.ts +1 -0
- package/dist/gherkin.test.js +46 -0
- package/dist/prompt.d.ts +7 -0
- package/dist/prompt.js +23 -0
- package/dist/prompt.test.d.ts +1 -0
- package/dist/prompt.test.js +76 -0
- package/dist/reporter.d.ts +9 -0
- package/dist/reporter.js +71 -0
- package/dist/reporter.test.d.ts +1 -0
- package/dist/reporter.test.js +84 -0
- package/dist/runner.d.ts +6 -0
- package/dist/runner.js +170 -0
- package/dist/runner.test.d.ts +1 -0
- package/dist/runner.test.js +81 -0
- package/dist/types.d.ts +30 -0
- package/dist/types.js +1 -0
- package/package.json +67 -0
- package/prompt-template.md +114 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matthieu Napoli
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# exspec
|
|
2
|
+
|
|
3
|
+
**Executable specs** — run Gherkin feature files with an AI agent in the browser.
|
|
4
|
+
|
|
5
|
+
exspec parses `.feature` files, launches a Claude agent restricted to browser-only interaction (Playwright, headless), and produces a test report. Feature files can be written in any language supported by Gherkin (English, French, German, Spanish, [70+ languages](https://cucumber.io/docs/gherkin/languages/)).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -D @mnapoli/exspec
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Prerequisites
|
|
14
|
+
|
|
15
|
+
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# Run all feature files in features/
|
|
21
|
+
npx exspec
|
|
22
|
+
|
|
23
|
+
# Run a specific file
|
|
24
|
+
npx exspec features/Auth/Login.feature
|
|
25
|
+
|
|
26
|
+
# Run all features in a directory
|
|
27
|
+
npx exspec features/Auth/
|
|
28
|
+
|
|
29
|
+
# Filter by scenario name
|
|
30
|
+
npx exspec features/Auth/Login.feature --filter "invalid password"
|
|
31
|
+
|
|
32
|
+
# Stop at first failure
|
|
33
|
+
npx exspec --fail-fast
|
|
34
|
+
|
|
35
|
+
# Run with visible browser (for debugging)
|
|
36
|
+
npx exspec --headed
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Configuration
|
|
40
|
+
|
|
41
|
+
### `exspec.md`
|
|
42
|
+
|
|
43
|
+
Create an `features/exspec.md` file. Its content is passed to the AI agent as context.
|
|
44
|
+
|
|
45
|
+
```markdown
|
|
46
|
+
# QA Configuration
|
|
47
|
+
|
|
48
|
+
## Application
|
|
49
|
+
|
|
50
|
+
URL: http://localhost:3000
|
|
51
|
+
|
|
52
|
+
This is an e-commerce app. The user is a store manager. For detailed feature documentation, see the `docs/` directory.
|
|
53
|
+
|
|
54
|
+
## Authentication
|
|
55
|
+
|
|
56
|
+
Use the `test@example.com` / `password` credentials for authentication.
|
|
57
|
+
|
|
58
|
+
## Browser
|
|
59
|
+
|
|
60
|
+
Resolution: 1920x1080
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The agent reads this file as context, so you can reference any project documentation here, or give it extra instructions.
|
|
64
|
+
|
|
65
|
+
### Environment variables
|
|
66
|
+
|
|
67
|
+
If your project has a `.env` file, exspec loads it automatically. You can then reference environment variables in `exspec.md` using `$VAR` or `${VAR}` syntax, they are resolved before the config is passed to the agent.
|
|
68
|
+
|
|
69
|
+
```markdown
|
|
70
|
+
URL: $APP_URL
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
This is useful for dynamic URLs across environments (e.g. with git worktrees). If a variable is not defined, the reference is left as-is.
|
|
74
|
+
|
|
75
|
+
## How it works
|
|
76
|
+
|
|
77
|
+
1. Loads `.env` (if present) and `exspec.md` (with variable expansion)
|
|
78
|
+
2. Discovers and parses `.feature` files (supports all Gherkin languages)
|
|
79
|
+
3. Groups scenarios by domain (subdirectory of `features/`)
|
|
80
|
+
4. For each domain, invokes Claude CLI with:
|
|
81
|
+
- Only Playwright tools available (browser-only, no database or code access)
|
|
82
|
+
- Playwright in headless mode (or headed with `--headed`)
|
|
83
|
+
- Feature content + context docs + config as prompt
|
|
84
|
+
5. Parses results (PASS/FAIL/SKIP) and writes them to `features/exspec/`
|
|
85
|
+
|
|
86
|
+
## Results
|
|
87
|
+
|
|
88
|
+
Results are written to `features/exspec/{YYYY-MM-DD-HHmm}.md` with failure screenshots in the corresponding directory.
|
|
89
|
+
|
|
90
|
+
The CLI exits with code `1` if any tests fail (CI-friendly).
|
|
91
|
+
|
|
92
|
+
## Agent restrictions
|
|
93
|
+
|
|
94
|
+
The AI agent can ONLY use Playwright browser tools. It cannot:
|
|
95
|
+
|
|
96
|
+
- Access the database
|
|
97
|
+
- Read or modify source code
|
|
98
|
+
- Execute shell commands
|
|
99
|
+
|
|
100
|
+
If a scenario cannot be verified through the browser, it is marked as FAIL.
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, existsSync } from "fs";
|
|
3
|
+
import { resolve } from "path";
|
|
4
|
+
import { loadDotenv, expandVars } from "./env.js";
|
|
5
|
+
import { discoverFeatures } from "./discovery.js";
|
|
6
|
+
import { parseFeature, filterScenarios, groupByDomain } from "./gherkin.js";
|
|
7
|
+
import { buildPrompt } from "./prompt.js";
|
|
8
|
+
import { runDomain } from "./runner.js";
|
|
9
|
+
import { generateRunId, initResultsFile, appendDomainResults, appendSummary, } from "./reporter.js";
|
|
10
|
+
const projectRoot = resolve(process.cwd());
|
|
11
|
+
// Parse arguments
|
|
12
|
+
const args = process.argv.slice(2);
|
|
13
|
+
let target;
|
|
14
|
+
let filter = null;
|
|
15
|
+
let failFast = false;
|
|
16
|
+
let headed = false;
|
|
17
|
+
for (let i = 0; i < args.length; i++) {
|
|
18
|
+
if (args[i] === "--filter" && args[i + 1]) {
|
|
19
|
+
filter = args[++i];
|
|
20
|
+
}
|
|
21
|
+
else if (args[i] === "--fail-fast") {
|
|
22
|
+
failFast = true;
|
|
23
|
+
}
|
|
24
|
+
else if (args[i] === "--headed") {
|
|
25
|
+
headed = true;
|
|
26
|
+
}
|
|
27
|
+
else if (!args[i].startsWith("--")) {
|
|
28
|
+
target = args[i];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// Load .env if it exists (populates process.env)
|
|
32
|
+
loadDotenv(projectRoot);
|
|
33
|
+
// Load config
|
|
34
|
+
const configPath = resolve(projectRoot, "features", "exspec.md");
|
|
35
|
+
if (!existsSync(configPath)) {
|
|
36
|
+
console.error("features/exspec.md not found.");
|
|
37
|
+
console.error("Create a features/exspec.md file with your QA configuration.");
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
// Resolve $VAR and ${VAR} references in the config using process.env
|
|
41
|
+
const configContent = expandVars(readFileSync(configPath, "utf-8"));
|
|
42
|
+
// Discover and parse features
|
|
43
|
+
let featureFiles;
|
|
44
|
+
try {
|
|
45
|
+
featureFiles = discoverFeatures(projectRoot, target);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
if (featureFiles.length === 0) {
|
|
52
|
+
console.error("No .feature files found.");
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
let features = featureFiles.map((f) => parseFeature(f, projectRoot));
|
|
56
|
+
if (filter) {
|
|
57
|
+
features = filterScenarios(features, filter);
|
|
58
|
+
if (features.length === 0) {
|
|
59
|
+
console.error(`No scenarios matching filter "${filter}".`);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const domains = groupByDomain(features);
|
|
64
|
+
const totalScenarios = features.reduce((sum, f) => sum + f.scenarios.length, 0);
|
|
65
|
+
// Display test plan
|
|
66
|
+
console.log(`\nSuite: ${totalScenarios} scenario(s) in ${domains.size} domain(s)\n`);
|
|
67
|
+
for (const [domain, domainFeatures] of domains) {
|
|
68
|
+
const count = domainFeatures.reduce((sum, f) => sum + f.scenarios.length, 0);
|
|
69
|
+
console.log(` ${domain} (${count} scenarios)`);
|
|
70
|
+
for (const f of domainFeatures) {
|
|
71
|
+
for (const s of f.scenarios) {
|
|
72
|
+
console.log(` · ${s.name}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
console.log();
|
|
77
|
+
// Initialize results
|
|
78
|
+
const runId = generateRunId();
|
|
79
|
+
const { resultsPath, screenshotsDir } = initResultsFile(projectRoot, runId);
|
|
80
|
+
console.log(`Results: features/exspec/${runId}.md\n`);
|
|
81
|
+
// Execute tests domain by domain
|
|
82
|
+
const totals = { passed: 0, failed: 0, skipped: 0, errors: 0 };
|
|
83
|
+
for (const [domain, domainFeatures] of domains) {
|
|
84
|
+
console.log(`▶ ${domain}...`);
|
|
85
|
+
const prompt = buildPrompt({
|
|
86
|
+
features: domainFeatures,
|
|
87
|
+
scenarioFilter: filter,
|
|
88
|
+
configContent,
|
|
89
|
+
screenshotsDir,
|
|
90
|
+
});
|
|
91
|
+
const result = await runDomain(prompt, domain, projectRoot, { headed });
|
|
92
|
+
appendDomainResults(resultsPath, result);
|
|
93
|
+
if (result.isError) {
|
|
94
|
+
totals.errors++;
|
|
95
|
+
console.log(` ✗ ERROR`);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
for (const s of result.scenarios) {
|
|
99
|
+
if (s.status === "pass")
|
|
100
|
+
totals.passed++;
|
|
101
|
+
else if (s.status === "fail")
|
|
102
|
+
totals.failed++;
|
|
103
|
+
else
|
|
104
|
+
totals.skipped++;
|
|
105
|
+
}
|
|
106
|
+
const p = result.scenarios.filter((s) => s.status === "pass").length;
|
|
107
|
+
const f = result.scenarios.filter((s) => s.status === "fail").length;
|
|
108
|
+
console.log(` ${p} passed, ${f} failed`);
|
|
109
|
+
}
|
|
110
|
+
if (result.cost) {
|
|
111
|
+
totals.cost = (totals.cost ?? 0) + result.cost;
|
|
112
|
+
console.log(` Cost: $${result.cost.toFixed(4)}`);
|
|
113
|
+
}
|
|
114
|
+
console.log();
|
|
115
|
+
if (failFast &&
|
|
116
|
+
(result.isError || result.scenarios.some((s) => s.status === "fail"))) {
|
|
117
|
+
console.log("--fail-fast: stopping after first failure.");
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Summary
|
|
122
|
+
appendSummary(resultsPath, totals);
|
|
123
|
+
console.log("─".repeat(40));
|
|
124
|
+
console.log(`Total: ${totals.passed} passed, ${totals.failed} failed, ${totals.skipped} skipped, ${totals.errors} errors`);
|
|
125
|
+
if (totals.cost) {
|
|
126
|
+
console.log(`Total cost: $${totals.cost.toFixed(4)}`);
|
|
127
|
+
}
|
|
128
|
+
console.log(`\nResults written to features/exspec/${runId}.md`);
|
|
129
|
+
process.exit(totals.failed > 0 || totals.errors > 0 ? 1 : 0);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { readdirSync, statSync, existsSync } from "fs";
|
|
2
|
+
import { resolve, relative } from "path";
|
|
3
|
+
export function discoverFeatures(projectRoot, target) {
|
|
4
|
+
if (!target) {
|
|
5
|
+
return globFeatures(resolve(projectRoot, "features"));
|
|
6
|
+
}
|
|
7
|
+
const fullPath = resolve(projectRoot, target);
|
|
8
|
+
if (!existsSync(fullPath)) {
|
|
9
|
+
throw new Error(`Path not found: ${fullPath}`);
|
|
10
|
+
}
|
|
11
|
+
if (statSync(fullPath).isDirectory()) {
|
|
12
|
+
return globFeatures(fullPath);
|
|
13
|
+
}
|
|
14
|
+
return [fullPath];
|
|
15
|
+
}
|
|
16
|
+
function globFeatures(dir) {
|
|
17
|
+
return readdirSync(dir, { recursive: true, withFileTypes: true })
|
|
18
|
+
.filter((entry) => !entry.isDirectory() && entry.name.endsWith(".feature"))
|
|
19
|
+
.map((entry) => resolve(entry.parentPath, entry.name))
|
|
20
|
+
.sort();
|
|
21
|
+
}
|
|
22
|
+
export function getDomain(featurePath, projectRoot) {
|
|
23
|
+
const rel = relative(resolve(projectRoot, "features"), featurePath);
|
|
24
|
+
const parts = rel.split("/");
|
|
25
|
+
return parts.length > 1 ? parts[0] : "default";
|
|
26
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { getDomain } from "./discovery.js";
|
|
3
|
+
describe("getDomain", () => {
|
|
4
|
+
const root = "/project";
|
|
5
|
+
test("extracts domain from subdirectory", () => {
|
|
6
|
+
expect(getDomain("/project/features/Auth/login.feature", root)).toBe("Auth");
|
|
7
|
+
});
|
|
8
|
+
test("extracts domain from nested subdirectory", () => {
|
|
9
|
+
expect(getDomain("/project/features/Auth/Admin/users.feature", root)).toBe("Auth");
|
|
10
|
+
});
|
|
11
|
+
test("returns 'default' for files directly in features/", () => {
|
|
12
|
+
expect(getDomain("/project/features/login.feature", root)).toBe("default");
|
|
13
|
+
});
|
|
14
|
+
});
|
package/dist/env.d.ts
ADDED
package/dist/env.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { config } from "dotenv";
|
|
2
|
+
import dotenvExpand from "dotenv-expand";
|
|
3
|
+
import { existsSync } from "fs";
|
|
4
|
+
import { resolve } from "path";
|
|
5
|
+
export function loadDotenv(projectRoot) {
|
|
6
|
+
const envPath = resolve(projectRoot, ".env");
|
|
7
|
+
if (!existsSync(envPath))
|
|
8
|
+
return;
|
|
9
|
+
const env = config({ path: envPath });
|
|
10
|
+
dotenvExpand.expand(env);
|
|
11
|
+
}
|
|
12
|
+
export function expandVars(text) {
|
|
13
|
+
return text.replace(/\$\{(\w+)\}|\$(\w+)/g, (match, braced, bare) => {
|
|
14
|
+
const varName = braced ?? bare;
|
|
15
|
+
return process.env[varName] ?? match;
|
|
16
|
+
});
|
|
17
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/env.test.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { expandVars } from "./env.js";
|
|
3
|
+
describe("expandVars", () => {
|
|
4
|
+
const saved = { ...process.env };
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
process.env.APP_URL = "http://localhost:3000";
|
|
7
|
+
process.env.SECRET = "s3cret";
|
|
8
|
+
});
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
process.env = { ...saved };
|
|
11
|
+
});
|
|
12
|
+
test("expands $VAR syntax", () => {
|
|
13
|
+
expect(expandVars("URL: $APP_URL")).toBe("URL: http://localhost:3000");
|
|
14
|
+
});
|
|
15
|
+
test("expands ${VAR} syntax", () => {
|
|
16
|
+
expect(expandVars("URL: ${APP_URL}")).toBe("URL: http://localhost:3000");
|
|
17
|
+
});
|
|
18
|
+
test("expands multiple variables", () => {
|
|
19
|
+
expect(expandVars("$APP_URL with $SECRET")).toBe("http://localhost:3000 with s3cret");
|
|
20
|
+
});
|
|
21
|
+
test("leaves undefined variables as-is", () => {
|
|
22
|
+
expect(expandVars("$UNDEFINED_VAR")).toBe("$UNDEFINED_VAR");
|
|
23
|
+
expect(expandVars("${UNDEFINED_VAR}")).toBe("${UNDEFINED_VAR}");
|
|
24
|
+
});
|
|
25
|
+
test("returns text without variables unchanged", () => {
|
|
26
|
+
expect(expandVars("no variables here")).toBe("no variables here");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ParsedFeature } from "./types.js";
|
|
2
|
+
export declare function parseFeature(filePath: string, projectRoot: string): ParsedFeature;
|
|
3
|
+
export declare function filterScenarios(features: ParsedFeature[], filter: string): ParsedFeature[];
|
|
4
|
+
export declare function groupByDomain(features: ParsedFeature[]): Map<string, ParsedFeature[]>;
|
package/dist/gherkin.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { AstBuilder, GherkinClassicTokenMatcher, Parser, } from "@cucumber/gherkin";
|
|
2
|
+
import { IdGenerator } from "@cucumber/messages";
|
|
3
|
+
import { readFileSync } from "fs";
|
|
4
|
+
import { getDomain } from "./discovery.js";
|
|
5
|
+
const uuidFn = IdGenerator.uuid();
|
|
6
|
+
export function parseFeature(filePath, projectRoot) {
|
|
7
|
+
const rawContent = readFileSync(filePath, "utf-8");
|
|
8
|
+
const builder = new AstBuilder(uuidFn);
|
|
9
|
+
const matcher = new GherkinClassicTokenMatcher();
|
|
10
|
+
const parser = new Parser(builder, matcher);
|
|
11
|
+
const document = parser.parse(rawContent);
|
|
12
|
+
const feature = document.feature;
|
|
13
|
+
const scenarios = (feature?.children ?? [])
|
|
14
|
+
.filter((child) => child.scenario)
|
|
15
|
+
.map((child) => ({ name: child.scenario.name }));
|
|
16
|
+
return {
|
|
17
|
+
name: feature?.name ?? "Unknown",
|
|
18
|
+
filePath,
|
|
19
|
+
domain: getDomain(filePath, projectRoot),
|
|
20
|
+
rawContent,
|
|
21
|
+
scenarios,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function filterScenarios(features, filter) {
|
|
25
|
+
const lowerFilter = filter.toLowerCase();
|
|
26
|
+
return features
|
|
27
|
+
.map((f) => ({
|
|
28
|
+
...f,
|
|
29
|
+
scenarios: f.scenarios.filter((s) => s.name.toLowerCase().includes(lowerFilter)),
|
|
30
|
+
}))
|
|
31
|
+
.filter((f) => f.scenarios.length > 0);
|
|
32
|
+
}
|
|
33
|
+
export function groupByDomain(features) {
|
|
34
|
+
const groups = new Map();
|
|
35
|
+
for (const feature of features) {
|
|
36
|
+
const existing = groups.get(feature.domain) ?? [];
|
|
37
|
+
existing.push(feature);
|
|
38
|
+
groups.set(feature.domain, existing);
|
|
39
|
+
}
|
|
40
|
+
return groups;
|
|
41
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { filterScenarios, groupByDomain } from "./gherkin.js";
|
|
3
|
+
function feature(overrides = {}) {
|
|
4
|
+
return {
|
|
5
|
+
name: "Test Feature",
|
|
6
|
+
filePath: "/features/Test/test.feature",
|
|
7
|
+
domain: "Test",
|
|
8
|
+
rawContent: "",
|
|
9
|
+
scenarios: [{ name: "Scenario A" }, { name: "Scenario B" }],
|
|
10
|
+
...overrides,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
describe("filterScenarios", () => {
|
|
14
|
+
test("filters scenarios by name (case-insensitive)", () => {
|
|
15
|
+
const features = [feature()];
|
|
16
|
+
const result = filterScenarios(features, "scenario a");
|
|
17
|
+
expect(result).toHaveLength(1);
|
|
18
|
+
expect(result[0].scenarios).toEqual([{ name: "Scenario A" }]);
|
|
19
|
+
});
|
|
20
|
+
test("removes features with no matching scenarios", () => {
|
|
21
|
+
const features = [feature()];
|
|
22
|
+
const result = filterScenarios(features, "nonexistent");
|
|
23
|
+
expect(result).toHaveLength(0);
|
|
24
|
+
});
|
|
25
|
+
test("partial match works", () => {
|
|
26
|
+
const features = [feature({ scenarios: [{ name: "User can login" }] })];
|
|
27
|
+
const result = filterScenarios(features, "login");
|
|
28
|
+
expect(result).toHaveLength(1);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
describe("groupByDomain", () => {
|
|
32
|
+
test("groups features by domain", () => {
|
|
33
|
+
const features = [
|
|
34
|
+
feature({ domain: "Auth" }),
|
|
35
|
+
feature({ domain: "Billing" }),
|
|
36
|
+
feature({ domain: "Auth", name: "Other" }),
|
|
37
|
+
];
|
|
38
|
+
const groups = groupByDomain(features);
|
|
39
|
+
expect(groups.size).toBe(2);
|
|
40
|
+
expect(groups.get("Auth")).toHaveLength(2);
|
|
41
|
+
expect(groups.get("Billing")).toHaveLength(1);
|
|
42
|
+
});
|
|
43
|
+
test("returns empty map for no features", () => {
|
|
44
|
+
expect(groupByDomain([]).size).toBe(0);
|
|
45
|
+
});
|
|
46
|
+
});
|
package/dist/prompt.d.ts
ADDED
package/dist/prompt.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import { resolve, dirname } from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
5
|
+
const templatePath = resolve(__dirname, "..", "prompt-template.md");
|
|
6
|
+
export function buildPrompt(options) {
|
|
7
|
+
let template = readFileSync(templatePath, "utf-8");
|
|
8
|
+
const featureContent = options.features
|
|
9
|
+
.map((f) => f.rawContent)
|
|
10
|
+
.join("\n\n---\n\n");
|
|
11
|
+
const scenariosToExecute = options.scenarioFilter
|
|
12
|
+
? options.features
|
|
13
|
+
.flatMap((f) => f.scenarios)
|
|
14
|
+
.map((s) => s.name)
|
|
15
|
+
.join(", ")
|
|
16
|
+
: "ALL";
|
|
17
|
+
template = template
|
|
18
|
+
.replaceAll("{FEATURE_CONTENT}", featureContent)
|
|
19
|
+
.replaceAll("{SCENARIOS_TO_EXECUTE}", scenariosToExecute)
|
|
20
|
+
.replaceAll("{CONFIG_CONTEXT}", options.configContent)
|
|
21
|
+
.replaceAll("{SCREENSHOTS_DIR}", options.screenshotsDir);
|
|
22
|
+
return template;
|
|
23
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { buildPrompt } from "./prompt.js";
|
|
3
|
+
describe("buildPrompt", () => {
|
|
4
|
+
const feature = {
|
|
5
|
+
name: "Login",
|
|
6
|
+
filePath: "/features/Auth/login.feature",
|
|
7
|
+
domain: "Auth",
|
|
8
|
+
rawContent: "Feature: Login\n Scenario: Valid login",
|
|
9
|
+
scenarios: [{ name: "Valid login" }],
|
|
10
|
+
};
|
|
11
|
+
test("includes feature content", () => {
|
|
12
|
+
const prompt = buildPrompt({
|
|
13
|
+
features: [feature],
|
|
14
|
+
scenarioFilter: null,
|
|
15
|
+
configContent: "URL: http://localhost",
|
|
16
|
+
screenshotsDir: "/tmp/screenshots",
|
|
17
|
+
});
|
|
18
|
+
expect(prompt).toContain("Feature: Login");
|
|
19
|
+
});
|
|
20
|
+
test("includes config content", () => {
|
|
21
|
+
const prompt = buildPrompt({
|
|
22
|
+
features: [feature],
|
|
23
|
+
scenarioFilter: null,
|
|
24
|
+
configContent: "URL: http://localhost",
|
|
25
|
+
screenshotsDir: "/tmp/screenshots",
|
|
26
|
+
});
|
|
27
|
+
expect(prompt).toContain("URL: http://localhost");
|
|
28
|
+
});
|
|
29
|
+
test("sets scenarios to ALL when no filter", () => {
|
|
30
|
+
const prompt = buildPrompt({
|
|
31
|
+
features: [feature],
|
|
32
|
+
scenarioFilter: null,
|
|
33
|
+
configContent: "",
|
|
34
|
+
screenshotsDir: "/tmp",
|
|
35
|
+
});
|
|
36
|
+
expect(prompt).toContain("`ALL`");
|
|
37
|
+
});
|
|
38
|
+
test("lists filtered scenario names", () => {
|
|
39
|
+
const prompt = buildPrompt({
|
|
40
|
+
features: [feature],
|
|
41
|
+
scenarioFilter: "login",
|
|
42
|
+
configContent: "",
|
|
43
|
+
screenshotsDir: "/tmp",
|
|
44
|
+
});
|
|
45
|
+
expect(prompt).toContain("Valid login");
|
|
46
|
+
expect(prompt).not.toContain("`ALL`");
|
|
47
|
+
});
|
|
48
|
+
test("replaces all occurrences of screenshots dir", () => {
|
|
49
|
+
const prompt = buildPrompt({
|
|
50
|
+
features: [feature],
|
|
51
|
+
scenarioFilter: null,
|
|
52
|
+
configContent: "",
|
|
53
|
+
screenshotsDir: "/tmp/shots",
|
|
54
|
+
});
|
|
55
|
+
// The template has {SCREENSHOTS_DIR} in two places
|
|
56
|
+
expect(prompt).not.toContain("{SCREENSHOTS_DIR}");
|
|
57
|
+
const count = (prompt.match(/\/tmp\/shots/g) ?? []).length;
|
|
58
|
+
expect(count).toBeGreaterThanOrEqual(2);
|
|
59
|
+
});
|
|
60
|
+
test("joins multiple features with separator", () => {
|
|
61
|
+
const feature2 = {
|
|
62
|
+
...feature,
|
|
63
|
+
name: "Register",
|
|
64
|
+
rawContent: "Feature: Register\n Scenario: New user",
|
|
65
|
+
};
|
|
66
|
+
const prompt = buildPrompt({
|
|
67
|
+
features: [feature, feature2],
|
|
68
|
+
scenarioFilter: null,
|
|
69
|
+
configContent: "",
|
|
70
|
+
screenshotsDir: "/tmp",
|
|
71
|
+
});
|
|
72
|
+
expect(prompt).toContain("Feature: Login");
|
|
73
|
+
expect(prompt).toContain("Feature: Register");
|
|
74
|
+
expect(prompt).toContain("---");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { DomainResult, RunTotals } from "./types.js";
|
|
2
|
+
export declare function generateRunId(): string;
|
|
3
|
+
export declare function formatTime(): string;
|
|
4
|
+
export declare function initResultsFile(projectRoot: string, runId: string): {
|
|
5
|
+
resultsPath: string;
|
|
6
|
+
screenshotsDir: string;
|
|
7
|
+
};
|
|
8
|
+
export declare function appendDomainResults(resultsPath: string, result: DomainResult): void;
|
|
9
|
+
export declare function appendSummary(resultsPath: string, totals: RunTotals): void;
|
package/dist/reporter.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, writeFileSync } from "fs";
|
|
2
|
+
import { resolve, join } from "path";
|
|
3
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
4
|
+
export function generateRunId() {
|
|
5
|
+
const now = new Date();
|
|
6
|
+
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}`;
|
|
7
|
+
}
|
|
8
|
+
export function formatTime() {
|
|
9
|
+
const now = new Date();
|
|
10
|
+
return `${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
|
11
|
+
}
|
|
12
|
+
export function initResultsFile(projectRoot, runId) {
|
|
13
|
+
const resultsDir = resolve(projectRoot, "features/exspec");
|
|
14
|
+
const screenshotsDir = resolve(resultsDir, runId);
|
|
15
|
+
const resultsPath = resolve(resultsDir, `${runId}.md`);
|
|
16
|
+
mkdirSync(screenshotsDir, { recursive: true });
|
|
17
|
+
// Create .gitignore on first run
|
|
18
|
+
const gitignorePath = join(resultsDir, ".gitignore");
|
|
19
|
+
if (!existsSync(gitignorePath)) {
|
|
20
|
+
writeFileSync(gitignorePath, "*\n!.gitignore\n");
|
|
21
|
+
}
|
|
22
|
+
writeFileSync(resultsPath, `# Test results — ${runId}\n\nStarted at ${formatTime()}\n`);
|
|
23
|
+
return { resultsPath, screenshotsDir };
|
|
24
|
+
}
|
|
25
|
+
export function appendDomainResults(resultsPath, result) {
|
|
26
|
+
const lines = [""];
|
|
27
|
+
if (result.isError) {
|
|
28
|
+
lines.push(`## ${result.domain} — ERROR`, "");
|
|
29
|
+
lines.push(` Agent crashed or returned no results.`);
|
|
30
|
+
if (result.rawOutput) {
|
|
31
|
+
lines.push(` Raw output: ${result.rawOutput.slice(0, 500)}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
const passed = result.scenarios.filter((s) => s.status === "pass").length;
|
|
36
|
+
const failed = result.scenarios.filter((s) => s.status === "fail").length;
|
|
37
|
+
const skipped = result.scenarios.filter((s) => s.status === "skip").length;
|
|
38
|
+
lines.push(`## ${result.domain} — ${passed} passed, ${failed} failed, ${skipped} skipped`, "");
|
|
39
|
+
for (const scenario of result.scenarios) {
|
|
40
|
+
if (scenario.status === "pass") {
|
|
41
|
+
lines.push(` ✓ ${scenario.name}`);
|
|
42
|
+
if (scenario.details) {
|
|
43
|
+
lines.push(` ${scenario.details.split("\n")[0]}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else if (scenario.status === "fail") {
|
|
47
|
+
lines.push(` ✗ ${scenario.name}`);
|
|
48
|
+
if (scenario.details) {
|
|
49
|
+
lines.push(` → ${scenario.details.split("\n").join("\n ")}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
lines.push(` ○ ${scenario.name}`);
|
|
54
|
+
if (scenario.details) {
|
|
55
|
+
lines.push(` → ${scenario.details.split("\n")[0]}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
lines.push("");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
appendFileSync(resultsPath, lines.join("\n"));
|
|
62
|
+
}
|
|
63
|
+
export function appendSummary(resultsPath, totals) {
|
|
64
|
+
const content = [
|
|
65
|
+
"---\n",
|
|
66
|
+
"## Summary\n",
|
|
67
|
+
`Total: ${totals.passed} passed, ${totals.failed} failed, ${totals.skipped} skipped, ${totals.errors} errors\n`,
|
|
68
|
+
`Finished at ${formatTime()}\n`,
|
|
69
|
+
].join("\n");
|
|
70
|
+
appendFileSync(resultsPath, content);
|
|
71
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, test, expect, afterEach } from "vitest";
|
|
2
|
+
import { readFileSync, mkdirSync, rmSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
import { generateRunId, initResultsFile, appendDomainResults, appendSummary, } from "./reporter.js";
|
|
6
|
+
describe("generateRunId", () => {
|
|
7
|
+
test("returns YYYY-MM-DD-HHmm format", () => {
|
|
8
|
+
expect(generateRunId()).toMatch(/^\d{4}-\d{2}-\d{2}-\d{4}$/);
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
describe("initResultsFile", () => {
|
|
12
|
+
const tmpRoot = join(tmpdir(), `exspec-test-${Date.now()}`);
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
rmSync(tmpRoot, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
test("creates results file and screenshots dir", () => {
|
|
17
|
+
mkdirSync(tmpRoot, { recursive: true });
|
|
18
|
+
const { resultsPath, screenshotsDir } = initResultsFile(tmpRoot, "2025-01-15-1430");
|
|
19
|
+
const content = readFileSync(resultsPath, "utf-8");
|
|
20
|
+
expect(content).toContain("# Test results — 2025-01-15-1430");
|
|
21
|
+
expect(screenshotsDir).toContain("2025-01-15-1430");
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
describe("appendDomainResults", () => {
|
|
25
|
+
const tmpRoot = join(tmpdir(), `exspec-test-${Date.now()}`);
|
|
26
|
+
let resultsPath;
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
rmSync(tmpRoot, { recursive: true, force: true });
|
|
29
|
+
});
|
|
30
|
+
function setup() {
|
|
31
|
+
mkdirSync(tmpRoot, { recursive: true });
|
|
32
|
+
const result = initResultsFile(tmpRoot, "test-run");
|
|
33
|
+
resultsPath = result.resultsPath;
|
|
34
|
+
}
|
|
35
|
+
test("writes passed/failed/skipped counts", () => {
|
|
36
|
+
setup();
|
|
37
|
+
const result = {
|
|
38
|
+
domain: "Auth",
|
|
39
|
+
scenarios: [
|
|
40
|
+
{ name: "Login", status: "pass", details: "OK" },
|
|
41
|
+
{ name: "Logout", status: "fail", details: "Button missing" },
|
|
42
|
+
],
|
|
43
|
+
rawOutput: "",
|
|
44
|
+
isError: false,
|
|
45
|
+
};
|
|
46
|
+
appendDomainResults(resultsPath, result);
|
|
47
|
+
const content = readFileSync(resultsPath, "utf-8");
|
|
48
|
+
expect(content).toContain("Auth — 1 passed, 1 failed, 0 skipped");
|
|
49
|
+
expect(content).toContain("✓ Login");
|
|
50
|
+
expect(content).toContain("✗ Logout");
|
|
51
|
+
});
|
|
52
|
+
test("writes error domain", () => {
|
|
53
|
+
setup();
|
|
54
|
+
const result = {
|
|
55
|
+
domain: "Broken",
|
|
56
|
+
scenarios: [],
|
|
57
|
+
rawOutput: "some error output",
|
|
58
|
+
isError: true,
|
|
59
|
+
};
|
|
60
|
+
appendDomainResults(resultsPath, result);
|
|
61
|
+
const content = readFileSync(resultsPath, "utf-8");
|
|
62
|
+
expect(content).toContain("Broken — ERROR");
|
|
63
|
+
expect(content).toContain("some error output");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
describe("appendSummary", () => {
|
|
67
|
+
const tmpRoot = join(tmpdir(), `exspec-test-${Date.now()}`);
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
rmSync(tmpRoot, { recursive: true, force: true });
|
|
70
|
+
});
|
|
71
|
+
test("writes totals", () => {
|
|
72
|
+
mkdirSync(tmpRoot, { recursive: true });
|
|
73
|
+
const { resultsPath } = initResultsFile(tmpRoot, "test-run");
|
|
74
|
+
const totals = {
|
|
75
|
+
passed: 5,
|
|
76
|
+
failed: 2,
|
|
77
|
+
skipped: 1,
|
|
78
|
+
errors: 0,
|
|
79
|
+
};
|
|
80
|
+
appendSummary(resultsPath, totals);
|
|
81
|
+
const content = readFileSync(resultsPath, "utf-8");
|
|
82
|
+
expect(content).toContain("5 passed, 2 failed, 1 skipped, 0 errors");
|
|
83
|
+
});
|
|
84
|
+
});
|
package/dist/runner.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { DomainResult, ScenarioResult } from "./types.js";
|
|
2
|
+
export interface RunOptions {
|
|
3
|
+
headed?: boolean;
|
|
4
|
+
}
|
|
5
|
+
export declare function runDomain(prompt: string, domain: string, projectRoot: string, options?: RunOptions): Promise<DomainResult>;
|
|
6
|
+
export declare function parseScenarioResults(output: string): ScenarioResult[];
|
package/dist/runner.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import { writeFileSync, existsSync, readFileSync } from "fs";
|
|
3
|
+
import { join, dirname } from "path";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
import { createRequire } from "module";
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
const playwrightBin = join(dirname(require.resolve("@playwright/mcp/package.json")), "cli.js");
|
|
8
|
+
function getMcpConfigPath(headed) {
|
|
9
|
+
const config = {
|
|
10
|
+
mcpServers: {
|
|
11
|
+
playwright: {
|
|
12
|
+
type: "stdio",
|
|
13
|
+
command: playwrightBin,
|
|
14
|
+
args: headed ? [] : ["--headless"],
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
const suffix = headed ? "-headed" : "";
|
|
19
|
+
const configPath = join(tmpdir(), `exspec-mcp${suffix}.json`);
|
|
20
|
+
const json = JSON.stringify(config);
|
|
21
|
+
if (!existsSync(configPath) || readFileSync(configPath, "utf-8") !== json) {
|
|
22
|
+
writeFileSync(configPath, json);
|
|
23
|
+
}
|
|
24
|
+
return configPath;
|
|
25
|
+
}
|
|
26
|
+
export async function runDomain(prompt, domain, projectRoot, options = {}) {
|
|
27
|
+
const mcpConfigPath = getMcpConfigPath(options.headed ?? false);
|
|
28
|
+
try {
|
|
29
|
+
const { result, cost, duration } = await invokeClaude(prompt, projectRoot, mcpConfigPath);
|
|
30
|
+
const scenarios = parseScenarioResults(result);
|
|
31
|
+
return {
|
|
32
|
+
domain,
|
|
33
|
+
scenarios,
|
|
34
|
+
rawOutput: result,
|
|
35
|
+
isError: false,
|
|
36
|
+
cost,
|
|
37
|
+
duration,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
42
|
+
return {
|
|
43
|
+
domain,
|
|
44
|
+
scenarios: [],
|
|
45
|
+
rawOutput: message.slice(0, 500),
|
|
46
|
+
isError: true,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function invokeClaude(prompt, cwd, mcpConfigPath) {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const child = spawn("claude", [
|
|
53
|
+
"-p",
|
|
54
|
+
prompt,
|
|
55
|
+
"--allowedTools",
|
|
56
|
+
"mcp__playwright__*",
|
|
57
|
+
"--output-format",
|
|
58
|
+
"stream-json",
|
|
59
|
+
"--model",
|
|
60
|
+
"sonnet",
|
|
61
|
+
"--mcp-config",
|
|
62
|
+
mcpConfigPath,
|
|
63
|
+
], { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
64
|
+
let buffer = "";
|
|
65
|
+
let resultText = "";
|
|
66
|
+
let cost;
|
|
67
|
+
let duration;
|
|
68
|
+
child.stdout.on("data", (data) => {
|
|
69
|
+
buffer += data.toString();
|
|
70
|
+
// Process complete JSON lines
|
|
71
|
+
const lines = buffer.split("\n");
|
|
72
|
+
buffer = lines.pop() ?? "";
|
|
73
|
+
for (const line of lines) {
|
|
74
|
+
if (!line.trim())
|
|
75
|
+
continue;
|
|
76
|
+
try {
|
|
77
|
+
const event = JSON.parse(line);
|
|
78
|
+
handleStreamEvent(event);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// Skip malformed lines
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
function handleStreamEvent(event) {
|
|
86
|
+
switch (event.type) {
|
|
87
|
+
case "assistant": {
|
|
88
|
+
const message = event.message;
|
|
89
|
+
const content = message?.content;
|
|
90
|
+
if (content) {
|
|
91
|
+
for (const block of content) {
|
|
92
|
+
if (block.type === "text") {
|
|
93
|
+
process.stderr.write(".");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
case "tool_use": {
|
|
100
|
+
const toolName = event.tool_name;
|
|
101
|
+
if (toolName) {
|
|
102
|
+
const short = toolName.replace("mcp__playwright__browser_", "");
|
|
103
|
+
process.stderr.write(` [${short}]`);
|
|
104
|
+
}
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
case "tool_result": {
|
|
108
|
+
process.stderr.write(".");
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
case "result": {
|
|
112
|
+
resultText = event.result ?? "";
|
|
113
|
+
cost = event.cost_usd;
|
|
114
|
+
duration = event.duration_ms;
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
let stderr = "";
|
|
120
|
+
child.stderr.on("data", (data) => {
|
|
121
|
+
stderr += data.toString();
|
|
122
|
+
});
|
|
123
|
+
child.on("close", (code) => {
|
|
124
|
+
// Process remaining buffer
|
|
125
|
+
if (buffer.trim()) {
|
|
126
|
+
try {
|
|
127
|
+
const event = JSON.parse(buffer);
|
|
128
|
+
handleStreamEvent(event);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// ignore
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
process.stderr.write("\n");
|
|
135
|
+
if (code !== 0) {
|
|
136
|
+
reject(new Error(`claude exited with code ${code}${stderr ? `: ${stderr.slice(0, 500)}` : ""}`));
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
resolve({ result: resultText, cost, duration });
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
child.on("error", (err) => {
|
|
143
|
+
reject(new Error(`Failed to spawn claude: ${err.message}`));
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
export function parseScenarioResults(output) {
|
|
148
|
+
const results = [];
|
|
149
|
+
const lines = output.split("\n");
|
|
150
|
+
for (let i = 0; i < lines.length; i++) {
|
|
151
|
+
const match = lines[i].match(/^### (PASS|FAIL|SKIP):\s*(.+)/);
|
|
152
|
+
if (match) {
|
|
153
|
+
const status = match[1].toLowerCase();
|
|
154
|
+
const details = collectDetails(lines, i + 1);
|
|
155
|
+
results.push({ name: match[2].trim(), status, details });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return results;
|
|
159
|
+
}
|
|
160
|
+
function collectDetails(lines, startIndex) {
|
|
161
|
+
const detailLines = [];
|
|
162
|
+
for (let i = startIndex; i < lines.length; i++) {
|
|
163
|
+
if (lines[i].match(/^### (PASS|FAIL|SKIP):/))
|
|
164
|
+
break;
|
|
165
|
+
if (lines[i].match(/^## /))
|
|
166
|
+
break;
|
|
167
|
+
detailLines.push(lines[i]);
|
|
168
|
+
}
|
|
169
|
+
return detailLines.join("\n").trim();
|
|
170
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { parseScenarioResults } from "./runner.js";
|
|
3
|
+
describe("parseScenarioResults", () => {
|
|
4
|
+
test("parses PASS scenarios", () => {
|
|
5
|
+
const output = `## Feature: Login
|
|
6
|
+
|
|
7
|
+
### PASS: User can login
|
|
8
|
+
Login succeeded with correct credentials.`;
|
|
9
|
+
const results = parseScenarioResults(output);
|
|
10
|
+
expect(results).toEqual([
|
|
11
|
+
{
|
|
12
|
+
name: "User can login",
|
|
13
|
+
status: "pass",
|
|
14
|
+
details: "Login succeeded with correct credentials.",
|
|
15
|
+
},
|
|
16
|
+
]);
|
|
17
|
+
});
|
|
18
|
+
test("parses FAIL scenarios with details", () => {
|
|
19
|
+
const output = `### FAIL: User sees dashboard
|
|
20
|
+
**Failed step**: Then I should see the dashboard
|
|
21
|
+
**Error**: Element not found
|
|
22
|
+
**Expected**: Dashboard page
|
|
23
|
+
**Observed**: Login page`;
|
|
24
|
+
const results = parseScenarioResults(output);
|
|
25
|
+
expect(results).toHaveLength(1);
|
|
26
|
+
expect(results[0].status).toBe("fail");
|
|
27
|
+
expect(results[0].name).toBe("User sees dashboard");
|
|
28
|
+
expect(results[0].details).toContain("Element not found");
|
|
29
|
+
});
|
|
30
|
+
test("parses SKIP scenarios", () => {
|
|
31
|
+
const output = `### SKIP: Admin panel
|
|
32
|
+
**Reason**: Setup step failed`;
|
|
33
|
+
const results = parseScenarioResults(output);
|
|
34
|
+
expect(results).toEqual([
|
|
35
|
+
{
|
|
36
|
+
name: "Admin panel",
|
|
37
|
+
status: "skip",
|
|
38
|
+
details: "**Reason**: Setup step failed",
|
|
39
|
+
},
|
|
40
|
+
]);
|
|
41
|
+
});
|
|
42
|
+
test("parses mixed results", () => {
|
|
43
|
+
const output = `## Feature: Auth
|
|
44
|
+
|
|
45
|
+
### PASS: Login
|
|
46
|
+
OK
|
|
47
|
+
|
|
48
|
+
### FAIL: Logout
|
|
49
|
+
Button not found
|
|
50
|
+
|
|
51
|
+
### SKIP: MFA
|
|
52
|
+
Not configured`;
|
|
53
|
+
const results = parseScenarioResults(output);
|
|
54
|
+
expect(results).toHaveLength(3);
|
|
55
|
+
expect(results[0]).toMatchObject({ name: "Login", status: "pass" });
|
|
56
|
+
expect(results[1]).toMatchObject({ name: "Logout", status: "fail" });
|
|
57
|
+
expect(results[2]).toMatchObject({ name: "MFA", status: "skip" });
|
|
58
|
+
});
|
|
59
|
+
test("returns empty array for no matches", () => {
|
|
60
|
+
expect(parseScenarioResults("random text")).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
test("stops collecting details at next scenario header", () => {
|
|
63
|
+
const output = `### PASS: First
|
|
64
|
+
Detail line 1
|
|
65
|
+
Detail line 2
|
|
66
|
+
|
|
67
|
+
### PASS: Second
|
|
68
|
+
Other detail`;
|
|
69
|
+
const results = parseScenarioResults(output);
|
|
70
|
+
expect(results[0].details).toBe("Detail line 1\nDetail line 2");
|
|
71
|
+
expect(results[1].details).toBe("Other detail");
|
|
72
|
+
});
|
|
73
|
+
test("stops collecting details at feature header", () => {
|
|
74
|
+
const output = `### PASS: First
|
|
75
|
+
Some detail
|
|
76
|
+
|
|
77
|
+
## Feature: Other`;
|
|
78
|
+
const results = parseScenarioResults(output);
|
|
79
|
+
expect(results[0].details).toBe("Some detail");
|
|
80
|
+
});
|
|
81
|
+
});
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface ParsedFeature {
|
|
2
|
+
name: string;
|
|
3
|
+
filePath: string;
|
|
4
|
+
domain: string;
|
|
5
|
+
rawContent: string;
|
|
6
|
+
scenarios: ParsedScenario[];
|
|
7
|
+
}
|
|
8
|
+
export interface ParsedScenario {
|
|
9
|
+
name: string;
|
|
10
|
+
}
|
|
11
|
+
export interface ScenarioResult {
|
|
12
|
+
name: string;
|
|
13
|
+
status: "pass" | "fail" | "skip";
|
|
14
|
+
details?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface DomainResult {
|
|
17
|
+
domain: string;
|
|
18
|
+
scenarios: ScenarioResult[];
|
|
19
|
+
rawOutput: string;
|
|
20
|
+
isError: boolean;
|
|
21
|
+
cost?: number;
|
|
22
|
+
duration?: number;
|
|
23
|
+
}
|
|
24
|
+
export interface RunTotals {
|
|
25
|
+
passed: number;
|
|
26
|
+
failed: number;
|
|
27
|
+
skipped: number;
|
|
28
|
+
errors: number;
|
|
29
|
+
cost?: number;
|
|
30
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mnapoli/exspec",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Executable specs — run Gherkin feature files with an AI agent in the browser",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"exspec": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"prompt-template.md"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc && node scripts/add-shebang.js",
|
|
15
|
+
"prepublishOnly": "npm run build",
|
|
16
|
+
"dev": "tsx src/cli.ts",
|
|
17
|
+
"test": "vitest run",
|
|
18
|
+
"lint": "eslint src",
|
|
19
|
+
"format": "prettier --write src",
|
|
20
|
+
"format:check": "prettier --check src"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"gherkin",
|
|
24
|
+
"bdd",
|
|
25
|
+
"testing",
|
|
26
|
+
"executable-specifications",
|
|
27
|
+
"playwright",
|
|
28
|
+
"ai",
|
|
29
|
+
"claude",
|
|
30
|
+
"browser-testing",
|
|
31
|
+
"feature-tests"
|
|
32
|
+
],
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/mnapoli/exspec.git"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/mnapoli/exspec",
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/mnapoli/exspec/issues"
|
|
40
|
+
},
|
|
41
|
+
"author": "Matthieu Napoli",
|
|
42
|
+
"license": "MIT",
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=20"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@cucumber/gherkin": "^29.0.0",
|
|
51
|
+
"@cucumber/messages": "^25.0.0",
|
|
52
|
+
"@playwright/mcp": "^0.0.68",
|
|
53
|
+
"dotenv": "^16.4.0",
|
|
54
|
+
"dotenv-expand": "^12.0.0"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@eslint/js": "^10.0.1",
|
|
58
|
+
"@types/node": "^25.5.0",
|
|
59
|
+
"eslint": "^10.0.3",
|
|
60
|
+
"eslint-config-prettier": "^10.1.8",
|
|
61
|
+
"prettier": "^3.8.1",
|
|
62
|
+
"tsx": "^4.0.0",
|
|
63
|
+
"typescript": "^5.2.0",
|
|
64
|
+
"typescript-eslint": "^8.57.1",
|
|
65
|
+
"vitest": "^4.1.0"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# Feature Scenario Executor
|
|
2
|
+
|
|
3
|
+
You execute Gherkin scenarios by interacting with a web application through the browser. You are autonomous: read each step, understand the intent, and figure out how to perform it in the UI.
|
|
4
|
+
|
|
5
|
+
## Input
|
|
6
|
+
|
|
7
|
+
- **Feature file content**: `{FEATURE_CONTENT}`
|
|
8
|
+
- **Scenarios to execute**: `{SCENARIOS_TO_EXECUTE}`
|
|
9
|
+
|
|
10
|
+
## Context
|
|
11
|
+
|
|
12
|
+
- **Screenshots directory**: {SCREENSHOTS_DIR}
|
|
13
|
+
|
|
14
|
+
Read the configuration below for the application URL, authentication method, browser settings, and application context.
|
|
15
|
+
|
|
16
|
+
## Configuration
|
|
17
|
+
|
|
18
|
+
{CONFIG_CONTEXT}
|
|
19
|
+
|
|
20
|
+
## Role
|
|
21
|
+
|
|
22
|
+
You are a QA tester. You can only interact with the application through the browser. If a step cannot be accomplished through the browser UI, mark the scenario as FAIL.
|
|
23
|
+
|
|
24
|
+
## How to interpret Gherkin steps
|
|
25
|
+
|
|
26
|
+
Steps may be written in any language. Do NOT rely on hardcoded mappings — instead:
|
|
27
|
+
|
|
28
|
+
1. **Read the step text** and understand what it describes (setup, action, or assertion)
|
|
29
|
+
2. **Use the configuration** to understand the domain and how the app works
|
|
30
|
+
3. **Explore the UI** to find the right page, button, or form to accomplish the step
|
|
31
|
+
4. **For assertions with tables**, the table provides expected values — verify them in the UI
|
|
32
|
+
|
|
33
|
+
### Step types
|
|
34
|
+
|
|
35
|
+
- **Given** — Setup: create entities, navigate to a state, ensure preconditions
|
|
36
|
+
- **When** — Action: perform a user action (click, fill, submit, navigate)
|
|
37
|
+
- **Then / And** — Assertion: verify the UI shows expected data
|
|
38
|
+
|
|
39
|
+
### Tables in steps
|
|
40
|
+
|
|
41
|
+
Tables can appear after any step. They provide structured data — either input data or expected values depending on context. Read the step text to understand the table's role.
|
|
42
|
+
|
|
43
|
+
## Process
|
|
44
|
+
|
|
45
|
+
### 1. Authenticate
|
|
46
|
+
|
|
47
|
+
1. Navigate to the application URL.
|
|
48
|
+
2. Resize the browser to the configured resolution with `mcp__playwright__browser_resize`.
|
|
49
|
+
3. Follow the authentication instructions from the Configuration section above.
|
|
50
|
+
4. Take a snapshot to confirm successful login.
|
|
51
|
+
|
|
52
|
+
### 2. Execute each scenario sequentially
|
|
53
|
+
|
|
54
|
+
For each scenario:
|
|
55
|
+
|
|
56
|
+
1. **Setup**: Execute all Given steps.
|
|
57
|
+
2. **Actions**: Execute all When steps.
|
|
58
|
+
3. **Assertions**: Verify all Then/And steps.
|
|
59
|
+
4. **Record result**: PASS, FAIL, or SKIP.
|
|
60
|
+
|
|
61
|
+
Between scenarios, start fresh if needed (create new test data).
|
|
62
|
+
|
|
63
|
+
### 3. Navigating the UI
|
|
64
|
+
|
|
65
|
+
- Use `mcp__playwright__browser_snapshot` to understand the current page.
|
|
66
|
+
- Use `mcp__playwright__browser_click` to interact with elements.
|
|
67
|
+
- Use `mcp__playwright__browser_fill_form` to fill forms.
|
|
68
|
+
- If you get lost, navigate directly to a known URL.
|
|
69
|
+
- Check dropdown menus and action bars for buttons.
|
|
70
|
+
|
|
71
|
+
### 4. Error handling
|
|
72
|
+
|
|
73
|
+
- If a step fails, take a screenshot and save it to `{SCREENSHOTS_DIR}/{scenario-slug}.png`. Use `mcp__playwright__browser_take_screenshot` with the full path.
|
|
74
|
+
- Continue with subsequent steps in the same scenario if possible.
|
|
75
|
+
- If a setup step fails, mark the whole scenario as SKIP.
|
|
76
|
+
|
|
77
|
+
### 5. Error detection
|
|
78
|
+
|
|
79
|
+
After each significant action, check the browser for error indicators:
|
|
80
|
+
- Error pages (500, 404, etc.)
|
|
81
|
+
- Error toasts or notification banners
|
|
82
|
+
- Form validation messages
|
|
83
|
+
|
|
84
|
+
## Output format
|
|
85
|
+
|
|
86
|
+
Return your report using this EXACT format:
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
## Feature: {feature_name}
|
|
90
|
+
|
|
91
|
+
### PASS: Scenario name
|
|
92
|
+
Brief confirmation of what was verified, including actual values seen.
|
|
93
|
+
|
|
94
|
+
### FAIL: Scenario name
|
|
95
|
+
**Failed step**: The step that failed
|
|
96
|
+
**Error**: What went wrong
|
|
97
|
+
**Expected**: Expected values
|
|
98
|
+
**Observed**: Actual values seen in the UI
|
|
99
|
+
**Screenshot**: [description]
|
|
100
|
+
|
|
101
|
+
### SKIP: Scenario name
|
|
102
|
+
**Reason**: Why the scenario was skipped
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Rules
|
|
106
|
+
|
|
107
|
+
- Execute ONLY the scenarios provided
|
|
108
|
+
- Report EVERY scenario
|
|
109
|
+
- Be autonomous: don't ask questions, figure it out
|
|
110
|
+
- Take screenshots ONLY on failures
|
|
111
|
+
- Close the browser with `mcp__playwright__browser_close` when done
|
|
112
|
+
- When creating test data, use distinctive names (e.g. include a timestamp or random suffix)
|
|
113
|
+
|
|
114
|
+
Begin testing now!
|