@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 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
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
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,2 @@
1
+ export declare function discoverFeatures(projectRoot: string, target?: string): string[];
2
+ export declare function getDomain(featurePath: string, projectRoot: string): string;
@@ -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
@@ -0,0 +1,2 @@
1
+ export declare function loadDotenv(projectRoot: string): void;
2
+ export declare function expandVars(text: string): string;
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 {};
@@ -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[]>;
@@ -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
+ });
@@ -0,0 +1,7 @@
1
+ import type { ParsedFeature } from "./types.js";
2
+ export declare function buildPrompt(options: {
3
+ features: ParsedFeature[];
4
+ scenarioFilter: string | null;
5
+ configContent: string;
6
+ screenshotsDir: string;
7
+ }): string;
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;
@@ -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
+ });
@@ -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
+ });
@@ -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!