Howdoc

Generate end-user documentation from your Capybara system tests.

A system test already knows every step a person would take through your application: which link they click, what they type, what they should see next. Howdoc listens to those steps, narrates them in prose, photographs the browser along the way, and writes an illustrated guide.

Because the guide is produced by a test that has to pass, it cannot quietly drift away from the application it describes. When the workflow changes, either the guide changes with it or the test goes red.

test 'should add a claim', doc: '1.3', permalink: 'how_to_add_a_claim' do
   :user

  tap_menu ['Contacts', 'Claims']
  tap_link 'New'

  fill_in 'Ref', with: 'TestREF'
  select 'Test', from: 'Contact'
  click_button 'Save'

  assert_text 'TestREF'
end

becomes a page titled 1.3. How to add a claim? with numbered instructions and a screenshot at each point where the reader arrives somewhere new.

Installation

group :test do
  gem 'howdoc'
end

Configuration

Howdoc.configure do |config|
  config.root    = 'public/docs'          # where guides are written
  config.enabled = ENV['DOC'].present?    # off unless asked for
  config.host    = 'example.com'          # the address a reader should open
  config.formats = %i[html]
  config.locales = %i[en et]              # languages every guide is written in

  config.register_locale_path 'test/howdoc/locales/*.yml'
  config.register_template_path 'test/howdoc/templates'
end

Nothing above has a sensible universal default except the formats, which is why none of it is baked into the engine.

Wiring it into a Minitest suite

Howdoc does not guess how your suite starts and finishes a test. The wiring is short and belongs in your own test case, where you can see it:

require 'howdoc/capybara_actions'

class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  include Howdoc::CapybaraActions   # include last: each wrapper ends in super

  setup do
    Howdoc.start(
      id: [:doc],
      permalink: [:permalink],
      intro: [:intro],
      title: "How to #{[:description]}?"
    )
  end

  teardown { Howdoc.finish }
end

Howdoc.finish is called in teardown rather than after a successful assertion, so a test that breaks halfway still leaves a partial guide showing exactly how far the reader would have got.

Once the whole run is over, build the index:

Howdoc::MinitestIntegration.install_at_exit!

or call Howdoc.finalize from wherever your suite already reports completion. Tests run in forked workers, so the index is assembled at the end from the manifest each guide leaves on disk, not from anything held in memory.

What ends up in a guide

Howdoc wraps the Capybara verbs a person would recognise as an instruction: visit, fill_in, select, check, uncheck, choose, click_button, click_link, click_on, attach_file and assert_text. Everything else your test does happens silently.

Each of them accepts four extra options, removed before the call reaches Capybara:

Option Effect
nodoc: true leave this action out of the guide
screenshot: true illustrate an action that normally is not illustrated
no_screenshot: true skip the illustration
full_page: true capture the whole scrollable page

By default visit and assert_text are illustrated and the action verbs are not, on the grounds that a picture is worth showing when the reader arrives somewhere, not every time they press a button.

Showing where to look

A screenshot of a busy page does not say which of forty things on it the step is about, so howdoc draws the mark a person would draw by hand:

  • an arrow with its tip on the button about to be pressed, on the field about to be filled, on the menu about to be chosen from
  • a highlight around the words an assertion was looking for

It is drawn in the browser between sizing the window and taking the picture, and taken off again straight after, so the page the test goes on driving is the page it was. There is no cursor in a headless screenshot to photograph -- the arrow is drawn, and its tip moves below anything too small for it to sit on without covering.

By default only the moments a reader arrives somewhere are illustrated. A guide that shows every click is one line of configuration:

config.illustrate_actions = true    # a picture per action, each one marked
config.marker_colour = '#e11d48'    # the arrow and the highlight
config.marker_dim = true            # dim the rest of the page around the mark
config.marker = false               # or draw nothing at all

Your own helpers

Real suites have helpers Capybara has no verb for -- a custom editor, a menu widget, a checkbox drawn as something else. Howdoc cannot know about those, so it exposes the same recording API its own wrappers use:

def choose_from_fancy_menu(*path)
  Howdoc.record(:fancy_menu, path: path.join(''))
  Howdoc.capture(page)

  within('#navbar') { path.each { |item| click_link(item, nodoc: true) } }
end

Howdoc.record is a no-op when documentation is switched off, so helpers need no guards. Pass html: instead of an action name for the rare step no sentence describes.

Three actions come translated even though no wrapper records them, because almost every suite ends up writing a helper that needs one: menu (a path), fill_in_editor (a value) and sign_in_required (a text).

Languages

A test runs in one language. Its guide is written in all of them.

Name the languages your application publishes and every recorded guide is written once per language, from the same run:

