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
sign_in :user
['Contacts', 'Claims']
tap_link 'New'
fill_in 'Ref', with: 'TestREF'
select 'Test', from: 'Contact'
'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 (*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.
Navigation
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, leavingconfig.preserved_filesalonehowdoc:index— rebuild the index from manifests already on disk
Licence
MIT.