
Cucumber Sentences
- 8 installs
- Updated May 6, 2026
- donkeycode/skills-cucumber-sentences
cucumber-sentences is an agent skill that helps you write Cucumber features and page objects using the cucumber-sentences Ruby gem DSL.
About
cucumber-sentences is an agent skill for Ruby projects that depend on the donkeycode cucumber-sentences gem. It helps solo builders and small teams keep browser acceptance tests consistent: one shared Gherkin vocabulary built on page-object, Watir, and rspec-expectations instead of reimplementing Selenium boilerplate per page. Invoke it when you create or edit `.feature` files, add buttons or fields that must be addressable from scenarios, introduce a new page class, or debug undefined Cucumber steps. The agent learns when to reuse the gem’s ~39 generic steps versus authoring custom definitions, and how to wire PageObject lookups correctly. That matters for indie SaaS teams who want readable scenarios stakeholders can skim while still automating against real browsers. Complexity is intermediate because you need Ruby, Cucumber, and the host project’s page map. It is an integration-style skill anchored to a specific open-source gem, not a generic test generator.
- ~39 pre-built generic Cucumber step definitions via a single ~500 LOC library file
- Standardizes Gherkin for clicks, fields, and named elements across page-object classes
- Guides when to reuse library sentences versus custom step definitions
- Covers Domain helpers, typed `of <type> "<id>"`, and state holder conventions
- Resolves undefined-step errors by aligning pages with get_button / get_field / get_element_by_name
Cucumber Sentences by the numbers
- 8 all-time installs (skills.sh)
- Ranked #1,571 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/donkeycode/skills-cucumber-sentences --skill cucumber-sentencesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 8 |
|---|---|
| Security audit | 2 / 3 scanners passed |
| Last updated | May 6, 2026 |
| Repository | donkeycode/skills-cucumber-sentences ↗ |
What it does
Write and extend Cucumber feature files and page objects using the cucumber-sentences Ruby gem’s shared Gherkin DSL.
Who is it for?
Ruby/Watir teams already using or adopting the cucumber-sentences gem for acceptance tests.
Skip if: JavaScript Playwright-only stacks, unit-test-only workflows, or projects with no Cucumber dependency.
When should I use this skill?
Writing or editing Cucumber `.feature` files, page-object classes, or step definitions in a project that depends on the cucumber-sentences Ruby gem; adding scenarios, undefined steps, new pages, or UI elements addressabl
What you get
Scenarios reuse the gem’s shared sentences and correctly mapped page objects so new UI elements are feature-addressable without rewriting step glue.
- Updated `.feature` scenarios using shared sentences where appropriate
- Page-object classes with correct element lookup helpers for new UI
By the numbers
- ~39 pre-built generic Cucumber step definitions
- Single library file ~500 lines of code (lib/cucumber-sentences.rb)
Files
cucumber-sentences
A Ruby gem that ships ~39 pre-built generic Cucumber step definitions on top of page-object + watir + rspec-expectations. The whole library is a single file; once required, every page in the host project speaks the same Gherkin DSL without re-implementing selenium boilerplate.
Source: <https://github.com/donkeycode/cucumber-sentences> · gem name:cucumber-sentences· file:lib/cucumber-sentences.rb(~500 LOC).
---
When to invoke this skill
Trigger on any of:
- A
.featurefile is being created or edited and you must decide whether to reuse an existing sentence or write a custom step. - A new button, field, or element on a page must become referenceable from features (
I click on the button "...",I fill "..." field with "...", …). - A new page is being introduced — needs a
PageObject-style class with theget_button/get_field/get_element_by_namelookups wired correctly. - A
Cucumber::Undefinederror or "undefined step" feedback appears — the phrase is probably misspelled relative to the catalogue, or the page lacks a name in its lookup hash. - A request to add a "kind" of object resolvable by name (
I am on the "OrderPage" of order "ABC-123") — needs a typed helper class undersupport/helpers/. - A request to share state across steps in the same scenario (an extracted email link, a generated id, …) — needs a session-scoped state holder.
---
The mental model: phrase → element in five hops
Cucumber-sentences is glue, not magic. Every browser-touching phrase follows the same chain. Internalise this before writing or debugging steps.
Gherkin string
│ ① Cucumber regex match (captures the name)
▼
get_button("save profile") ← method on @current_page (a PageObject)
│ ② Hash lookup that you wrote on the page class
▼
save_element ← page-object accessor
│ ③ Synthesised by `button(:save, :xpath => "...")`
▼
Watir::Element ← live DOM handle
│ ④ Watir verb (when_visible, click, value=, text, …)
▼
Browser action ← what actually happens| Phrase family | Resolver method called on @current_page |
|---|---|
... button "<name>" ... (click, see, not see, see disabled) | get_button(name) |
... field "<name>" ..., `I fill "<name>" {field\ | autocomplete\ |
I should not see the element "<name>", I can [not] see "..." in element "<name>", I scroll to "<name>", I hover over the element "<name>", I should see the element "<name>" | get_element_by_name(name) |
I fill "<name>" ckeditor field with "...", I can see "..." in ckeditor "<name>" | get_ckeditor(name) |
I fill "<name>" js field with "...", I force scroll to "<name>" | get_js_selector(name) |
I upload a file with the filename "..." in element "<name>" | get_field(name) (Watir treats file inputs as fields) |
The layered indirection — Gherkin name → get_X hash → page-object accessor → Watir handle — is deliberate:
- Gherkin names track product copy, not markup.
get_Xhashes localise renames in a single file.- Page-object declarations isolate locator strategy (xpath/id/css).
- Watir is the only stable thing the gem talks to.
Adding a new button to a feature does not require a new step. Extend the page's locator block + the get_button hash, and the existing When I click on the button "..." resolves it. Authoring custom steps is the last resort, not the first.
---
Decision tree
When asked to make a new sentence work, walk it in this order:
1. Is the sentence already in the catalogue? Open references/sentences-catalogue.md and search. If yes, the only work is on the page class (add a locator + a hash entry). Stop. 2. Is it a near-miss spelling? Compare against the regex column. The phrases use double quotes only; embedded " is not supported. "in input" vs "in element" resolve to different methods. 3. *Does it need a new kind of object (a `<type>` lookup)? Add a class in `support/helpers/<type>.rb` exposing `self.get(identifier)`. See `references/helpers-and-state.md`. 4. Does it need to share data between steps? Add a state-holder class in `support/helpers/`. See `references/helpers-and-state.md`. 5. Genuinely new behaviour? Only now write a custom step in `features/step_definitions/.rb. Compose with step "..."` whenever possible to reuse existing sentences.
---
Fast templates (copy/adapt)
Step-definitions entry point
features/step_definitions/imports.rb:
ENV['CUCUMBER_ROOT'] = File.absolute_path('../', File.dirname(__FILE__))
require "cucumber-sentences"This must run before any other step file — ENV['CUCUMBER_ROOT'] is required for the gem's dynamic helper-loading. See templates/step_definitions_imports.rb.
support/env.rb
require 'page-object'
$api_url = ENV['API_URL'] || "https://api.example.test/"
$front_url = ENV['FRONT_URL'] || "https://app.example.test/"
$mailhog_url = ENV['MAILHOG_URL'] || "http://localhost:8025/"
$site_url = $front_url # default base URL injected as params['site_url']
World PageObject::PageFactorysupport/hooks.rb
require 'watir'
browser = Watir::Browser.new :chrome, options: {
args: %w(--no-sandbox --window-size=1600,1200) + (ENV['IS_DEV'] ? [] : %w(--headless=new))
}
Before { @browser = browser }
at_exit { browser.close }
After do |tc|
if tc.failed?
@browser.driver.save_screenshot("logs/#{tc.name}.png")
File.write("logs/#{tc.name}.log", @browser.driver.logs.get(:browser).join("\n"))
end
endA page class — the only file most contributors edit
class LoginPage
include PageObject
page_url "<%=params['site_url']%>login" # ERB-rendered with the params hash
text_field(:email, :id => "email")
text_field(:password, :id => "password")
button(:submit, :xpath => "//button[@type='submit']")
div(:formError, :xpath => "//global-error//div")
# Required by `I fill "<name>" field with "..."`, `I can see "..." in input "<name>"`,
# `I should not see the field "<name>"`, etc.
def get_field(name)
{
"email" => email_element,
"password" => password_element,
}[name]
end
# Required by `I click on the button "<name>"`, `I should [not] see the button "<name>"`.
def get_button(name)
{
"log in" => submit_element,
}[name]
end
# Required by `I should [not] see the element "<name>"`, `I can [not] see "..." in element "<name>"`,
# `I scroll to "<name>"`, `I hover over the element "<name>"`.
def get_element_by_name(name)
{
"form error" => formError_element,
}[name]
end
endSee templates/page.rb for the fuller template (CKEditor, JS-selector, custom redirect matching with is_on_page).
A typed helper for of <type> "<identifier>"
# support/helpers/order.rb
class Order
def self.get(identifier)
# Return a Hash of params used by the page's `page_url` ERB template.
# Only `site_url` is added later by the gem; the rest is up to you.
{
"ABC-123" => { "id" => "abc-123-uuid", "tab" => "summary" },
}[identifier]
end
endThen in a feature:
Given I am on the "OrderEditPage" of order "ABC-123"Page must template the params:
class OrderEditPage
include PageObject
page_url "<%=params['site_url']%>orders/<%=params['id']%>?tab=<%=params['tab']%>"
endSee templates/helper_typed.rb and references/helpers-and-state.md.
A session-scoped state holder
# support/helpers/extracted_link.rb
class ExtractedLink
@@value = nil
def self.set(v); @@value = v; end
def self.get; @@value; end
endUse from any step:
When(/^I save the link from the email$/) do
href = @browser.execute_script("return document.querySelector('iframe').contentDocument.querySelector('a').href")
ExtractedLink.set(href)
end
When(/^I follow the saved link$/) do
@browser.goto ExtractedLink.get
end@@-style class variables persist for the whole Cucumber run unless reset in a Before hook.
A new custom step that composes existing sentences
# features/step_definitions/auth.rb
Given(/^"([^"]*)" is logged in$/) do |name|
require File.join(ENV['CUCUMBER_ROOT'], 'support/helpers/user')
user = User.get(name) or raise "Unknown user: #{name}"
step %{I try visit the page "LoginPage"}
step %{I fill "email" field with "#{user['email']}"}
step %{I fill "password" field with "#{user['password']}"}
step %{I click on the button "log in"}
step %{I should be redirected on "HomePage"}
endComposition with step "..." is the right way to build domain-specific shortcuts — never copy-paste Watir code that the gem already encapsulates.
---
The full sentence catalogue (cheat sheet)
The gem registers exactly 39 step definitions. Full table with regex, retry semantics, and examples is in references/sentences-catalogue.md. The 39, grouped:
Navigation (6) — I am on the "<page>" · I try visit the page "<page>" · I am on the "<page>" of <type> "<id>" · I try visit the page "<page>" of <type> "<id>" · I should be redirected on "<page>" · I change the domain to "<name>"
Buttons (3) — I click on the button "<name>" · I should not see the button "<name>" · I should see the (button|field|element) "<name>"(\| disabled)
Fill (8) — I fill "<f>" field with "<v>" · I fill "<f>" autocomplete with "<v>" · I fill "<f>" datepicker with "<v>" · I fill "<f>" ckeditor field with "<v>" · I fill "<f>" contenteditable with "<v>" · I fill "<f>" js field with "<v>" · I fill "<f>" field with date · I fill "<f>" field with time
Assertions on fields (6) — I should see field "<f>" filled "<v>" · I should see field "<f>" filled date · I should see field "<f>" filled time · I can see "<v>" in input "<f>" · I can see the value "<v>" selected in the select box "<f>" · I should not see the field "<f>"
Selects (1) — I click on the select box "<f>" to select "<v>"
Visibility & text (5) — I should not see the element "<n>" · I can see "<t>" in element "<n>"(\| exactly) · I can not see "<t>" in element "<n>" · I should see a message tell me "<t>" · I should not see a message tell me "<t>"
CKEditor assertion (1) — I can see "<v>" in ckeditor "<f>"
Browser misc (7) — I refresh the page · I wait <n> seconds · I make one pause · I scroll to "<n>" · I force scroll to "<n>" · I hover over the element "<n>" · I upload a file with the filename "<f>" in element "<dz>"
MailHog helpers (2) — I see the last email subject "<s>" · I open on the last email link
Total: 6 + 3 + 8 + 6 + 1 + 5 + 1 + 7 + 2 = 39.
Given / When / Then keywords at registration are stylistic only — Cucumber matches against the same pool regardless of keyword. Use whichever reads naturally in the feature.
---
Fakable inline substitution (optional)
Inside any string argument routed through Fakable.fake_if_needed, the token
@('Category', 'method', 'memoize_key')is replaced by the value of Faker::<Category>.<method>. Repeated memoize_key reuses the first value within the run — useful when two steps must refer to the same generated email or name.
Given I fill "email" field with "@('internet', 'email', 'main-user')"
And I fill "email confirm" field with "@('internet', 'email', 'main-user')"⚠️ Memoisation is class-wide (@@memorized_strings) and not reset between scenarios — choose distinct mem keys per scenario or accept the shared state. See references/sentences-catalogue.md for the precise list of phrases that route through Fakable.
---
Hidden contracts (the things that bite)
1. `ENV['CUCUMBER_ROOT']` must be set before `require "cucumber-sentences"`. Otherwise the dynamic helper loaders (I change the domain to, I am on the "..." of <type> "...", I upload a file ...) blow up on nil path joins. 2. Page name → snake_case transform is gsub(" ", "_") only — non-ASCII and dashes pass through. visit_page then expects a class named in PascalCase of the snake-case (e.g. "OrderEditPage" → file/class OrderEditPage). 3. *String capture is `"([^"])"** — only double quotes, no escapes. Embedded " in test data is impossible without forking the gem. 4. **I force scroll to "<name>" (built-in)** does getElementById(get_js_selector(name)) — your get_js_selector must return an *id*, not a CSS selector. Most projects override this phrase with a CSS/XPath-aware variant in their own step_definitions/. 5. **I open on the last email link** depends on jQuery being loaded on the current page (jQuery(srcdoc)…). Replace with a pure-JS extension if you can't guarantee jQuery. 6. **Strict URL match on redirection** — unless the page implements is_on_page(url), page.page_url_value must equal @browser.url exactly (no trailing-slash leniency, no query-string tolerance). 7. **Disabled-button assertion** calls enabled? on the element returned by get_button. If the visible "button" is a <div> wrapper around the real <button>, return the inner element from get_button. 8. **Retry budget** — most assertions retry **30 times × 1 s sleep**. I fill ... datepicker and I can see ... in element cap retries at **5** when the cause is Watir::Wait::TimeoutError (the visibility check already waited). Failures past the budget propagate normally. 9. **Faker memoisation persists across scenarios** in the same cucumber invocation — see §Fakable above. 10. **Watir / Selenium / Chrome alignment** — if the suite runs in Docker against selenium/standalone-chrome, bumping Chrome out of band against an unchanged watir / selenium-webdriver` will break headlessly. Pin both.
---
Verification checklist before declaring a step "done"
- [ ] The phrase is one of the 39 registered, OR a project-side step composed from them.
- [ ] Every name referenced in the feature exists in the page's
get_button/get_field/get_element_by_name(or the right resolver per the table above). - [ ] The locator (
text_field/button/div/etc.) is declared at the top of the page class. - [ ] If the URL is templated (
<%=params['…']%>), every placeholder is supplied either by$site_urlor by the typed helper's.get(identifier)Hash. - [ ] If
is_on_pageis overridden, it returns truthy for every URL the redirect should accept. - [ ] Run the scenario locally —
IS_DEV=1 bundle exec cucumber path/to.feature— and watch the actual browser; do not trust a green run-on-CI without eyes-on the first time.
---
References (load on demand)
references/sentences-catalogue.md— every registered sentence with its exact regex, parameters, retry semantics, and an example. Use when debugging an "undefined step" or when picking the closest match for a new feature line.references/page-class-contract.md— deeper dive into page-object's locator declarations, the synthesised<name>_elementaccessors, and which sentences call whichget_Xresolver.references/helpers-and-state.md— patterns forDomain(named base URLs),<type>helpers (typed object lookups), and session-scoped state holders.
Templates (copy from templates/)
step_definitions_imports.rb— the entry point that requires the gem.page.rb— full skeleton of a page class with allget_Xresolvers stubbed.helper_domain.rb—Domainhelper template (named base URLs).helper_typed.rb— typed helper template forof <type> "<id>"flows.state_holder.rb— session-scoped state holder template.
cucumber-sentences (Claude Code skill)
A Claude Code skill that documents the Ruby gem `cucumber-sentences` — the Gherkin DSL on top of `page-object` + `watir` used to write browser-driven Cucumber scenarios in plain English.
When this skill is loaded, Claude knows the full catalogue of 39 registered phrases, the page-class contract (get_field / get_button / get_element_by_name / get_ckeditor / get_js_selector), how to wire helpers (Domain, typed of <type> "<id>"), and how to share state between steps. It can read or write .feature files, page objects, and step definitions without re-deriving the conventions every time.
Install
Via the `skills` CLI (recommended)
npx skills add donkeycode/skills-cucumber-sentencesThe CLI drops the skill into .claude/skills/cucumber-sentences/ of the current project. Restart Claude Code (or run /skills to refresh) and the skill triggers automatically when you edit .feature files, page-object classes, or step definitions.
Manual install
# Project-scoped (committed with the project)
mkdir -p .claude/skills
git clone https://github.com/donkeycode/skills-cucumber-sentences.git .claude/skills/cucumber-sentences
# OR user-scoped (available in every project on this machine)
mkdir -p ~/.claude/skills
git clone https://github.com/donkeycode/skills-cucumber-sentences.git ~/.claude/skills/cucumber-sentencesVerify
After install you should have a SKILL.md reachable at one of:
<project>/.claude/skills/cucumber-sentences/SKILL.md~/.claude/skills/cucumber-sentences/SKILL.md
Inside Claude Code, asking "how do I add a button to a feature in this project" (when the project has Cucumber + page-object + cucumber-sentences) should now route through this skill.
When does it trigger?
Automatically, when:
- A
.featurefile is being created or edited. - A page-object class is being created or has its locators /
get_Xresolvers edited. - A
Cucumber::Undefinedor "undefined step" error is being investigated. - You want to add a kind of object resolvable by name (
I am on the "OrderEditPage" of order "ABC-123") — needs a typed helper. - You need to share state across steps in the same scenario (e.g. extract a link from an email, follow it later).
What's inside
SKILL.md Entry point — frontmatter, decision tree, mapping mechanics, fast templates
references/
sentences-catalogue.md All 39 registered phrases with regex, retry semantics, examples
page-class-contract.md Page-object contract, `is_on_page`, naming conventions, pitfalls
helpers-and-state.md Domain helper, typed `<type>` helpers, session-scoped state holders
templates/
step_definitions_imports.rb Entry point that requires the gem
page.rb Full page-class skeleton with all `get_X` resolvers stubbed
helper_domain.rb `Domain.get(name)` → base URL
helper_typed.rb Typed `Order.get(id)` → params hash
state_holder.rb `@@`-class-var holder with optional resetCompatibility
The skill targets:
cucumber-sentences~> 0.0.18(single-file gem, ~500 LOC; analysis based on commit9661eb1)- Ruby + Cucumber 10.x + page-object 2.x + watir 7.x + selenium-webdriver 4.x
- Chrome via
selenium/standalone-chromeDocker image, or local Chrome with chromedriver
If a future release of the gem adds or renames phrases, regenerate references/sentences-catalogue.md from the updated lib/cucumber-sentences.rb.
Source
- Upstream gem: <https://github.com/donkeycode/cucumber-sentences>
- This skill: <https://github.com/donkeycode/skills-cucumber-sentences>
License
MIT — same as the upstream gem. See LICENSE.
Contributing
Found an inaccuracy or want to add a missing pattern (a new helper recipe, a project-side step extension worth sharing, …) ? Open a PR.
When updating the sentence catalogue, always re-run grep -n -E '^(Given|When|Then)\(' lib/cucumber-sentences.rb against the upstream gem to confirm the count and regex of every phrase. The catalogue must stay in sync with the gem version it claims to document.
Helpers & state holders
Three of cucumber-sentences' phrases load Ruby files at runtime to extend the DSL: I change the domain to "..." loads support/helpers/domain.rb; I am on the "..." of <type> "..." and I try visit the page "..." of <type> "..." load support/helpers/<type>.rb. This document explains how to author each kind of helper and the related pattern of session-scoped state holders.
All helpers live underfeatures/support/helpers/. They are not loaded automatically — the gemrequires the specific file when the matching sentence runs.
---
1. The Domain helper (named base URLs)
Triggered by: Given I change the domain to "<name>".
Contract:
- File at
features/support/helpers/domain.rb. - Defines a
class Domain(top-level — the gem looks it up viaObject.const_get). - Exposes a single class method
Domain.get(name) → String. The string is assigned to$site_urland is then injected asparams['site_url']in every subsequentpage_urlERB template.
Template:
# features/support/helpers/domain.rb
class Domain
def self.get(name)
{
"frontend" => $front_url,
"api" => $api_url,
"mailhog" => $mailhog_url,
}[name]
end
endWiring (`features/support/env.rb`):
$front_url = ENV.fetch("FRONT_URL", "https://app.example.test/")
$api_url = ENV.fetch("API_URL", "https://api.example.test/")
$mailhog_url = ENV.fetch("MAILHOG_URL", "http://localhost:8025/")
$site_url = $front_url # default before any `I change the domain to`Rules of thumb:
- Keep the names short and descriptive:
frontend,api,mailhog,admin-portal. The phraseI change the domain to "X"should read like English. - Source URLs from environment variables so the same suite can run against local Docker, a CI ephemeral env, and a staging deploy.
Domain.getreturningnilraisesDomain X not found !; lookup is exact-match.
---
2. Typed helpers — the of <type> "<id>" pattern
Triggered by:
Given I am on the "<page>" of <type> "<identifier>"Given I try visit the page "<page>" of <type> "<identifier>"
Contract:
- File at
features/support/helpers/<type>.rb(snake-case file name; the type capture from the regex is a free-form word). - Defines a class whose name is the PascalCase of `<type>` (the gem does
type.sub(/^(\w)/) { |s| s.capitalize }thenObject.const_get). - Exposes
self.get(identifier) → Hash. The hash is merged with `{ 'site_url' => $site_url }` and passed as:using_paramstovisit_page.
Effect: Every key in the returned Hash becomes a params['…'] placeholder available in the page's page_url ERB template.
2.1 Static lookup table
When the test corpus knows the identifiers in advance (typically true for fixture-based suites):
# features/support/helpers/order.rb
class Order
def self.get(identifier)
{
"alpha" => { "id" => "01HXXXXXXXXALPHA", "tab" => "summary" },
"beta" => { "id" => "01HXXXXXXXXBETA", "tab" => "items" },
}[identifier]
end
endGiven I am on the "OrderEditPage" of order "alpha"The matching page:
class OrderEditPage
include PageObject
page_url "<%=params['site_url']%>orders/<%=params['id']%>?tab=<%=params['tab']%>"
end2.2 DB-backed lookup
When identifiers are dynamic (created inline by another step, or sourced from a fixture loader):
# features/support/helpers/customer.rb
require File.join(File.absolute_path('../../', File.dirname(__FILE__)), 'support/helpers/db')
class Customer
def self.get(identifier)
row = Db.execute("SELECT id, slug FROM customers WHERE label = ? LIMIT 1", [identifier]).first
return nil unless row
{ "id" => row['id'], "slug" => row['slug'] }
end
endThe above presupposes a support/helpers/db.rb like the MySQL helper many projects ship.
2.3 Choosing identifiers
The user types the identifier in plain text inside the feature; pick something stable:
- A role-based label (
"primary admin","second user") when the corpus is small. - A business id (
"ORD-2024-001") when the suite mirrors real data. - A memo key consumable by
Fakable("@('internet','email','user-1')") when each scenario should generate a fresh value.
The I am on the "..." of <type> "..." variant does Fakable-substitute the identifier; I try visit the page "..." of <type> "..." does not (a small inconsistency in the gem).
2.4 Multiple typed helpers in one project
User, Customer, Order, Project, Mission, Document — each is a separate file under support/helpers/. There is no register; the gem resolves them by file name when the matching sentence runs. Keep the files independent of one another.
---
3. Session-scoped state holders
Not triggered by any built-in sentence. State holders are a project pattern you implement yourself in custom step definitions, used to share data across steps within a scenario (or across scenarios — see caveats).
Use cases:
- Stash a link extracted from an email and follow it in a later step.
- Save the id of a record created in step N, retrieve it in step M.
- Persist a verification code grabbed from the UI for use in a subsequent form.
Template:
# features/support/helpers/extracted_link.rb
class ExtractedLink
@@value = nil
def self.set(v); @@value = v; end
def self.get; @@value; end
endUsed from a custom step (in `step_definitions/`):
require File.join(ENV['CUCUMBER_ROOT'], 'support/helpers/extracted_link')
When(/^I save the link from the email body$/) do
href = @browser.execute_script(<<~JS)
var iframe = document.querySelector('iframe');
return iframe.contentDocument.querySelector('a').href;
JS
ExtractedLink.set(href)
end
When(/^I follow the saved link$/) do
@browser.goto ExtractedLink.get
endCaveats:
@@-class variables persist for the entire Cucumber run, across scenarios. Reset them explicitly in aBeforehook if the value should not leak:
Before { ExtractedLink.set(nil) }- Keep one fact per holder. A "Mission" holder that stores
uid,url,state,last_actionbecomes a god object; preferMissionId,MissionUrlseparately, or — better — encode the state in the URL so@browser.urlis the source of truth. - Holders are an escape hatch for things you can't model on the page. If a value is visible in the DOM, prefer asserting it directly with
I can see "..." in element "..."; only stash when the value is ephemeral (one-time tokens, mailbox links).
---
4. Calling helpers from custom steps
Helpers are auto-loaded only by the three built-in sentences listed above. To use them in your own step definitions, require them explicitly:
require File.join(ENV['CUCUMBER_ROOT'], 'support/helpers/user')
Given(/^"([^"]*)" is logged in$/) do |name|
user = User.get(name) or raise "Unknown user: #{name}"
step %{I try visit the page "LoginPage" of domain "frontend"} unless name == "anonymous"
step %{I fill "email" field with "#{user['email']}"}
step %{I fill "password" field with "#{user['password']}"}
step %{I click on the button "log in"}
step %{I should be redirected on "HomePage"}
endstep %{...} is Cucumber's mechanism for calling another step from within a step body — the way to compose existing sentences into project-specific shortcuts. Always prefer this over re-implementing Watir/page-object code that the gem already encapsulates.
---
5. A complete worked example
Goal: a feature that logs in a known user, navigates to a known order, and follows a link extracted from an email.
support/env.rb
require 'page-object'
$front_url = ENV.fetch("FRONT_URL", "https://app.example.test/")
$api_url = ENV.fetch("API_URL", "https://api.example.test/")
$mailhog_url = ENV.fetch("MAILHOG_URL", "http://localhost:8025/")
$site_url = $front_url
World PageObject::PageFactorysupport/helpers/domain.rb
class Domain
def self.get(name)
{ "frontend" => $front_url, "api" => $api_url, "mailhog" => $mailhog_url }[name]
end
endsupport/helpers/user.rb
class User
def self.get(name)
{
"primary admin" => { "email" => "admin@example.com", "password" => "secret" },
"first manager" => { "email" => "manager@example.com", "password" => "secret" },
"first viewer" => { "email" => "viewer@example.com", "password" => "secret" },
}[name]
end
endsupport/helpers/order.rb
class Order
def self.get(identifier)
{ "alpha" => { "id" => "ord_alpha" }, "beta" => { "id" => "ord_beta" } }[identifier]
end
endsupport/helpers/extracted_link.rb
class ExtractedLink
@@value = nil
def self.set(v); @@value = v; end
def self.get; @@value; end
endstep_definitions/auth.rb
ENV['CUCUMBER_ROOT'] = File.absolute_path('../', File.dirname(__FILE__))
require "cucumber-sentences"
require File.join(ENV['CUCUMBER_ROOT'], 'support/helpers/user')
Given(/^"([^"]*)" is logged in$/) do |name|
user = User.get(name) or raise "Unknown user: #{name}"
step %{I try visit the page "LoginPage"}
step %{I fill "email" field with "#{user['email']}"}
step %{I fill "password" field with "#{user['password']}"}
step %{I click on the button "log in"}
step %{I should be redirected on "HomePage"}
endstep_definitions/email.rb
require File.join(ENV['CUCUMBER_ROOT'], 'support/helpers/extracted_link')
When(/^I save the link from the email body$/) do
href = @browser.execute_script("return document.querySelector('iframe').contentDocument.querySelector('a').href")
ExtractedLink.set(href)
end
When(/^I follow the saved link$/) do
@browser.goto ExtractedLink.get
endFeature
Feature: Order link in invitation email
Scenario: An invited user lands on the right order page
Given "primary admin" is logged in
And I am on the "OrderEditPage" of order "alpha"
And I click on the button "send invitation"
Given I change the domain to "mailhog"
And I am on the "MailhogPage"
Then I see the last email subject "You have been invited"
When I open on the last email link
And I save the link from the email body
Given I change the domain to "frontend"
When I follow the saved link
Then I should be redirected on "OrderEditPage"Every step in this feature is either one of the 39 built-in sentences (I am on the "...", I click on the button "...", I change the domain to "...", I see the last email subject "...", I open on the last email link, I should be redirected on "...") or a thin project-side composition ("X" is logged in, I save the link from the email body, I follow the saved link). No raw Watir is ever written in a feature.
---
6. Checklist when introducing a helper
- [ ] File path matches the convention:
features/support/helpers/<lowercase>.rb. - [ ] Top-level class name matches
<lowercase>.capitalize(e.g.customer.rb→Customer,payment_method.rb→Payment_method— prefer single-word file/class names to avoid this gotcha). - [ ]
self.get(identifier)returns eithernil(not found — gem raises) or aHash(typed helpers) /String(Domain helper). - [ ] All keys in the returned Hash are required by the page's
page_urlERB placeholders (no orphan params, no missing ones). - [ ] When the helper hits a database, it tolerates
nilrows and returnsnil— the gem's "not found !" error is more informative than aNoMethodError on nil. - [ ] When the helper holds shared state (
@@-vars), aBeforehook resets it if leakage between scenarios would cause flakiness.
Page class contract
Pages are the only files most contributors will touch. This document explains everything that the gem expects from a page class, how page-object synthesises the <name>_element accessors that the resolver hashes return, and how to keep page classes maintainable as the suite grows.
---
1. Anatomy of a page class
class OrderEditPage
include PageObject # ① mixin
page_url "<%=params['site_url']%>orders/<%=params['id']%>" # ② ERB template
text_field(:reference, :id => "reference") # ③ locator declaration → synthesises `reference_element`, `reference`, `reference=`, …
text_area(:description, :id => "description")
button(:save, :xpath => "//button[contains(., 'Save')]")
div(:formError, :xpath => "//global-error//div")
def is_on_page(url) # ④ optional regex/lax URL check
!!(url =~ %r{^#{Regexp.escape($site_url)}orders/\w+})
end
def get_field(name) # ⑤ name → element accessor
{
"reference" => reference_element,
"description" => description_element,
}[name]
end
def get_button(name)
{ "save" => save_element }[name]
end
def get_element_by_name(name)
{ "form error" => formError_element }[name]
end
# Optional, only if your features call sentences that need them:
def get_ckeditor(name) { { "description rich" => "description" }[name] }
def get_js_selector(name) { { "color picker" => "#color" }[name] }
endThe numbered comments are explained in the next sections.
---
2. include PageObject — what it gives you
The page-object gem provides:
- DSL methods for declaring locators:
text_field,text_area,button,link,div,span,select_list,checkbox,radio,image,element(generic), … - A PageFactory module for navigating:
visit_page <Class>, :using_params => h,on_page <Class>, :using_params => h do |p| … end. The host project enables this withWorld PageObject::PageFactoryinsupport/env.rb. - The `page_url` macro that templates a URL with ERB, rendering
params['…']placeholders at navigation time. - The `page_url_value` instance method on every page (used by
I should be redirected on) which returns the rendered URL string. - An optional `is_on_page(url)` instance method override (see §6).
When you write text_field(:email, :id => "email") page-object generates six methods on the class:
| Method | Returns | Used by |
|---|---|---|
email_element | Watir::TextField (the live element) | Your get_field hash |
email | The element's value (calls .value) | Rare in step bodies |
email= | Assigns the value (calls .value =) | Rare in step bodies |
email? | true if visible | Rare |
wait_for_email | Wait helper | Rare |
email_element (alias) | — | — |
*The gem only ever uses the `_element accessor.** Your get_field` hash maps a string name to that accessor.
---
3. The five resolver methods
Cucumber-sentences calls only these methods on @current_page. Implementing each is opt-in: only define the resolvers your features actually use.
3.1 get_field(name) → Watir::Element | nil
Required when the feature uses any of: I fill "X" field|autocomplete|datepicker|contenteditable with "...", I should see field "X" filled "...", I should see field "X" filled date|time, I can see "..." in input "X", I can see the value "..." selected in the select box "X", I should not see the field "X", I should see the field "X", I click on the select box "X" to select "...", I upload a file with the filename "..." in element "X".
Note (autocomplete): the sentence I fill "X" autocomplete with "..." makes a second call get_field("selected_autocomplete") on the same page after a 2 s sleep. Your hash must therefore include the selected_autocomplete key whenever you use the autocomplete sentence.
text_field(:autocompleteCity, :id => "city")
button(:autocompleteOption, :xpath => "//ul[contains(@class, 'suggestions')]/li[1]")
def get_field(name)
{
"city" => autocompleteCity_element,
"selected_autocomplete" => autocompleteOption_element, # ← required by I fill ... autocomplete with
}[name]
end3.2 get_button(name) → Watir::Element | nil
Required when the feature uses: I click on the button "X", I should not see the button "X", I should see the button "X"(\| disabled).
The gem checks disabled? (for the disabled variant) and enabled? (when waiting to click) — return the actual <button>, not a wrapping <div> or <a> whose disabled state may not match.
3.3 get_element_by_name(name) → Watir::Element | nil
Required when the feature uses: I should not see the element "X", I can see "..." in element "X"(\| exactly), I can not see "..." in element "X", I scroll to "X", I hover over the element "X", I should see the element "X"(\| disabled).
This is the most generic resolver — anything that has a name and is visually checked, scrolled to, or hovered.
3.4 get_ckeditor(name) → String
Required when the feature uses: I fill "X" ckeditor field with "...", I can see "..." in ckeditor "X".
Returns the CKEditor instance id (a plain string), used inside CKEDITOR.instances.<id>.setData(...). Not a Watir element.
def get_ckeditor(name)
{
"description rich" => "description", # the id you passed to CKEDITOR.replace(...)
}[name]
end3.5 get_js_selector(name) → String
Required when the feature uses: I fill "X" js field with "...", I force scroll to "X".
For js field: returns a CSS selector (used in document.querySelector(sel)). For force scroll: returns a bare id (used in document.getElementById(sel)) — note the inconsistency. Most projects override force scroll with a CSS/XPath-aware variant in their own step definitions.
def get_js_selector(name)
{
"color picker" => "#color", # ok for both querySelector and getElementById
"hidden field" => "[name='token']", # ok for js field; NOT for force scroll
}[name]
end---
4. Naming the keys in resolver hashes
The string a feature writer types — "save profile", "email modal", "first suggestion contact" — is the public name of an element. Conventions that work in practice:
- Use the human label when there is one:
"save","cancel","log in". Drop punctuation ("…","!"). - Disambiguate by context when multiple buttons share a label:
"save profile","save settings". Order matters less than uniqueness. - Use kebab- or sentence-case consistently — pick one per project. The gem doesn't care; readers do.
- Stable phrases survive product rewording — name what the element does, not what its current label says.
"submit form"is more durable than"Validate". - Avoid leaking implementation —
"button.btn-primary"is a bad name;"primary action"is fine.
Per-element naming, not per-feature
A given element has one canonical name in get_button / get_field / get_element_by_name. Re-use it across features. If two features need the same element under different names, add both keys pointing to the same *_element — but that's almost always a smell.
---
5. ERB templating in page_url
page-object runs the page_url "..." string through ERB at navigation time, with the params hash from :using_params. Anything inside <%= %> is Ruby.
page_url "<%=params['site_url']%>orders/<%=params['id']%>?tab=<%=params['tab'] || 'summary'%>"Where the params come from:
params['site_url']— always supplied by the gem ($site_urlat the time of the step).- All other keys — must be provided by the typed helper's
Klass.get(identifier)Hash. Page navigation will fail with a NameError if a placeholder is missing.
If the URL has multiple required path segments and you don't want a typed helper, fall back to I am on the "<page>" with no helper and add an is_on_page matcher (next section) that tolerates the URL shape.
---
6. is_on_page(url) — overriding strict redirect equality
By default, Then I should be redirected on "<page>" asserts that @browser.url equals page.page_url_value exactly. This is brittle for pages with:
- An id in the URL that the test does not know in advance (
/orders/<random uuid>), - A query string the app appends (
?ref=…), - Trailing-slash inconsistencies between dev and prod.
Define is_on_page(url) to make the assertion lax:
class OrderEditPage
include PageObject
page_url "<%=params['site_url']%>orders/<%=params['id']%>" # used only when params['id'] is provided
def is_on_page(url)
!!(url =~ %r{^#{Regexp.escape($site_url)}orders/[^/]+/?$})
end
endWhen is_on_page exists the gem calls it instead of comparing page_url_value. Returning true accepts the URL; returning false/nil triggers the retry loop.
---
7. Keeping page classes maintainable
As pages grow, the resolver hashes become long. Patterns that scale:
- Group declarations + hash entries by section of the page (header, body, modal, …) and keep the order consistent.
text_field(...)blocks at the top,def get_fieldmirroring the same order at the bottom. - One page per file, file path mirrors the class:
features/pages/order/edit.rb→class OrderEditPage. - Sub-pages for modals — when a modal has its own elements, define
class OrderEditPage::ConfirmModal(or a separateOrderConfirmModal) and useThen I should be redirected on "OrderConfirmModal"only if the modal also changes the URL. Otherwise expose the modal's elements through the parent page'sget_element_by_name. - Avoid logic in resolvers — they should be pure Hash lookups. Put any computation behind a method (
def first_suggestion_for(autocomplete_id) … end) and reference it from a project-level step, not from a generic resolver. - Watch hash size — past ~50 entries, split the page or extract a sub-page. Hashes are linear-scan on every step; more importantly, a 200-entry resolver is unreadable.
---
8. Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
Button "save" not found exception thrown by I click on the button "save" | Either the page has no get_button, or get_button("save") returns nil. | Add the key (and the locator) — or check the casing; lookup is exact-match. |
Watir::Exception::UnknownObjectException retrying for 30 s | The XPath/CSS in the locator declaration matches no DOM node. | Open devtools, paste the selector in $x("…") / document.querySelectorAll("…"). |
I should be redirected on "OrderEditPage" fails although the browser is on the right URL | Strict equality with page_url_value and the URL has a query string / trailing slash mismatch / dynamic id. | Override is_on_page(url) to match the actual URL pattern. |
I fill "X" autocomplete with "..." finds the field, types, then errors | Page does not expose the selected_autocomplete key. | Add it: a button/option element representing the chosen suggestion. |
I fill "X" ckeditor field with "..." succeeds but the editor stays empty | get_ckeditor("X") returned the wrong instance id, or CKEditor is not loaded yet. | Verify in console: Object.keys(CKEDITOR.instances). Wait for the editor with an explicit I wait <n> seconds if needed. |
| Disabled-button check passes when the button looks disabled | The visible "button" is a <div> overlay; enabled? is read from the inner <button>. Or: page-object's enabled? differs from CSS disabling (CSS pointer-events: none does not affect enabled?). | Return the inner <button> from get_button; assert via attribute('aria-disabled') in a custom step if the app uses ARIA-only disabling. |
Sentences catalogue — all 39 phrases
The complete catalogue of step definitions registered by cucumber-sentences 0.0.18. Sourced directly from lib/cucumber-sentences.rb. Line numbers refer to that single file.
Conventions: parameters are captured by"([^"]*)"so embedded"is impossible.Given/When/Thenkeywords at registration are stylistic — Cucumber matches against the same step pool regardless of which keyword you use in the feature. Fakable in the table below means the value flows throughFakable.fake_if_needed(seeSKILL.md§Fakable inline substitution).
---
1. Navigation (6)
1.1 Given I am on the "<page>"
| Regex | /^I am on the "([^"]*)"$/ |
| Source | line 1 |
| Behaviour | visit_page page.gsub(" ", "_"), :using_params => { 'site_url' => $site_url }, stores result in @current_page, then asserts I should be redirected on "<page>". |
| Fakable | No |
| Example | Given I am on the "LoginPage" |
1.2 Given I try visit the page "<page>"
| Regex | /^I try visit the page "([^"]*)"$/ |
| Source | line 65 |
| Behaviour | Same as 1.1 but does not assert redirection (lets the test inspect e.g. an unauthenticated bounce). |
| Fakable | No |
| Example | Given I try visit the page "AdminPage" |
1.3 Given I am on the "<page>" of <type> "<id>"
| Regex | /^I am on the "([^"]*)" of (.+) "([^"]*)"$/ |
| Source | line 23 |
| Behaviour | Loads support/helpers/<type>.rb, capitalises <type> to a class name (order → Order), calls Klass.get(Fakable.fake_if_needed(id)) to obtain a Hash of params, merges site_url, then visit_page + redirect assertion. |
| Fakable | Yes (on the identifier) |
| Example | Given I am on the "OrderEditPage" of order "ABC-123" |
1.4 Given I try visit the page "<page>" of <type> "<id>"
| Regex | /^I try visit the page "([^"]*)" of ([^"]*) "([^"]*)"$/ |
| Source | line 197 |
| Behaviour | Same lookup as 1.3, no redirect assertion. Note: identifier is not Fakable in this variant (uses clazz.get(identifier) directly). |
| Fakable | No |
| Example | Given I try visit the page "OrderEditPage" of order "ABC-123" |
1.5 Then I should be redirected on "<page>"
| Regex | /^I should be redirected on "([^"]*)"$/ |
| Source | line 72 |
| Behaviour | Loads the page object via on_page page.gsub(" ", "_"). If the page defines is_on_page(url), asserts page.is_on_page(@browser.url) == true. Otherwise asserts strict @browser.url == page.page_url_value. |
| Retry | 30 × 1 s on RSpec::Expectations::ExpectationNotMetError |
| Fakable | No |
| Example | Then I should be redirected on "HomePage" |
1.6 Given I change the domain to "<name>"
| Regex | /^I change the domain to "([^"]*)"$/ |
| Source | line 12 |
| Behaviour | Requires support/helpers/domain.rb, calls Domain.get(name), assigns the result to $site_url. Throws "Domain <name> not found !" on nil. |
| Fakable | No |
| Example | Given I change the domain to "frontend" |
---
2. Buttons (3)
2.1 When I click on the button "<name>"
| Regex | /^I click on the button "([^"]*)"$/ |
| Source | line 185 |
| Behaviour | Throws "Button <name> not found" if @current_page.get_button(name) returns nil. Otherwise: when_visible, then wait_until { not disabled? }, then click(). |
| Fakable | No |
| Example | When I click on the button "save profile" |
2.2 Then I should not see the button "<name>"
| Regex | /^I should not see the button "([^"]*)"$/ |
| Source | line 100 |
| Behaviour | get_button(name).when_not_present(). |
| Fakable | No |
| Example | Then I should not see the button "delete account" |
2.3 Then I should see the (button|field|element) "<name>"(\| disabled)
| Regex | `/^I should see the (button\ |
| Source | line 259 |
| Behaviour | One sentence, three behaviours dispatched on the element type capture: button → get_button(name).visible? (or when_visible.enabled? == false if disabled); field → get_field(name).visible?; element → get_element_by_name(name).visible?. |
| Retry | 30 × 1 s on RSpec::Expectations::ExpectationNotMetError |
| Fakable | No |
| Example | Then I should see the button "save", Then I should see the field "email" disabled, Then I should see the element "alert" |
---
3. Fill (8)
3.1 Given I fill "<field>" field with "<value>"
| Regex | /^I fill "([^"]*)" field with "([^"]*)"$/ |
| Source | line 40 |
| Behaviour | get_field(field).when_visible().value = Fakable.fake_if_needed(value). |
| Fakable | Yes (on value) |
| Example | Given I fill "email" field with "user@example.com" |
3.2 Given I fill "<field>" autocomplete with "<value>"
| Regex | /^I fill "([^"]*)" autocomplete with "([^"]*)"$/ |
| Source | line 129 |
| Behaviour | Sets the value (Fakable), sleep 2, then clicks the element on the same page named `selected_autocomplete` (resolved through get_field("selected_autocomplete")). The page must therefore expose that name in its get_field hash. |
| Fakable | Yes |
| Example | Given I fill "city" autocomplete with "Paris" |
3.3 Given I fill "<field>" datepicker with "<value>"
| Regex | /^I fill "([^"]*)" datepicker with "([^"]*)"$/ |
| Source | line 155 |
| Behaviour | Sets the value (Fakable). After success, runs JS to remove .show-calendar overlays (element.removeAttribute("style"); element.classList.remove("show-calendar")). |
| Retry | 5 × on Watir::Wait::TimeoutError, 30 × 1 s on any other Exception (logs class + message via puts) |
| Fakable | Yes |
| Example | Given I fill "start date" datepicker with "01/06/2024" |
3.4 Given I fill "<field>" ckeditor field with "<value>"
| Regex | /^I fill "([^"]*)" ckeditor field with "([^"]*)"$/ |
| Source | line 288 |
| Behaviour | Runs CKEDITOR.instances.<get_ckeditor(field)>.setData("<value>") via JS, then .fire("change"), then asserts via the next sentence (I can see "<value>" in ckeditor "<field>"). The page must expose get_ckeditor(name) → "<editor-id>". |
| Retry | 30 × 1 s on RSpec::Expectations::ExpectationNotMetError |
| Fakable | No (value is interpolated raw into JS — beware of " in the value) |
| Example | Given I fill "description" ckeditor field with "Hello world" |
3.5 Given I fill "<field>" contenteditable with "<value>"
| Regex | /^I fill "([^"]*)" contenteditable with "([^"]*)"$/ |
| Source | line 408 |
| Behaviour | get_field(field).when_visible().send_keys(Fakable.fake_if_needed(value)). Use for elements that don't support value = assignment. |
| Fakable | Yes |
| Example | Given I fill "comment" contenteditable with "All good" |
3.6 Given I fill "<field>" js field with "<value>"
| Regex | /^I fill "([^"]*)" js field with "([^"]*)"$/ |
| Source | line 412 |
| Behaviour | Resolves a CSS selector via get_js_selector(field), then runs document.querySelector(sel).value = <value>; sel.dispatchEvent(new Event('change')) via JS. The page must expose get_js_selector(name) → "<css selector>". Useful for hidden / native controls Watir cannot reach. |
| Fakable | Yes |
| Example | Given I fill "color picker" js field with "#ff0000" |
3.7 Given I fill "<field>" field with date
| Regex | /^I fill "([^"]*)" field with date$/ |
| Source | line 437 |
| Behaviour | Sets field.value to today's date — dateCurrent.strftime("%Y-%m-%d"). dateCurrent = Time.new is captured once at gem load time (line 429), so within a long Cucumber run the "today" is the day the gem was loaded. |
| Fakable | N/A |
| Example | Given I fill "report date" field with date |
3.8 Given I fill "<field>" field with time
| Regex | /^I fill "([^"]*)" field with time$/ |
| Source | line 457 |
| Behaviour | Same as 3.7 but dateCurrent.strftime("%H:%M"). |
| Fakable | N/A |
| Example | Given I fill "start time" field with time |
---
4. Field assertions (6)
4.1 Then I should see field "<field>" filled "<value>"
| Regex | /^I should see field "([^"]*)" filled "([^"]*)"$/ |
| Source | line 45 |
| Behaviour | field.value includes Fakable.fake_if_needed(value). |
| Retry | 30 × 1 s on RSpec::Expectations::ExpectationNotMetError |
| Fakable | Yes |
4.2 Then I should see field "<field>" filled date
| Regex | /^I should see field "([^"]*)" filled date$/ |
| Source | line 441 |
| Behaviour | field.value == dateCurrent.strftime("%Y-%m-%d"). Equality, not includes. |
| Retry | 30 × 1 s |
4.3 Then I should see field "<field>" filled time
| Regex | /^I should see field "([^"]*)" filled time$/ |
| Source | line 461 |
| Behaviour | field.value == dateCurrent.strftime("%H:%M"). |
| Retry | 30 × 1 s |
4.4 Then I can see "<value>" in input "<input>"
| Regex | /^I can see "([^"]*)" in input "([^"]*)"$/ |
| Source | line 249 |
| Behaviour | field.value includes Fakable.fake_if_needed(value). No retry. |
| Fakable | Yes |
4.5 Given I can see the value "<value>" selected in the select box "<field>"
| Regex | /^I can see the value "([^"]*)" selected in the select box "([^"]*)"$/ |
| Source | line 137 |
| Behaviour | field.selected_options() includes Fakable.fake_if_needed(value). |
| Retry | 30 × 1 s on Watir::Exception::NoValueFoundException |
| Fakable | Yes |
4.6 Then I should not see the field "<name>"
| Regex | /^I should not see the field "([^"]*)"$/ |
| Source | line 108 |
| Behaviour | get_field(name).when_not_present(). |
---
5. Selects (1)
5.1 Given I click on the select box "<field>" to select "<value>"
| Regex | /^I click on the select box "([^"]*)" to select "([^"]*)"$/ |
| Source | line 61 |
| Behaviour | field.when_visible().select(Fakable.fake_if_needed(value)). |
| Fakable | Yes |
| Example | Given I click on the select box "country" to select "France" |
---
6. Visibility & text (5)
6.1 Then I should not see the element "<name>"
| Regex | /^I should not see the element "([^"]*)"$/ |
| Source | line 104 |
| Behaviour | get_element_by_name(name).when_not_present(). |
6.2 Then I can see "<text>" in element "<name>"(\| exactly)
| Regex | `/^I can see "([^"])" in element "([^"])"(\ |
| Source | line 212 |
| Behaviour | If trailing exactly: asserts element.text == Fakable.fake_if_needed(text) first; then asserts case-insensitive element.text.downcase.include?(faked.downcase). The case-insensitive include is checked even when ` exactly` is present, because the equality check is the first of two expects. |
| Retry | 30 × 1 s on expectation failure; 5 × on Watir::Wait::TimeoutError; 30 × on any other Exception (logs class + message) |
| Fakable | Yes |
| Example | Then I can see "Welcome" in element "page title", Then I can see "Welcome, John" in element "page title" exactly |
6.3 Then I can not see "<text>" in element "<name>"
| Regex | /^I can not see "([^"]*)" in element "([^"]*)"$/ |
| Source | line 353 |
| Behaviour | element.when_visible().text does not include text. |
| Retry | 30 × 1 s on expectation failure |
| Fakable | No (text is used raw — careful when negating Fakable strings elsewhere in the same scenario) |
6.4 Then I should see a message tell me "<text>"
| Regex | /^I should see a message tell me "([^"]*)"$/ |
| Source | line 112 |
| Behaviour | @current_page.text.include?(Fakable.fake_if_needed(text)) — searches the whole page text. |
| Retry | 30 × 1 s |
| Fakable | Yes |
6.5 Then I should not see a message tell me "<text>"
| Regex | /^I should not see a message tell me "([^"]*)"$/ |
| Source | line 253 |
| Behaviour | sleep 2 then asserts @current_page.text does not include Fakable.fake_if_needed(text). |
| Fakable | Yes |
---
7. CKEditor assertion (1)
7.1 Given I can see "<value>" in ckeditor "<field>"
| Regex | /^I can see "([^"]*)" in ckeditor "([^"]*)"$/ |
| Source | line 311 |
| Behaviour | Runs JS to read the CKEditor content: if CKEDITOR.instances.<id>.document is unset, returns getData(); otherwise returns document.getBody().getText(). Asserts include?(value). |
| Retry | 30 × 1 s on expectation failure |
| Fakable | No |
| Notes | Triggered automatically by sentence 3.4 (I fill "..." ckeditor field with "..."). Can also be used standalone for read-only CKEditor assertions. |
---
8. Browser misc (7)
8.1 Given I refresh the page
| Regex | /^I refresh the page$/ |
| Source | line 341 |
| Behaviour | @browser.driver.navigate.refresh. |
8.2 Given I wait <n> seconds
| Regex | /^I wait ([^"]*) seconds$/ |
| Source | line 345 |
| Behaviour | sleep n.to_i. Note: <n> is captured by [^"]* not \d+, so non-numeric input becomes 0 silently. |
8.3 Given I make one pause
| Regex | /^I make one pause$/ |
| Source | line 404 |
| Behaviour | sleep 5. |
8.4 When I scroll to "<name>"
| Regex | /^I scroll to "([^"]*)"$/ |
| Source | line 349 |
| Behaviour | get_element_by_name(name).when_visible.scroll_into_view. |
8.5 When I force scroll to "<name>"
| Regex | /^I force scroll to "([^"]*)"$/ |
| Source | line 431 |
| Behaviour | Reads get_js_selector(name), then runs document.getElementById(selector).scrollIntoView(). ⚠️ Uses getElementById, so the selector must be a bare id, not a CSS selector. |
8.6 When I hover over the element "<name>"
| Regex | /^I hover over the element "([^"]*)"$/ |
| Source | line 386 |
| Behaviour | @browser.driver.action.move_to(get_element_by_name(name).wd).perform. |
| Retry | 30 × 1 s on any exception |
8.7 When I upload a file with the filename "<filename>" in element "<dropzone>"
| Regex | /^I upload a file with the filename "([^"]*)" in element "([^"]*)"$/ |
| Source | line 369 |
| Behaviour | get_field(dropzone).set(File.join(ENV['CUCUMBER_ROOT'], 'support/files/' + filename)). Test files therefore live in features/support/files/. |
| Retry | 30 × 1 s on Watir::Exception::UnknownObjectException |
| Note | Despite the phrase saying "in element", the gem looks the dropzone up via get_field (file inputs are fields in Watir). |
---
9. MailHog helpers (2)
9.1 Then I see the last email subject "<subject>"
| Regex | /^I see the last email subject "([^"]*)"$/ |
| Source | line 419 |
| Behaviour | Delegates to I can see "<subject>" in element "last-message-subject". The page (typically MailhogPage) must expose last-message-subject in get_element_by_name. |
9.2 When I open on the last email link
| Regex | /^I open on the last email link$/ |
| Source | line 423 |
| Behaviour | Clicks .msglist-message .subject, sleeps 1 s, then sets document.location.href = jQuery(srcdoc).find('a').attr('href') via JS. |
| Caveat | Depends on jQuery being loaded on the current page. Most projects replace this with a pure-JS extension that targets the iframe rendered by MailHog. |
---
Cross-reference: which sentences call which page resolver
Resolver method on @current_page | Sentences that call it |
|---|---|
get_button(name) | 2.1, 2.2, 2.3 (when type=button) |
get_field(name) | 3.1, 3.2 (twice — also looks up selected_autocomplete), 3.3, 3.5, 3.7, 3.8, 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 5.1, 8.7, 2.3 (when type=field) |
get_element_by_name(name) | 6.1, 6.2, 6.3, 8.4, 8.6, 2.3 (when type=element) |
get_ckeditor(name) | 3.4, 7.1 |
get_js_selector(name) | 3.6, 8.5 |
(page-wide text) | 6.4, 6.5 |
---
Cross-reference: Fakable substitution applies in
3.1, 3.2, 3.3, 3.5, 3.6, 4.1, 4.4, 4.5, 5.1, 6.2, 6.4, 6.5, 1.3 (on identifier).
Substitution does not apply in 1.4, 6.3, 7.1 (CKEditor), or any sentence with no string parameter.
# Place at: features/support/helpers/domain.rb
# Auto-loaded by `Given I change the domain to "<name>"`.
# `Domain.get(name)` must return the base URL to assign to $site_url.
class Domain
def self.get(name)
{
"frontend" => $front_url,
"api" => $api_url,
"mailhog" => $mailhog_url,
# Add as many entries as your suite needs. Choose names that read
# naturally inside `I change the domain to "..."`.
}[name]
end
end
# Place at: features/support/helpers/<type>.rb
# Rename the file AND the class. The gem capitalises the type word from the
# Gherkin phrase to find the class — so `support/helpers/order.rb` must define
# `class Order`.
#
# Auto-loaded by:
# - `Given I am on the "<page>" of <type> "<id>"`
# - `Given I try visit the page "<page>" of <type> "<id>"`
#
# `Klass.get(identifier)` must return a Hash whose keys cover every
# `params['…']` placeholder in the target page's `page_url` ERB template
# (except `site_url`, which the gem injects automatically).
class Order
def self.get(identifier)
# Static lookup — replace with a DB call if your fixtures are dynamic.
{
"alpha" => { "id" => "ord_alpha", "tab" => "summary" },
"beta" => { "id" => "ord_beta", "tab" => "items" },
}[identifier]
end
end
# Place at: features/pages/<area>/<page>.rb
# Rename the class to match the file (PascalCase). The class name is what feature
# writers type in `Given I am on the "<ClassName>"`.
class ExamplePage
include PageObject
# ── URL template ──────────────────────────────────────────────────────────
# Renders with ERB on every navigation. `params['site_url']` is always
# supplied by the gem; any other placeholder must come from a typed helper's
# `Klass.get(identifier)` Hash.
page_url "<%=params['site_url']%>example/<%=params['id']%>"
# ── Optional: lax redirect matching ───────────────────────────────────────
# When `is_on_page` is defined, the sentence `I should be redirected on
# "ExamplePage"` calls it instead of comparing strict URL equality.
# def is_on_page(url)
# !!(url =~ %r{^#{Regexp.escape($site_url)}example/[^/]+/?$})
# end
# ── Locator declarations (page-object DSL) ────────────────────────────────
# Every line synthesises a `<name>_element` accessor used in the resolver
# hashes below. Use the locator strategy that survives DOM churn: prefer
# stable ids, then test-only attributes, then xpath as a fallback.
text_field(:reference, :id => "reference")
text_area(:description, :id => "description")
button(:submit, :xpath => "//button[contains(., 'Save')]")
button(:cancel, :xpath => "//button[contains(., 'Cancel')]")
div(:formError, :xpath => "//global-error//div")
# For autocomplete sentences: the gem looks up `selected_autocomplete` on
# the same page after the value is typed. Uncomment if you use
# `I fill "..." autocomplete with "..."`:
# button(:firstSuggestion, :xpath => "//ul[contains(@class, 'suggestions')]/li[1]")
# ── Resolver methods ──────────────────────────────────────────────────────
# Define only those required by the sentences your features actually use.
# See references/page-class-contract.md for the mapping.
# `I fill "..." field|autocomplete|datepicker|contenteditable with "..."`,
# `I should see field "..." filled "..."`,
# `I can see "..." in input "..."`,
# `I click on the select box "..." to select "..."`,
# `I should not see the field "..."`,
# `I should see the field "..."`,
# `I upload a file with the filename "..." in element "..."`.
def get_field(name)
{
"reference" => reference_element,
"description" => description_element,
# "selected_autocomplete" => firstSuggestion_element, # for autocomplete sentences
}[name]
end
# `I click on the button "..."`,
# `I should not see the button "..."`,
# `I should see the button "..."(\| disabled)`.
def get_button(name)
{
"save" => submit_element,
"cancel" => cancel_element,
}[name]
end
# `I should not see the element "..."`,
# `I can see "..." in element "..."(\| exactly)`,
# `I can not see "..." in element "..."`,
# `I scroll to "..."`,
# `I hover over the element "..."`,
# `I should see the element "..."`.
def get_element_by_name(name)
{
"form error" => formError_element,
}[name]
end
# Optional — only when features use CKEditor sentences.
# def get_ckeditor(name)
# { "description rich" => "description" }[name] # CKEDITOR.instances key, NOT a Watir element
# end
# Optional — only when features use `I fill "..." js field with "..."`
# or `I force scroll to "..."`. Returns a CSS selector (querySelector) or
# a bare id (getElementById, used by `I force scroll`).
# def get_js_selector(name)
# { "color picker" => "#color" }[name]
# end
end
# Place at: features/support/helpers/<thing>.rb
# Pattern for sharing state between steps — typically a value extracted from
# the UI in step N and consumed by step M. Not auto-loaded; `require` it from
# the step definitions that use it.
#
# ⚠️ `@@`-class variables persist for the entire Cucumber run, across
# scenarios. Reset in a `Before` hook if leakage between scenarios would cause
# flakiness.
class ExtractedLink
@@value = nil
def self.set(value)
@@value = value
end
def self.get
@@value
end
def self.reset
@@value = nil
end
end
# Optional: in features/support/hooks.rb
#
# Before { ExtractedLink.reset }
# Place at: features/step_definitions/imports.rb
# Required: this file MUST run before any other step definition file because it
# wires ENV['CUCUMBER_ROOT'] (used by the gem to resolve `support/helpers/*.rb`)
# and requires the gem itself.
ENV['CUCUMBER_ROOT'] = File.absolute_path('../', File.dirname(__FILE__))
require "cucumber-sentences"
Related skills
How it compares
Opinionated gem integration for shared Gherkin steps—not a greenfield BDD framework generator for every language.
FAQ
Who is cucumber-sentences for?
Developers and small teams on Ruby Cucumber projects that use page-object and Watir and want the donkeycode sentence library applied correctly.
When should I use cucumber-sentences?
When editing `.feature` files, adding feature-referenceable UI elements, creating pages, extending Domain/state helpers, or fixing undefined steps in a cucumber-sentences-based repo.
Is cucumber-sentences safe to install?
It guides test code against your repo; review the Security Audits panel on this Prism page and pin the gem version you trust in Gemfile.