howdoc 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE.txt +21 -0
- data/README.md +221 -0
- data/config/locales/howdoc.en.yml +24 -0
- data/config/locales/howdoc.et.yml +24 -0
- data/lib/howdoc/capybara_actions.rb +155 -0
- data/lib/howdoc/configuration.rb +106 -0
- data/lib/howdoc/document.rb +83 -0
- data/lib/howdoc/minitest.rb +22 -0
- data/lib/howdoc/narrator.rb +75 -0
- data/lib/howdoc/recorder.rb +78 -0
- data/lib/howdoc/registry.rb +116 -0
- data/lib/howdoc/screenshot.rb +26 -0
- data/lib/howdoc/step.rb +21 -0
- data/lib/howdoc/tasks.rb +18 -0
- data/lib/howdoc/templates.rb +75 -0
- data/lib/howdoc/version.rb +5 -0
- data/lib/howdoc/writers.rb +60 -0
- data/lib/howdoc.rb +120 -0
- data/templates/default/assets/howdoc.css +110 -0
- data/templates/default/document/html/layout.haml +16 -0
- data/templates/default/document/html/step.haml +7 -0
- data/templates/default/index/html/layout.haml +22 -0
- metadata +99 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 02cc27d9848b0932cc7ba10c66b8e5682c955071f2cde03f27e6c9061c52d82c
|
|
4
|
+
data.tar.gz: 1bed4d6da3860c0ea8ac1f3f680218f24941f45fbb7d8e3b0699085e70588811
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 72e52519fec7d24abdb8765aa146702ef053a627fd87e4b40e1ff77775ba69ed626db5250accbb307719bbfee68a7f12e8d1fac6d966a0ab05df9859a1b38326
|
|
7
|
+
data.tar.gz: 70208bdc3b1557985b69bbdceb70ade90a91c39b9063412ed922df7cd1cae26e5629875dd1f66a16277364af39d49eff10ede8df80695c678a1792675f0f0b42
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
First release. Extracted from a working in-house documentation helper that had
|
|
6
|
+
been generating user guides from a Rails application's Capybara system tests.
|
|
7
|
+
|
|
8
|
+
- Narrates Capybara actions into prose through I18n, so the vocabulary is
|
|
9
|
+
translatable and no sentence is hardcoded in the engine. English and Estonian
|
|
10
|
+
are included.
|
|
11
|
+
- Captures a screenshot per interesting step.
|
|
12
|
+
- Writes an illustrated HTML guide per document.
|
|
13
|
+
- Builds an index across all generated guides, read back from the pages
|
|
14
|
+
themselves at the end of the run.
|
|
15
|
+
- Templates are Haml and overridable per directory, so an application supplies
|
|
16
|
+
its own page chrome without forking the gem.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Priit Tark
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# Howdoc
|
|
2
|
+
|
|
3
|
+
Generate end-user documentation from your Capybara system tests.
|
|
4
|
+
|
|
5
|
+
A system test already knows every step a person would take through your
|
|
6
|
+
application: which link they click, what they type, what they should see next.
|
|
7
|
+
Howdoc listens to those steps, narrates them in prose, photographs the browser
|
|
8
|
+
along the way, and writes an illustrated guide.
|
|
9
|
+
|
|
10
|
+
Because the guide is produced by a test that has to pass, it cannot quietly
|
|
11
|
+
drift away from the application it describes. When the workflow changes, either
|
|
12
|
+
the guide changes with it or the test goes red.
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
test 'should add a claim', doc: '1.3', permalink: 'how_to_add_a_claim' do
|
|
16
|
+
sign_in :user
|
|
17
|
+
|
|
18
|
+
tap_menu ['Contacts', 'Claims']
|
|
19
|
+
tap_link 'New'
|
|
20
|
+
|
|
21
|
+
fill_in 'Ref', with: 'TestREF'
|
|
22
|
+
select 'Test', from: 'Contact'
|
|
23
|
+
click_button 'Save'
|
|
24
|
+
|
|
25
|
+
assert_text 'TestREF'
|
|
26
|
+
end
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
becomes a page titled *1.3. How to add a claim?* with numbered instructions and
|
|
30
|
+
a screenshot at each point where the reader arrives somewhere new.
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
group :test do
|
|
36
|
+
gem 'howdoc'
|
|
37
|
+
end
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Configuration
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
Howdoc.configure do |config|
|
|
44
|
+
config.root = 'public/docs' # where guides are written
|
|
45
|
+
config.enabled = ENV['DOC'].present? # off unless asked for
|
|
46
|
+
config.host = 'example.com' # the address a reader should open
|
|
47
|
+
config.formats = %i[html]
|
|
48
|
+
|
|
49
|
+
config.register_locale_path 'test/howdoc/locales/*.yml'
|
|
50
|
+
config.register_template_path 'test/howdoc/templates'
|
|
51
|
+
end
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Nothing above has a sensible universal default except the formats, which is why
|
|
55
|
+
none of it is baked into the engine.
|
|
56
|
+
|
|
57
|
+
## Wiring it into a Minitest suite
|
|
58
|
+
|
|
59
|
+
Howdoc does not guess how your suite starts and finishes a test. The wiring is
|
|
60
|
+
short and belongs in your own test case, where you can see it:
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
require 'howdoc/capybara_actions'
|
|
64
|
+
|
|
65
|
+
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
|
|
66
|
+
include Howdoc::CapybaraActions # include last: each wrapper ends in super
|
|
67
|
+
|
|
68
|
+
setup do
|
|
69
|
+
Howdoc.start(
|
|
70
|
+
id: metadata[:doc],
|
|
71
|
+
permalink: metadata[:permalink],
|
|
72
|
+
intro: metadata[:intro],
|
|
73
|
+
title: "How to #{metadata[:description]}?",
|
|
74
|
+
locale: I18n.locale
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
teardown { Howdoc.finish }
|
|
79
|
+
end
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`Howdoc.finish` is called in `teardown` rather than after a successful
|
|
83
|
+
assertion, so a test that breaks halfway still leaves a partial guide showing
|
|
84
|
+
exactly how far the reader would have got.
|
|
85
|
+
|
|
86
|
+
Once the whole run is over, build the index:
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
Howdoc::MinitestIntegration.install_at_exit!
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
or call `Howdoc.finalize` from wherever your suite already reports completion.
|
|
93
|
+
Tests run in forked workers, so the index is assembled at the end from the
|
|
94
|
+
manifest each guide leaves on disk, not from anything held in memory.
|
|
95
|
+
|
|
96
|
+
## What ends up in a guide
|
|
97
|
+
|
|
98
|
+
Howdoc wraps the Capybara verbs a person would recognise as an instruction:
|
|
99
|
+
`visit`, `fill_in`, `select`, `check`, `uncheck`, `choose`, `click_button`,
|
|
100
|
+
`click_link`, `click_on`, `attach_file` and `assert_text`. Everything else your
|
|
101
|
+
test does happens silently.
|
|
102
|
+
|
|
103
|
+
Each of them accepts four extra options, removed before the call reaches
|
|
104
|
+
Capybara:
|
|
105
|
+
|
|
106
|
+
| Option | Effect |
|
|
107
|
+
| --- | --- |
|
|
108
|
+
| `nodoc: true` | leave this action out of the guide |
|
|
109
|
+
| `screenshot: true` | illustrate an action that normally is not illustrated |
|
|
110
|
+
| `no_screenshot: true` | skip the illustration |
|
|
111
|
+
| `full_page: true` | capture the whole scrollable page |
|
|
112
|
+
|
|
113
|
+
By default `visit` and `assert_text` are illustrated and the action verbs are
|
|
114
|
+
not, on the grounds that a picture is worth showing when the reader arrives
|
|
115
|
+
somewhere, not every time they press a button.
|
|
116
|
+
|
|
117
|
+
## Your own helpers
|
|
118
|
+
|
|
119
|
+
Real suites have helpers Capybara has no verb for -- a custom editor, a menu
|
|
120
|
+
widget, a checkbox drawn as something else. Howdoc cannot know about those, so
|
|
121
|
+
it exposes the same recording API its own wrappers use:
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
def choose_from_fancy_menu(*path)
|
|
125
|
+
Howdoc.record(:fancy_menu, path: path.join(' → '))
|
|
126
|
+
Howdoc.capture(page)
|
|
127
|
+
|
|
128
|
+
within('#navbar') { path.each { |item| click_link(item, nodoc: true) } }
|
|
129
|
+
end
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`Howdoc.record` is a no-op when documentation is switched off, so helpers need
|
|
133
|
+
no guards. Pass `html:` instead of an action name for the rare step no sentence
|
|
134
|
+
describes.
|
|
135
|
+
|
|
136
|
+
Three actions come translated even though no wrapper records them, because
|
|
137
|
+
almost every suite ends up writing a helper that needs one: `menu` (a `path`),
|
|
138
|
+
`fill_in_editor` (a `value`) and `sign_in_required` (a `text`).
|
|
139
|
+
|
|
140
|
+
## Wording
|
|
141
|
+
|
|
142
|
+
Every sentence is an I18n key under `howdoc.actions`, so the engine contains no
|
|
143
|
+
prose of its own. English and Estonian ship with the gem, complete; an
|
|
144
|
+
application needs no translation file of its own unless it changes the wording,
|
|
145
|
+
adds a language, or records an action of its own invention:
|
|
146
|
+
|
|
147
|
+
```yaml
|
|
148
|
+
fi:
|
|
149
|
+
howdoc:
|
|
150
|
+
actions:
|
|
151
|
+
click_button: Paina painiketta <strong>"%{locator}"</strong>.
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Interpolated values are HTML-escaped, the translations carry the markup.
|
|
155
|
+
|
|
156
|
+
Field names are humanised before they reach a translation: `user_email` becomes
|
|
157
|
+
"email", because a reader does not know your schema. Give a field a name of its
|
|
158
|
+
own by translating `howdoc.fields.<locator>`, or replace the whole convention:
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
config.field_label { |locator| MyLabels.for(locator) }
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Any key that is used but not translated is reported at the end of the run rather
|
|
165
|
+
than leaving a silent hole in the guide.
|
|
166
|
+
|
|
167
|
+
## Templates
|
|
168
|
+
|
|
169
|
+
Page chrome lives in templates, never in the code that records steps, so
|
|
170
|
+
restyling a guide does not mean running a browser suite again.
|
|
171
|
+
|
|
172
|
+
Templates are Haml, looked up as
|
|
173
|
+
`<registered path>/default/<type>/<format>/<file>`, searched most recently
|
|
174
|
+
registered first, with the gem's own set searched last. To change only the page
|
|
175
|
+
shell, mirror that one path:
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
test/howdoc/templates/default/document/html/layout.haml
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Everything else keeps coming from the gem. The arrangement is borrowed from
|
|
182
|
+
YARD, which has been proving it works for well over a decade.
|
|
183
|
+
|
|
184
|
+
Assigns arrive as instance variables (`@document`, `@records`, `@config`) and
|
|
185
|
+
`render` pulls in a partial. Haml escapes `=` output, so a narrated sentence --
|
|
186
|
+
which carries its own markup and whose values were escaped when the step was
|
|
187
|
+
recorded -- is written with `!=`.
|
|
188
|
+
|
|
189
|
+
An overriding document layout has exactly one obligation: keep the heading in
|
|
190
|
+
`<title>`. That is what the index reads. A page without one is left out of the
|
|
191
|
+
index rather than listed blank. Everything else the index shows -- which locale
|
|
192
|
+
a guide belongs to, what it links to -- comes from where the file sits.
|
|
193
|
+
|
|
194
|
+
## Output
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
public/docs/
|
|
198
|
+
assets/howdoc.css
|
|
199
|
+
en/
|
|
200
|
+
index.html
|
|
201
|
+
how_to_add_a_claim.html
|
|
202
|
+
images/how_to_add_a_claim_4.png
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
A guide is a page and nothing else. The index is built at the end of the run by
|
|
206
|
+
reading those pages back: tests run in forked workers, so nothing survives in
|
|
207
|
+
memory, and the pages are the only record of what was produced. Their markup is
|
|
208
|
+
not a foreign format to be parsed defensively -- this gem wrote it.
|
|
209
|
+
|
|
210
|
+
## Rake tasks
|
|
211
|
+
|
|
212
|
+
```ruby
|
|
213
|
+
require 'howdoc/tasks'
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
* `howdoc:clean` — remove generated guides, leaving `config.preserved_files` alone
|
|
217
|
+
* `howdoc:index` — rebuild the index from manifests already on disk
|
|
218
|
+
|
|
219
|
+
## Licence
|
|
220
|
+
|
|
221
|
+
MIT.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
en:
|
|
2
|
+
howdoc:
|
|
3
|
+
actions:
|
|
4
|
+
assert_text: You should now see <strong>"%{text}"</strong>.
|
|
5
|
+
attach_file: Attach a file to <strong>%{field}</strong>.
|
|
6
|
+
check: Tick <strong>"%{field}"</strong>.
|
|
7
|
+
choose: Choose <strong>"%{field}"</strong>.
|
|
8
|
+
click_button: Press the <strong>"%{locator}"</strong> button.
|
|
9
|
+
click_link: Click the <strong>"%{locator}"</strong> link.
|
|
10
|
+
click_on: Click <strong>"%{locator}"</strong>.
|
|
11
|
+
fill_in: Fill in <strong>%{field}</strong> with <strong>"%{value}"</strong>.
|
|
12
|
+
fill_in_editor: Fill the text editor with <strong>"%{value}"</strong>.
|
|
13
|
+
fill_in_email: Enter an email address, for example <strong>"%{value}"</strong>.
|
|
14
|
+
fill_in_password: Enter a password, for example <strong>"%{value}"</strong>.
|
|
15
|
+
menu: 'Click menu items: <strong>"%{path}"</strong>.'
|
|
16
|
+
select: Select <strong>"%{value}"</strong> from <strong>%{field}</strong>.
|
|
17
|
+
sign_in_required: Please log in to the system to access <strong>"%{text}"</strong>.
|
|
18
|
+
uncheck: Clear the <strong>"%{field}"</strong> tick.
|
|
19
|
+
visit: Open <strong>%{url}</strong> in your browser.
|
|
20
|
+
document:
|
|
21
|
+
follow_steps: 'Follow these steps:'
|
|
22
|
+
index:
|
|
23
|
+
empty: No guides have been generated yet.
|
|
24
|
+
title: Guides
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
et:
|
|
2
|
+
howdoc:
|
|
3
|
+
actions:
|
|
4
|
+
assert_text: Näete teksti <strong>"%{text}"</strong>.
|
|
5
|
+
attach_file: Lisa fail väljale <strong>%{field}</strong>.
|
|
6
|
+
check: Lisa linnuke <strong>"%{field}"</strong>.
|
|
7
|
+
choose: Vali <strong>"%{field}"</strong>.
|
|
8
|
+
click_button: Vajuta nupule <strong>"%{locator}"</strong>.
|
|
9
|
+
click_link: Vajuta lingile <strong>"%{locator}"</strong>.
|
|
10
|
+
click_on: Klõpsa <strong>"%{locator}"</strong>.
|
|
11
|
+
fill_in: Sisesta näiteks <strong>"%{value}"</strong>.
|
|
12
|
+
fill_in_editor: Sisesta tekstiredaktorisse näiteks <strong>"%{value}"</strong>.
|
|
13
|
+
fill_in_email: Sisesta eposti aadress, näiteks <strong>"%{value}"</strong>.
|
|
14
|
+
fill_in_password: Sisesta parool, näiteks <strong>"%{value}"</strong>.
|
|
15
|
+
menu: 'Vali menüüs: <strong>"%{path}"</strong>.'
|
|
16
|
+
select: Vali <strong>"%{value}"</strong> hüpikmenüüst "%{field}".
|
|
17
|
+
sign_in_required: 'Palun logige süsteemi sisse, et pääseda rakenduse keskkonda: <strong>"%{text}"</strong>.'
|
|
18
|
+
uncheck: Eemalda linnuke <strong>"%{field}"</strong>.
|
|
19
|
+
visit: Palun sisesta <strong>%{url}</strong> oma brauserisse.
|
|
20
|
+
document:
|
|
21
|
+
follow_steps: 'Palun jälgige järgnevaid samme:'
|
|
22
|
+
index:
|
|
23
|
+
empty: Ühtegi juhendit ei ole veel loodud.
|
|
24
|
+
title: Juhendid
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'howdoc'
|
|
4
|
+
|
|
5
|
+
module Howdoc
|
|
6
|
+
# Wraps the Capybara verbs a person would recognise as an instruction, so an
|
|
7
|
+
# ordinary test narrates itself. Include it after any other module that wraps
|
|
8
|
+
# the same verbs -- Ruby searches the most recently included module first, and
|
|
9
|
+
# each wrapper here ends in +super+.
|
|
10
|
+
#
|
|
11
|
+
# Every wrapper accepts four extra options, which are removed before the call
|
|
12
|
+
# reaches Capybara:
|
|
13
|
+
#
|
|
14
|
+
# nodoc: leave this action out of the guide entirely
|
|
15
|
+
# screenshot: illustrate this action even though it normally is not
|
|
16
|
+
# no_screenshot: do not illustrate this action even though it normally is
|
|
17
|
+
# full_page: capture the whole scrollable page, not one screenful
|
|
18
|
+
#
|
|
19
|
+
# Anything the application does that Capybara has no verb for is recorded by
|
|
20
|
+
# calling Howdoc.record directly from the application's own helper.
|
|
21
|
+
module CapybaraActions
|
|
22
|
+
def visit(visit_uri, **options)
|
|
23
|
+
doc = Howdoc.extract_options!(options)
|
|
24
|
+
result = super(visit_uri)
|
|
25
|
+
|
|
26
|
+
narrate(:visit, doc, capture: true, url: displayed_url(visit_uri))
|
|
27
|
+
result
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def fill_in(locator = nil, with:, currently_with: nil, fill_options: {}, **options)
|
|
31
|
+
doc = Howdoc.extract_options!(options)
|
|
32
|
+
label = howdoc_field_label(locator)
|
|
33
|
+
|
|
34
|
+
narrate(fill_in_action(label), doc, field: label, value: with)
|
|
35
|
+
|
|
36
|
+
super(locator, with:, currently_with:, fill_options:, **options)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def select(value = nil, from: nil, **options)
|
|
40
|
+
doc = Howdoc.extract_options!(options)
|
|
41
|
+
|
|
42
|
+
narrate(:select, doc, value:, field: howdoc_field_label(from))
|
|
43
|
+
|
|
44
|
+
super(value, from:, **options)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def check(locator = nil, **options)
|
|
48
|
+
doc = Howdoc.extract_options!(options)
|
|
49
|
+
|
|
50
|
+
narrate(:check, doc, field: howdoc_field_label(locator))
|
|
51
|
+
|
|
52
|
+
super(locator, **options)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def uncheck(locator = nil, **options)
|
|
56
|
+
doc = Howdoc.extract_options!(options)
|
|
57
|
+
|
|
58
|
+
narrate(:uncheck, doc, field: howdoc_field_label(locator))
|
|
59
|
+
|
|
60
|
+
super(locator, **options)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def choose(locator = nil, **options)
|
|
64
|
+
doc = Howdoc.extract_options!(options)
|
|
65
|
+
|
|
66
|
+
narrate(:choose, doc, field: howdoc_field_label(locator))
|
|
67
|
+
|
|
68
|
+
super(locator, **options)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def click_button(locator = nil, **options)
|
|
72
|
+
doc = Howdoc.extract_options!(options)
|
|
73
|
+
|
|
74
|
+
narrate(:click_button, doc, locator:)
|
|
75
|
+
|
|
76
|
+
super(locator, **options)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def click_link(locator = nil, **options)
|
|
80
|
+
doc = Howdoc.extract_options!(options)
|
|
81
|
+
|
|
82
|
+
narrate(:click_link, doc, locator:)
|
|
83
|
+
|
|
84
|
+
super(locator, **options)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def click_on(locator = nil, **options)
|
|
88
|
+
doc = Howdoc.extract_options!(options)
|
|
89
|
+
|
|
90
|
+
narrate(:click_on, doc, locator:)
|
|
91
|
+
|
|
92
|
+
super(locator, **options)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# rubocop:disable Style/OptionalArguments
|
|
96
|
+
def attach_file(locator = nil, paths, make_visible: nil, **options)
|
|
97
|
+
doc = Howdoc.extract_options!(options)
|
|
98
|
+
|
|
99
|
+
narrate(:attach_file, doc, field: howdoc_field_label(locator))
|
|
100
|
+
|
|
101
|
+
super(locator, paths, make_visible:, **options)
|
|
102
|
+
end
|
|
103
|
+
# rubocop:enable Style/OptionalArguments
|
|
104
|
+
|
|
105
|
+
# The one assertion that is also an instruction: it is how a guide tells the
|
|
106
|
+
# reader they have arrived somewhere and what they should see there.
|
|
107
|
+
def assert_text(locator, **options)
|
|
108
|
+
doc = Howdoc.extract_options!(options)
|
|
109
|
+
result = super(locator, **options)
|
|
110
|
+
|
|
111
|
+
narrate(:assert_text, doc, capture: true, arrival: true, text: locator)
|
|
112
|
+
result
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def howdoc_field_label(locator)
|
|
118
|
+
Howdoc::Narrator.field_label(locator, locale: howdoc_locale)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def howdoc_locale
|
|
122
|
+
Howdoc.current&.locale || I18n.locale
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def fill_in_action(label)
|
|
126
|
+
case label.to_s
|
|
127
|
+
when 'email' then :fill_in_email
|
|
128
|
+
when 'password' then :fill_in_password
|
|
129
|
+
else :fill_in
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# A guide tells the reader to open the application's real address, not the
|
|
134
|
+
# ephemeral host and port the test server happens to be listening on.
|
|
135
|
+
def displayed_url(visit_uri)
|
|
136
|
+
host = Howdoc.config.host
|
|
137
|
+
host.nil? ? visit_uri.to_s : "https://#{host}#{visit_uri}"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def narrate(action, doc_options, capture: false, arrival: false, **payload)
|
|
141
|
+
step = Howdoc.record(action, arrival:, nodoc: doc_options[:nodoc], **payload)
|
|
142
|
+
return nil if step.nil?
|
|
143
|
+
|
|
144
|
+
illustrate = doc_options.fetch(:screenshot, capture)
|
|
145
|
+
return step unless illustrate
|
|
146
|
+
|
|
147
|
+
Howdoc.capture(
|
|
148
|
+
page,
|
|
149
|
+
full_page: doc_options[:full_page] || false,
|
|
150
|
+
no_screenshot: doc_options[:no_screenshot] || false
|
|
151
|
+
)
|
|
152
|
+
step
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Howdoc
|
|
4
|
+
# Everything an application is allowed to decide: where guides are written,
|
|
5
|
+
# what they are called, which templates dress them and how the index is
|
|
6
|
+
# ordered. The engine itself holds no application knowledge, so anything that
|
|
7
|
+
# would otherwise be hardcoded belongs here.
|
|
8
|
+
class Configuration
|
|
9
|
+
# Directory the generated guides are written into, relative to the working
|
|
10
|
+
# directory unless given as an absolute path.
|
|
11
|
+
attr_accessor :root
|
|
12
|
+
|
|
13
|
+
# Guides are only generated when this is true. Keep it tied to an
|
|
14
|
+
# environment variable so an ordinary test run stays fast.
|
|
15
|
+
attr_accessor :enabled
|
|
16
|
+
|
|
17
|
+
# Public host name of the documented application. It appears in the prose
|
|
18
|
+
# for +visit+, where telling the reader to open "localhost:9200" would be
|
|
19
|
+
# useless.
|
|
20
|
+
attr_accessor :host
|
|
21
|
+
|
|
22
|
+
# Writers to run for every finished document, in order.
|
|
23
|
+
attr_accessor :formats
|
|
24
|
+
|
|
25
|
+
# Locales to build an index for. Left nil, only the locales that actually
|
|
26
|
+
# produced a guide get one, which means a run in a single language quietly
|
|
27
|
+
# drops the other languages' indexes. Naming them keeps every index in
|
|
28
|
+
# place, empty ones included.
|
|
29
|
+
attr_accessor :locales
|
|
30
|
+
|
|
31
|
+
# Screenshots are taken at this height unless the step asked for a full
|
|
32
|
+
# page, so that guides do not mix wildly different image proportions.
|
|
33
|
+
attr_accessor :screenshot_height
|
|
34
|
+
|
|
35
|
+
# Files in the output directory that the clean task must leave alone,
|
|
36
|
+
# typically hand-maintained landing pages.
|
|
37
|
+
attr_accessor :preserved_files
|
|
38
|
+
|
|
39
|
+
# Whether the templates' assets directory is copied into the output. An
|
|
40
|
+
# application whose templates link its own stylesheets turns this off.
|
|
41
|
+
attr_accessor :install_assets
|
|
42
|
+
|
|
43
|
+
# Directories searched for templates, most recently registered first. The
|
|
44
|
+
# gem's own directory is always searched last, so an application overrides
|
|
45
|
+
# a single file by mirroring its path rather than copying the whole set.
|
|
46
|
+
attr_reader :template_paths
|
|
47
|
+
|
|
48
|
+
# Globs of translation files to add to I18n's load path. The gem's English
|
|
49
|
+
# is loaded first so an application only translates what it wants to change.
|
|
50
|
+
attr_reader :locale_paths
|
|
51
|
+
|
|
52
|
+
def initialize
|
|
53
|
+
@root = 'doc/howdoc'
|
|
54
|
+
@enabled = false
|
|
55
|
+
@host = nil
|
|
56
|
+
@formats = %i[html]
|
|
57
|
+
@locales = nil
|
|
58
|
+
@screenshot_height = 940
|
|
59
|
+
@preserved_files = %w[index.html .keep]
|
|
60
|
+
@install_assets = true
|
|
61
|
+
@template_paths = []
|
|
62
|
+
@locale_paths = []
|
|
63
|
+
@field_label = nil
|
|
64
|
+
@sort_key = nil
|
|
65
|
+
@group_label = nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def register_template_path(path)
|
|
69
|
+
@template_paths.unshift(path.to_s)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def register_locale_path(glob)
|
|
73
|
+
@locale_paths << glob.to_s
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# How a form field locator becomes something a reader recognises. The
|
|
77
|
+
# default turns +user_email+ into "email", which suits Rails' own naming.
|
|
78
|
+
# Applications with their own conventions replace it wholesale.
|
|
79
|
+
def field_label(&block)
|
|
80
|
+
return @field_label = block if block
|
|
81
|
+
|
|
82
|
+
@field_label ||= ->(locator) { Howdoc::Narrator.humanize_locator(locator) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Sorts documents in the index. Receives a Howdoc::Registry::Record.
|
|
86
|
+
def sort_key(&block)
|
|
87
|
+
return @sort_key = block if block
|
|
88
|
+
|
|
89
|
+
@sort_key ||= ->(record) { record.heading.to_s }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Names the index section a document belongs under, or nil for no section.
|
|
93
|
+
# Receives a Howdoc::Registry::Record, so it decides from the heading, the
|
|
94
|
+
# identifier or the file name -- there is nowhere else for a section to
|
|
95
|
+
# come from once a guide is a page like any other.
|
|
96
|
+
def group_label(&block)
|
|
97
|
+
return @group_label = block if block
|
|
98
|
+
|
|
99
|
+
@group_label ||= ->(_record) {}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def enabled?
|
|
103
|
+
!!@enabled
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Howdoc
|
|
4
|
+
# One guide: the identity a test gave it, plus the steps recorded while that
|
|
5
|
+
# test ran. A document collects everything in memory and is written out once,
|
|
6
|
+
# so a writer always sees the whole guide rather than a half-built page.
|
|
7
|
+
class Document
|
|
8
|
+
# Letters that carry no accent to strip: they are their own character, so
|
|
9
|
+
# Unicode decomposition leaves them untouched and a filename filter would
|
|
10
|
+
# otherwise drop them entirely.
|
|
11
|
+
INDIVISIBLE = {
|
|
12
|
+
'ß' => 'ss', 'æ' => 'ae', 'œ' => 'oe', 'ø' => 'o',
|
|
13
|
+
'đ' => 'd', 'ð' => 'd', 'ł' => 'l', 'þ' => 'th'
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
attr_reader :id, :permalink, :title, :intro, :locale, :steps
|
|
17
|
+
|
|
18
|
+
def initialize(id:, title:, locale:, permalink: nil, intro: nil)
|
|
19
|
+
@id = id
|
|
20
|
+
@title = title
|
|
21
|
+
@locale = locale.to_sym
|
|
22
|
+
@permalink = permalink
|
|
23
|
+
@intro = intro
|
|
24
|
+
@steps = []
|
|
25
|
+
@counter = 0
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def slug
|
|
29
|
+
@slug ||= (permalink || slugify(title)).to_s
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def heading
|
|
33
|
+
id.nil? ? title : "#{id}. #{title}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def new_step(**attributes)
|
|
37
|
+
@counter += 1
|
|
38
|
+
step = Step.new(number: @counter, **attributes)
|
|
39
|
+
steps << step
|
|
40
|
+
step
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def last_step
|
|
44
|
+
steps.last
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def dir
|
|
48
|
+
File.join(Howdoc.config.root, locale.to_s)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def image_dir
|
|
52
|
+
File.join(dir, 'images')
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def path(extension)
|
|
56
|
+
File.join(dir, "#{slug}.#{extension}")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def image_filename(number)
|
|
60
|
+
"#{slug}_#{number}.png"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# Deliberately not ActiveSupport's parameterize: the engine has no business
|
|
66
|
+
# dragging Rails into a project that only wanted a documentation generator.
|
|
67
|
+
#
|
|
68
|
+
# Accents are decomposed and their marks dropped rather than deleted whole,
|
|
69
|
+
# so an Estonian title turns into "kuidas_lisada_noue" instead of the
|
|
70
|
+
# unreadable "kuidas_lisada_n_ue" a plain ASCII filter would leave behind.
|
|
71
|
+
def slugify(string)
|
|
72
|
+
string
|
|
73
|
+
.to_s
|
|
74
|
+
.downcase
|
|
75
|
+
.gsub(/[#{INDIVISIBLE.keys.join}]/, INDIVISIBLE)
|
|
76
|
+
.unicode_normalize(:nfd)
|
|
77
|
+
.gsub(/\p{Mn}/, '')
|
|
78
|
+
.downcase
|
|
79
|
+
.gsub(/[^a-z0-9]+/, '_')
|
|
80
|
+
.gsub(/\A_+|_+\z/, '')
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'minitest'
|
|
4
|
+
require 'howdoc'
|
|
5
|
+
|
|
6
|
+
module Howdoc
|
|
7
|
+
# Optional convenience for Minitest suites. The gem deliberately does not try
|
|
8
|
+
# to guess how a project starts and finishes a test, because that wiring is
|
|
9
|
+
# six readable lines in the project's own test case and any guess would be
|
|
10
|
+
# wrong somewhere. All this offers is the one hook that has no good place in
|
|
11
|
+
# a test case: assembling the index once the whole run is over.
|
|
12
|
+
module MinitestIntegration
|
|
13
|
+
class << self
|
|
14
|
+
def install_at_exit!
|
|
15
|
+
return if @installed
|
|
16
|
+
|
|
17
|
+
@installed = true
|
|
18
|
+
::Minitest.after_run { Howdoc.finalize }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|