Skip to content

Docs + two behavioural bugs found while evaluating a Cucumber → Varar migration #57

Description

@aslakhellesoy

Findings from evaluating a real Cucumber → Varar migration (a SvelteKit/Cloudflare-Workers app with 12 .feature files, ~31 scenarios, 103 step definitions) against v0.6.1 and the docs at varar.dev.

The evaluation went well — Varar's vitest plugin runs .feature files unchanged inside @cloudflare/vitest-pool-workers, which is a great story. Most of what follows is documentation, but two items are behavioural bugs I hit while reading the docs and expecting something else.


1. Bug: a sensor with ≥1 slot that returns undefined silently passes

core/dist/execute.js guards the comparison with if (!ex.rowChecks && returned !== undefined). So a one-slot sensor whose handler returns undefined performs no comparison at all and the example goes green.

// steps
const { stimulus, sensor } = steps(() => ({ name: '', total: 0 }))
stimulus('I set the name to {string}', (state, name) => ({ ...state, name }))
stimulus('I add {int}', (state, n) => ({ total: state.total + n }))  // drops `name`
sensor('The name is {string}', (state) => state.name)               // -> undefined
I set the name to "Ada". I add 2. The name is "Ada".
✓ I set the name to "Ada". I add 2. The name is "Ada" (1ms)
1 example, 1 passed, 0 failed

state.name is undefined at that point (confirmed by throwing from the sensor: STATE={"total":2}), yet the document claims "Ada" and the run is green.

The reference table only defines undefined as the return for the 0-slot case ("throw to fail, return nothing to pass"). For 1+ slots it says "the slot's value, bare" — it doesn't say undefined opts out. This is exactly the silent-coverage-loss failure mode the drift-detection page argues against, and a plain typo in a property access reaches it. Suggestion: a sensor with ≥1 slot returning undefined should be a ReturnShapeError, and the sensors reference should state the rule either way.

2. Bug: StimulusFn is typed Partial<C> but the runtime replaces state

// varar/dist/internal.d.ts
type StimulusFn<C, Custom> = <E extends string>(
  expression: E,
  handler: (state: DeepReadonly<C>, ...args) => Partial<C> | void | Promise<Partial<C> | void>,
) => void

execute.js is explicit that this is not a patch:

A stimulus REPLACES state ... There is no merge — a return with fewer keys shrinks the state.

