The workflow
I write the .feature file. The LLM writes the step definitions and the code. The tests run red. The LLM reads the failure, changes the code, re-runs, and keeps going until it's green. Then I review.
The loop
1. I write the story: what I'd say to my AI at the gym, and what should come back. 2. An agent writes the tool until the story is green. 3. The stories stay, so the next agent edit can't quietly break the contract. The specs are plain English; the agent reads them as the requirement, and the suite is the guardrail.
This is the whole quick-log spec, verbatim:
Feature: Logging a set in one sentence
Scenario: Logging a set with plain text
When my AI calls "quick_log" with:
"""
{"text": "bench 100kg x5,5,4 @8"}
"""
Then the call succeeds
And the response mentions "100"
Scenario: Same as last time
Given I did a session 3 days ago with "bench 90kg x5,5,5"
When my AI calls "quick_log" with:
"""
{"text": "bench same as last time"}
"""
Then the call succeeds
And the response mentions "90""Same as last time" only works if the tool reads history. The story says so, in one line, before there's any code.
How the suite is organized
features/
├── mcp/ 18 feature files, one per area, 89 scenarios
│ ├── analytics.feature 40 lines
│ ├── auth.feature 39
│ ├── clarify.feature 26
│ ├── compose.feature 10
│ ├── export.feature 20
│ ├── library.feature 50
│ ├── live_workout.feature 105
│ ├── nutrition_body_goals.feature 183
│ ├── presentation_calendar.feature 126
│ ├── profile.feature 19
│ ├── programs.feature 67
│ ├── prompts_resources.feature 34
│ ├── quick_log.feature 17
│ ├── readiness.feature 34
│ ├── schedule.feature 18
│ ├── sessions.feature 52
│ ├── templates.feature 63
│ └── undo.feature 31
├── mcp.steps.ts 86 lines: the loop + every step sentence
└── support/
└── world.ts 66 lines: DB, in-process server, mint/call/answer/fill
vitest.bdd.config.ts 13 lines: picks up features/**/*.steps.tsfeatures/mcp/*.feature: one file per area (sessions, programs, analytics, auth…). 18 files, 89 scenarios, 424 steps.features/mcp.steps.ts: one test file that loops over every feature and defines every step sentence once.features/support/world.ts: the test harness. In-memory DB with production migrations, an in-process MCP server, and helpersmint,call,answer,fill.vitest.bdd.config.ts: tells Vitest to runfeatures/**/*.steps.ts, keepsbun:modules external, writes JUnit in CI.
import { defineConfig } from "vitest/config";
import { fileURLToPath } from "node:url";
const ci = !!process.env.CI;
export default defineConfig({
resolve: { alias: { "~": fileURLToPath(new URL("./src", import.meta.url)) } },
test: {
include: ["features/**/*.steps.ts"],
server: { deps: { external: [/^bun:/] } },
reporters: ci ? ["default", "junit"] : ["default"],
outputFile: ci ? { junit: "reports/bdd-junit.xml" } : undefined,
testTimeout: 20000,
},
});How the code works
One World per feature file. The steps file reads the folder, and for each .feature it builds a fresh World. BeforeAllScenarios runs the migrations once for that file; BeforeEachScenario creates a new user. So the database is isolated per file, and each scenario is isolated by user, not by database.
for (const file of readdirSync("features/mcp").filter((f) => f.endsWith(".feature")).sort()) {
const feature = await loadFeature(`features/mcp/${file}`);
describeFeature(feature, ({ Scenario, defineSteps, BeforeAllScenarios, BeforeEachScenario }) => {
const w = new World();
BeforeAllScenarios(async () => { await w.init(); });
BeforeEachScenario(async () => { await w.newUser(); });
defineSteps((K: any) => {
const all = (pat: string, fn: any) => { for (const k of ["Given", "When", "Then", "And", "But"]) K[k](pat, fn); };
const Given = all, When = all, Then = all;
// ...every step sentence...
});
for (const s of (feature as any).scenarii) Scenario(s.description, () => {});
});
}One sentence, every keyword. vitest-cucumber wants steps under the keyword they're used with. The all helper registers each sentence under Given, When, Then, And and But at once, so "Given/When/Then" in the steps file is only for reading. The empty Scenario(...) loop at the end just tells the library every scenario is handled by the shared steps.
The World. :memory: bun:sqlite, same Drizzle migrations as production, the exercise library seeded. No live data. Tokens are a plain map: mint(scope) makes a random token for the current user with a scope. Every new user starts with a read-write one.
export class World {
raw = new Database(":memory:");
db: any = drizzle(this.raw, { schema: S });
tokens = new Map<string, { userId: string; tokenId: string; scope: string }>();
async init() {
migrate(this.db, { migrationsFolder: "drizzle" });
// + seed the shared exercise library
}
async newUser() {
this.userId = uid("u_"); this.vars = {}; this.res = null; this.rpc = null;
await this.db.insert(S.users).values({ id: this.userId, email: `${this.userId}@bdd.test`, passwordHash: "x" });
await this.db.insert(S.profiles).values({ userId: this.userId });
this.token = this.mint("rackd2:read rackd2:write");
}
mint(scope: string) { const t = uid("rk2_"); this.tokens.set(t, { userId: this.userId, tokenId: uid("tk_"), scope }); return t; }Real JSON-RPC, no network. call wraps a tools/call request and hands a real Request to the same mcpPost handler production uses. Bearer parsing, scope checks and JSON-RPC errors all run. Only the token lookup and analytics are stubbed in deps(). It keeps the structured result in w.res and remembers ids ($session, $clarification…) so fill can drop them, and $today+1-style dates, into the next JSON docstring.
async send(method: string, params: unknown = {}) {
const headers: Record<string, string> = { "content-type": "application/json" };
if (this.token) headers.authorization = `Bearer ${this.token}`;
const r = await mcpPost(this.deps(), new Request("http://bdd.test/mcp", { method: "POST", headers, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }) }));
this.status = r.status; const txt = await r.text(); this.rpc = txt ? JSON.parse(txt) : null;
this.res = this.rpc?.result?.structuredContent ?? this.rpc?.result ?? this.rpc?.error ?? null;
this.remember(this.res); return this.res;
}
call(name: string, args: Record<string, unknown> = {}) { return this.send("tools/call", { name, arguments: args }); }Given / When / Then. Given steps set up the situation through the same MCP tools (a past session is create_session, start_session, log_sets, end_session), or swap the token for a narrower one. When steps make the call. Then steps are expect on the response.
Given("my AI is connected with a read-only token", () => { w.token = w.mint("rackd2:read"); });
Given("I weigh {int} kg", async (_c, kg: number) => { await w.call("update_profile", { bodyweight_kg: kg }); await w.answer(1); });
When("my AI calls {string} with:", async (_c, t: string, doc: string) => { await w.call(t, json(w, doc)); });
When("I answer {string} with {string}", async (_c, q: string, v: string) => { await w.call("answer_clarification", { clarification_id: w.vars.clarification, answers: { [q]: v } }); });
Then("my AI is asked to clarify", () => { expect(w.res?.status, JSON.stringify(w.res)).toBe("needs_clarification"); });
Then("the call is refused mentioning {string}", (_c, s: string) => { const txt = JSON.stringify(w.rpc).toLowerCase(); expect(w.rpc?.error || w.rpc?.result?.isError || w.status >= 400, txt).toBeTruthy(); expect(txt).toContain(s.toLowerCase()); }); Scenario: Spotting a stalled lift
Given I did a session 15 days ago with "bench 100kg x5,5,5"
And I did a session 10 days ago with "bench 100kg x5,5,5"
And I did a session 5 days ago with "bench 100kg x5,5,4"
When my AI calls "detect_stalls"
Then the call succeeds
And the table shows "Bench Press"needs_clarification
The tools are told never to guess. An ambiguous exercise, a duplicate, or a 10% bodyweight jump returns needs_clarification with questions. The AI asks me and calls answer_clarification. In specs I answer explicitly, or w.answer(n) picks option n for every open question until the tool completes. Given steps use it so setup never stalls on a question.
Scenario: Adding an exercise that is already in the session asks first
Given I created a session with "bench press"
When my AI calls "add_session_exercises" with:
"""
{"session_id": "$session", "exercises": [{"exercise": "bench press"}]}
"""
Then my AI is asked a question mentioning "already"
When I answer "dup:Bench Press" with "skip"
Then the session lists exercises "bench press" /** Answer every open question with option n (1-based) until the tool completes. */
async answer(n = 1, rounds = 4) {
for (let i = 0; i < rounds && this.res?.status === "needs_clarification"; i++) {
const answers: Record<string, string> = {}; for (const q of this.res.questions) answers[q.id] = q.options?.[Math.min(n, q.options.length) - 1]?.value ?? "ok";
await this.call("answer_clarification", { clarification_id: this.res.clarification_id, answers });
}
return this.res;
}Result
$ bun run test:bdd # bunx --bun vitest run -c vitest.bdd.config.ts
✓ features/mcp.steps.ts (424 tests) 1923ms
Test Files 1 passed (1)
Tests 424 passed (424)
Duration 2.47sVitest counts steps, not scenarios: 89 scenarios, 424 steps, all green in about two seconds. That speed is the point. The agent runs it after every change.
Changing a feature
Same loop, smaller. I edit or add the scenario first. That one goes red; the other 88 stay green. The LLM changes the code until it's green too. If something else turns red on the way, that's the regression net doing its job. Deleting a scenario is how a behaviour goes away. The .feature files stay the living spec: what's in them is what the tools do.
The rule: the LLM never edits a .feature file to make it pass. Only I edit specs. It changes code.
Example (hypothetical change) — cap warm-ups at 3 sets
Before, in presentation_calendar.feature:
Then "warmup_detail" is not set
And at most 4 warm-up sets are plannedAfter, one number changed by me:
Then "warmup_detail" is not set
And at most 3 warm-up sets are plannedThe step already exists, so no new step code. The scenario goes red, the LLM edits the warm-up planner, everything goes green.
The specs are portable
Nothing in the .feature files is TypeScript. They're plain Gherkin, the same format Cucumber uses, so the same specs run unchanged in other languages. Only the step functions get rewritten. Here it's@amiceli/vitest-cucumber; in the browser it could be playwright-bdd; in Python, pytest-bdd orbehave (withbehave-django); in .NET, Reqnroll.
Illustrative example, not Rackd2 code — one step in Django with pytest-bdd
from pytest_bdd import given, parsers
@given(parsers.parse("I weigh {kg:d} kg"))
def set_weight(user, kg):
user.profile.bodyweight_kg = kg
user.profile.save()Same sentence, same scenario, different glue. In Django the test database plays the role of the isolated DB: each test gets a clean one and nothing leaks between scenarios.
What it doesn't test
- The LLM's choices. Whether Grok picks
quick_logorlog_sets, reads the clarification back, or invents a weight. That needs evals with real models, not specs. - OAuth. Tokens are minted in a map; the real connect flow is tested elsewhere.
- Cloudflare. SQLite in memory, not the deployed database or the Worker.
- Tool descriptions. A spec can pass while a description still confuses a model.
So: specs for the contract, evals for the model. The specs are the cheap half, which is why they come first.