config.locales = %i[en et]
public/docs/en/how_to_add_a_claim.html
public/docs/et/how_to_add_a_claim.html

Nothing is recorded as a finished sentence, which is what makes this possible: a step is kept as the action and the values it happened with, and the sentence is composed once per language when the page is written. The screenshot is taken once, while the browser is still on the page, and copied into each language's guide -- it is a picture of the same application either way.

A guide's own heading is the exception, because it comes from the test rather than from the engine. Translate howdoc.documents.<permalink>.title to give the same guide a heading in another language, and .intro for its introduction:

et:
  howdoc:
    documents:
      how_to_add_a_claim:
        title: Kuidas lisada nõue?

Left untranslated, every language gets the heading the test gave it. A guide that belongs to one language only -- something only that language's users ever see -- says so: Howdoc.start(..., locale: :et).

A value that reads differently in different languages must not be resolved while the test runs, or it freezes into the language the test ran in. Field labels are the usual case, and Howdoc::Narrator.field defers one:

Howdoc.record(:check, field: Howdoc::Narrator.field(locator))

Wording

Every sentence is an I18n key under howdoc.actions, so the engine contains no prose of its own. English and Estonian ship with the gem, complete; an application needs no translation file of its own unless it changes the wording, adds a language, or records an action of its own invention:

fi:
  howdoc:
    actions:
      click_button: Paina painiketta <strong>"%{locator}"</strong>.

Interpolated values are HTML-escaped, the translations carry the markup.

Field names are humanised before they reach a translation: user_email becomes "email", because a reader does not know your schema. Give a field a name of its own by translating howdoc.fields.<locator>, or replace the whole convention:

config.field_label { |locator| MyLabels.for(locator) }

Any key that is used but not translated is reported at the end of the run, naming the language it is missing from, rather than leaving a silent hole in the guide. A step no language can describe is not recorded at all; a step one language cannot describe is left out of that language's guide only.

Every page carries the menu, and a guide links the ones before and after it:

public/docs/en/how_to_add_a_claim.html
  ┌────────────┬────────────────────┐
  │ Guides     │ 1.3. How to add a … │
  │ EVERYDAY   │ 1. Log in …         │
  │ 1.1 …      │ [screenshot]        │
  │ 1.3 ●      │ ‹ Previous  Next ›   │
  └────────────┴────────────────────┘

A guide cannot draw that menu when it is written: tests run in forked workers, each one writes its own page, and no other guide exists yet. So the guide leaves an empty placeholder and the index pass -- which has just read every page back -- fills it in:

%nav.howdoc-nav{ data: { howdoc_nav: true } }    -# the menu
%nav.howdoc-pager{ data: { howdoc_pager: true } } -# previous / next

Both are optional. A template that carries neither is left exactly as it is.

Above the page rather than beside it -- a phone, a narrow window -- the list of guides folds away behind a line the reader taps, so the guide begins where the screen begins. The stylesheet unfolds it again where there is room beside the page, and the index, whose menu is the page, is open from the start.

The menu also links the same guide in every other language the application publishes. Guides are matched by their identifier rather than their file name, because a translated heading gives a translated file name, and each language names itself in its own language (howdoc.languages.<code>).

config.group_label heads the sections, in the menu and in the index alike.

Templates

Page chrome lives in templates, never in the code that records steps, so restyling a guide does not mean running a browser suite again.

Templates are Haml, looked up as <registered path>/default/<type>/<format>/<file>, searched most recently registered first, with the gem's own set searched last. To change only the page shell, mirror that one path:

test/howdoc/templates/default/document/html/layout.haml

Everything else keeps coming from the gem. The arrangement is borrowed from YARD, which has been proving it works for well over a decade.

Assigns arrive as instance variables (@document, @records, @config) and render pulls in a partial. Haml escapes = output, so a narrated sentence -- which carries its own markup and whose values were escaped when the step was recorded -- is written with !=.

An overriding document layout has exactly one obligation: keep the heading in <title>. That is what the index reads. A page without one is left out of the index rather than listed blank. Everything else the index shows -- which locale a guide belongs to, what it links to -- comes from where the file sits.

Output

public/docs/
  assets/howdoc.css
  en/
    index.html
    how_to_add_a_claim.html
    images/how_to_add_a_claim_4.png

A guide is a page and nothing else. The index is built at the end of the run by reading those pages back: tests run in forked workers, so nothing survives in memory, and the pages are the only record of what was produced. Their markup is not a foreign format to be parsed defensively -- this gem wrote it.

Rake tasks

require 'howdoc/tasks'
  • howdoc:clean — remove generated guides, leaving config.preserved_files alone
  • howdoc:index — rebuild the index from manifests already on disk

Licence

MIT.