So Partial<C> type-checks the one thing the semantics forbid, and everything downstream keeps the full DeepReadonly<C> type while the value has lost keys — silently, as in §1. It should be C | void. (The other ports get this right: the Rust/C#/Go samples carry a literal // FULL-REPLACEMENT state comment.)

The docs disagree with themselves here too:

  • Stimuli: "returning the complete next state — full replacement ... a return that leaves a field out drops it rather than preserving it". ✅
  • Your first spec from scratch: "The stimulus drives the software. It matches ... computes, and returns a patch to the state." ❌ — this is the first place a new user meets stimulus.

3. Undocumented: scannerPlugins, and that .feature files already run

gherkinTables / gherkinDocStrings and the scannerPlugins key of varar.config.json appear on no page of varar.dev (grepped all 20 URLs in the sitemap). They're only discoverable by reading @varar/core's dist.

This matters because Varar for Cucumber users says:

Our goal is to make Varar capable of running existing .feature files without any change to them. ... TODO: Finish the adapter implementation and test it extensively

…which reads as "not yet possible". But it already substantially works today. With:

{
  "docs": { "include": ["src/features/**/*.feature"], "exclude": [] },
  "steps": ["src/features/**/*.steps.ts"],
  "scannerPlugins": ["gherkinTables", "gherkinDocStrings"]
}

an untouched .feature file runs green: Feature:/Rule: lines fall out as prose, Given/When/Then/And keywords are narration the matcher ignores, each Example: becomes exactly one Varar example (its contiguous step lines are one paragraph), and indented Gherkin tables attach as data tables. Across the 12 real feature files the planner produced one example per scenario, as intended.

Worth a how-to page ("Run your existing .feature files"), plus a reference entry for scannerPlugins. Two caveats belong in it:

  • Background: has no equivalent. It's a blank-line-separated paragraph, so it becomes its own example with its own fresh state rather than running before each scenario. It has to be inlined into each scenario by hand.
  • Example names change. A Varar example is named after its whole paragraph, so a scenario is named Example: Simple basket Given the following users have been imported: — which breaks vitest -t "<scenario name>" filtering that Cucumber users rely on.

4. Undocumented: the whole drift-acceptance workflow

Examples › Drift detection explains drift at length and says the run "fails until you explicitly acknowledge the drift" — but never says how. Nowhere on the site do these appear:

  • varar.lock.json — the baseline file (it's described only as "the recorded baseline"). Not named, not shown, no guidance on committing it.
  • varar run --update — only in varar --help.
  • VAR_UPDATE=1 / VAR_UPDATE=true — the only way to accept drift under the vitest plugin (vitest/dist/runtime.js reads process.env.VAR_UPDATE), since there's no --update flag there. Run specs through vitest doesn't mention drift at all.

Also note the env var is VAR_UPDATE, not VARAR_UPDATE — see §7.

5. varar init doesn't work out of the box (tutorial is unrunnable as written)

Following Get started on your computer verbatim in a fresh npm init -y project on Node v22.13.1:

$ pnpm add -D @varar/varar @varar/cli
$ npx varar init
created varar.config.json
created varar-examples/deep-thought.md
created varar-examples/steps/deep-thought.steps.ts
$ npx varar run
varar: Unknown file extension ".ts" for .../varar-examples/steps/deep-thought.steps.ts

With NODE_OPTIONS=--experimental-strip-types:

varar: Cannot use import statement outside a module

It only runs after also adding "type": "module" to package.json. Two things scaffolded by varar init itself (a .ts step file, ESM import) are what break it, and the tutorial's very next block shows a passing run. Options: have init set "type": "module", have the CLI register a TS loader (or detect and print the fix), and state the Node baseline in the prerequisites.

6. run-with-vitest omits things you need in a non-trivial config

  • The plugin's config() hook overwrites test.include and test.exclude from varar.config.json. Fine and desirable, but surprising — worth saying, especially for the test.projects layout where you scope the plugin to one project so it doesn't clobber your unit-test globs. (It does work correctly there; I verified a projects: [unit, features] config.)
  • No mention of scannerPlugins (§3) or drift (§4).
  • "Varar has no lifecycle hooks of its own. Use vitest's native beforeEach / afterEach" — but afterEach has no access to the example's state, and the state factory is per-step-file, so there's no documented way to tear down something the example put in state (a spawned workflow, a created record). Migrating a Cucumber After(world => ...) hook means re-deriving everything from the outside. Worth calling out explicitly, since it's a real constraint rather than an oversight.

7. var vs varar naming is inconsistent across docs, API and code

The rename looks incomplete, and it's confusing when the CLI, the config file and the package are the three things a newcomer types first:

Where Says
Cucumber page "a file is a spec iff it matches the globs in var.config.ts" — stale; it's varar.config.json everywhere else
Most pages "the var CLI", "the JVM ports have no var CLI yet" — the binary is varar
Python sample from **var** import steps (package is pytest-varar)
Rust sample use **var**::{Handler, Steps, Value} (crate is varar-cargotest)
vitest how-to import **varPlugin** from '@varar/vitest', **Var**ResultsReporter
Runtime **VAR_UPDATE** env var
Page slugs /explanation/**var**-overview/, /explanation/**var**-for-cucumber-users/, /tutorials/try-**var**/

Relatedly, @varar/core/dist/config.js still ships a dead loadVarConfig that looks for var.config.ts / var.config.js / var.config.mjs with a vars: key — superseded by @varar/config, and the likely source of the stale doc reference above.

8. Small stuff

  • Examples: "matches those sentences against your registered stimuli and sensos" → sensors.
  • first-spec: "Steps come in roles ... (There's also context, for setting up the starting state — you'll meet it in bigger specs.)" — there is no context role; the API and the Cucumber page both say there are exactly two. If this refers to the steps(factory) state factory, it shouldn't be called a role.
  • Published @varar/core and @varar/vitest both export VERSION = '0.0.0' at 0.6.1 — the version stamp isn't being replaced at build time.
  • varar lint is documented only in --help, not on the site. Its behaviour ("a sentence that matches nothing is prose, so nothing is reported") is correct by design but is the single biggest expectation gap for a Cucumber user, who expects an "undefined step" list with snippets. Worth an explicit note.

Happy to send PRs for any of these — say which you'd like split out into separate issues.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdocumentationImprovements or additions to documentation

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions