E2E Authoring Rules

SkillFiles & storage

E2E testing with Cypress and Cucumber, feature files, step definitions, page objects, fluent interface, assertions, data-app-busy / waitUntilAppIsNotBusy pairing, test execution. Use when writing Cypress/Cucumber E2E tests.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the E2E Authoring Rules skill

What this skill tells your AI

The instructions your AI receives, as published by nerds-odd-e/doughnut in .agents/skills/e2e-authoring/SKILL.md and read by ahel’s review.

Use this skill when writing or modifying E2E tests, Cypress/Cucumber feature files, step definitions, page objects, or E2E assertions. For OCR-specific canvas assertions, use the e2e-ocr skill.

Environment Assumption

Assume the developer has already started services with pnpm cy:run (the wrapper owns its stack), including backend auto-reload, frontend HMR, and Mountebank. Services automatically restart when code changes are made.

If services are not running, suggest running pnpm cy:run --spec <feature> in a separate terminal.

Cypress Origin

Primary unconfigured checkouts and CI use http://localhost:5173 as the Cypress app origin through the local load balancer. A linked worktree (or a primary with .worktree.local.json) uses that checkout's isolated origin http://127.0.0.1:<lbListenPort> — see docs/worktree-browser-tests.md. Isolated Cypress accepts one or more selections from the assessed active feature set declared in scripts/isolated-cypress-spec-selection.mjs (application-only active specs plus the active CLI, MCP, OpenAI-mock, and Wikidata-mock focused specs) per run. The CLI command is scoped to the assessed active CLI workflows. The MCP command is scoped to that one search/graph feature. OpenAI-mock specs start a runner-owned private OpenAI Mountebank; Wikidata-mock specs start a runner-owned private Wikidata Mountebank; application-only, CLI, and MCP specs do not require Mountebank. Each isolated checkout needs its own healthy E2E allocation. Paired CLI-vs-browser reset proof:

CURSOR_DEV=true nix develop -c node scripts/worktree-reset-isolation-harness.mjs --mode cli --peer <cli-checkout> --resetter <browser-checkout>

CI vs pnpm cy:run, wait-on, NO_PROXY, and env vars are documented in docs/gcp/prod_env.md, Local dev / Cypress under section 6.

Run E2E Tests

Agents should default to pnpm cypress run --spec with one or more .feature paths tied to the changed capability. Add adjacent specs only when regression risk warrants it. Do not run bare pnpm cypress run, pnpm verify, or the whole suite unless the user explicitly asks for a full E2E run or the instructions require reproducing CI.

CURSOR_DEV=true nix develop -c pnpm cypress run --spec e2e_test/features/ai_generated_recall_questions/question_contest.feature

Important notes:

  • Do not use cypress run -- --spec; the empty -- causes the --spec parameter to be ignored.
  • The default tag filter is expose.tags in e2e_test/config/ci.ts, for example not @ignore. Override per run with --expose tags='...' (-x), not --env tags=... (ignored by @badeball/cypress-cucumber-preprocessor v27 on Cypress >= 15.17).
  • @skipOptimizationDueToKnownNecessarySlowness is Donut's profile-only exclusion for known-necessary slowness. dough-test-optimization profiles exclude it with --expose tags='not @ignore and not @skipOptimizationDueToKnownNecessarySlowness'. Normal CI/dev runs still execute these scenarios. Do not add this tag without developer review, except when an explicit dough-test-optimization --resolve pass makes an evident exclusion under the public skill's candidate-resolution rules.
  • @focus and @only are local debugging aids only. Do not commit them; CI runs ./scripts/check_focus_tags.sh on .feature files to block @focus, and @only should be treated the same.
  • If any node in a feature file is tagged @focus or @only, @badeball/cypress-cucumber-preprocessor filters scenarios in that file to @focus or @only only.

Test-optimization profiling

For per-scenario timing, run the selected feature scope through the owning wrapper, tee reporter output to a local file, and keep the profile-only exclusion:

CURSOR_DEV=true nix develop -c pnpm cy:run --spec '<feature selection>' --reporter json --expose tags='not @ignore and not @skipOptimizationDueToKnownNecessarySlowness' 2>&1 | tee /tmp/donut-e2e-profile.log

The JSON reporter prints one stats/tests object per spec. Associate each object with the preceding Running: <feature> line; do not assume reporter file output works. If mirroring CI, also exclude @wip. This filter is profiling-only and must not narrow normal verification.

Debugging

Use the canonical log-tail command for backend E2E log inspection:

CURSOR_DEV=true nix develop -c pnpm logs:tail backend-e2e

Frontend Vite checker terminal errors still matter even though the browser overlay is disabled for E2E stability. For canonical lint and format commands, use the linting_formating skill.

Technology And Structure

  • Cypress for E2E testing.
  • Cucumber plugin for BDD-style tests.
  • TypeScript for type safety.
  • Mountebank mocks external services.
  • e2e_test/features contains BDD-style tests.
  • e2e_test/step_definitions contains step definitions.
  • e2e_test/start/ contains page objects.
  • e2e_test/start/pageObjects/cli/ contains CLI page objects with the cli.xxx prefix.
  • e2e_test/support/ contains support files.

Focus on Behavior

Behavior (external behavior) has 3 parts: pre-condition, trigger, and post-condition. Each part is:

  • externally observable
  • of user value
  • typically involves state change.

Avoid e2e test that is only about the presentation of a state and having no state change after setup / given.

Gherkin

  • Write features in Gherkin syntax focused on behaviors that has business value.
  • Group related features in domain-specific folders.
  • Name feature files and titles by domain/capability, such as note_creation.feature or spaced_repetition.feature. Never name them by planning sequence number or delivery order.
  • Use tags, such as @usingMockedOpenAiService or @mockBrowserTime, to control test execution.
  • Keep scenarios focused and concise.
  • Prefer Scenario Outline with an Examples table when the path and assertion focus are the same and only data varies. Do not copy-paste near-identical Scenarios for different inputs. Keep separate Scenarios when the observable behavior differs (see Explicit Conditions and step-definition guidance on variants).

Step Definitions

  • Keep step definitions lightweight.
  • Delegate implementation details to page objects, preferably as fluent chains.
  • When user-visible behavior differs, use different steps and thin implementations. Do not hide variants behind one parameterized step.
  • Use TypeScript and reuse steps where practical.
  • Use parameter types for complex objects.
When("I start a conversation about the note {string}", (noteTopology: string) => {
  start.jumpToNotePage(noteTopology).startAConversationAboutNote()
})

Page Objects

Use page objects with a fluent interface: methods perform an action and return the next page object, or this when staying on the same screen. This keeps step definitions short and navigation explicit in the types.

  • Navigation (ADR 0005). E2E navigation goes through e2e_test/start/router.ts (visitNamed / named push). Compile hrefs with namedLocationHref / noteShowHref. Page objects and steps do not call cy.visit with SPA path strings (scripts/check_e2e_spa_visit_gate.sh in CI).

    IntentMechanism
    Unique trigger is in-app navigationUI
    Given-shaped shortcut (including Gherkin When I visit … when the unique behavior is on that screen)Named router.push after first load
    First SPA load, inbound URL, or explicit remountcy.visit of href compiled from the named table
  • Recall: visitRecallPage is remount (visitNamed('recall')); navigateToRecallPage is sidebar UI. Gherkin When I visit recall stays remount — do not convert it to sidebar.

  • Centralize UI interactions in page objects.

  • Prefer fluent chaining over one-off helpers that do not return a navigable object.

  • Keep page objects focused on a single responsibility.

  • Use meaningful method names that reflect user actions.

const notePage = {
  startAConversationAboutNote() {
    this.toolbarButton("Start a conversation").click()
    return conversationPage()
  },
}

CLI steps use cli.xxx page objects, not start.xxx. For the install feature, use cli.backend(), cli.installation(), and cli.nonInteractiveOutput() for spawned version / update stdout. The @interactiveCLI-tagged CLI files (cli_access_token.feature, cli_interactive_mode.feature, cli_recall.feature) and cli_gmail.feature are @ignore in CI; extend Vitest under cli/tests/interactive/ for interactive TTY coverage instead of adding a second Cypress PTY harness.

Then("I should see {string} in the non-interactive output", (expected: string) => {
  cli.nonInteractiveOutput().expectContains(expected)
})

Waiting until the app is not busy

Product loading UI that means unfinished work marks itself with data-app-busy (LoadingThinBar from apiCallWithLoading, ContentLoader for pending page data, LoadingModal for blockUi). See .agents/skills/frontend/SKILL.md.

E2E waits with waitUntilAppIsNotBusy() from e2e_test/start/pageBase.ts (also start.waitUntilAppIsNotBusy()):

import { waitUntilAppIsNotBusy } from '../pageBase'

cy.findByRole('button', { name: 'Submit' }).click()
waitUntilAppIsNotBusy()

Rules of thumb:

  • Call waitUntilAppIsNotBusy() in the page object on the action that starts loading, before the next step that needs that work’s result.
  • Prefer asserting a user-visible success outcome when the product provides one; use the waiter for “busy cleared,” not as a substitute for success.
  • Do not use hardcoded waits (cy.wait(ms)) for this.
  • Do not skip the wait and rely on a later navigation’s loading check alone — that races when the next step does not go through busy UI.
  • Network cy.intercept / cy.wait('@…') is optional; it is not the documented default stand-in for this product/E2E pair.

Test Data And Service Mocking

  • Use Given steps to set up test data.
  • Mock external services, such as OpenAI, for reliable tests.
  • Use tags to indicate when mocks are required.
  • Keep mock responses consistent with real service behavior.
  • Store mock data separately from test code.
  • Clean up test data after each test.
  • Use data tables for complex test data.
Given I have a notebook "Geometry set" with notes:
  | Title  |
  | Shape  |
  | Square |

Square is placed under Shape when the notebook is created in one inject batch. Do not add a Folder column; see testability inject ordering.

Given("OpenAI assistant will reply below for user messages in a stream run:", (data: DataTable) => {
  mock_services
    .openAi()
    .stubCreateThread("thread-123")
    .createThreadAndStubMessages("thread-123", data.hashes())
})

Assertions

Error messages must be immediately actionable:

  1. Assert with clear domain meaning, not just technical selectors.
  2. Show expected and actual values when they differ.
  3. Include relevant context, such as page content or API response, when helpful.
  4. Given, When, and Then steps should be assertive and fail early.
  5. Put assertions inside page objects where that keeps step definitions simple.
expectQuizScore(expectedScore?: string) {
  if (expectedScore) {
    cy.get("[data-test='quiz-score']").should(($score) => {
      const actualScore = $score.text().trim()
      expect(
        actualScore,
        `Expected quiz score to be ${expectedScore}, but found ${actualScore}`
      ).to.equal(expectedScore)
    })
  }
  return this
}

Explicit Conditions

A scenario describes one fixed path. Prefer dumb automation: the path is visible in Gherkin and in small, purpose-named page-object methods, not inferred at runtime from a string or flag.

  • Avoid large if / switch trees, parameter sniffing, and mode flags in steps and page objects except when the branch exists only to assert or fail fast with a clear error.
  • Distinguish variants at the caller with different steps and/or page-object methods, not one smart API.
  • State the condition in step wording when behavior differs, for example slash command vs plain line vs Enter alone vs ESC.
  • For CLI TTY sequences, add or extend Vitest under cli/tests/interactive/ instead of adding a second Cypress PTY stack.

Before hook order

@badeball/cypress-cucumber-preprocessor defaults Before hooks to order 10000. The shared DB reset Before uses order: 0 so it runs before tagged setup. Hooks that start long-lived side effects (e.g. @interactiveCLI PTY at order 2) must stay after that reset; otherwise truncate can block on MySQL locks held by the PTY’s HTTP traffic.

Pitfalls

  • Do not put complex logic in step definitions.
  • Do not create long scenarios with many steps.
  • Avoid using UI language like "click a button" in Gerhkin. Use intention and domain language.
  • Do not rely on test order.
  • Do not use hardcoded waits.
  • Do not fire a user action that starts app-busy loading and then assert domain state without waitUntilAppIsNotBusy() (or a clearer user-visible success signal) first.
  • Failure handling in steps and page objects: ADR 0006.
  • Do not mix different levels of abstraction in scenarios.

Signals

GitHub stars
49
Forks
72
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
e2e-authoring
Source
github.com/nerds-odd-e/doughnut