specbook 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 +25 -0
- data/LICENSE.txt +21 -0
- data/README.md +105 -0
- data/app/controllers/specbook/application_controller.rb +4 -0
- data/app/controllers/specbook/viewer_controller.rb +179 -0
- data/app/views/specbook/viewer/_app_js.html.erb +1335 -0
- data/app/views/specbook/viewer/_screenshots_sidebar.html.erb +24 -0
- data/app/views/specbook/viewer/_styles.html.erb +178 -0
- data/app/views/specbook/viewer/_top_bar.html.erb +13 -0
- data/app/views/specbook/viewer/_traces_sidebar.html.erb +15 -0
- data/app/views/specbook/viewer/_viewer_panel.html.erb +44 -0
- data/app/views/specbook/viewer/show.html.erb +27 -0
- data/config/routes.rb +6 -0
- data/lib/generators/specbook/install/USAGE +25 -0
- data/lib/generators/specbook/install/install_generator.rb +19 -0
- data/lib/generators/specbook/install/templates/README +22 -0
- data/lib/generators/specbook/install/templates/specbook.rb +45 -0
- data/lib/specbook/configuration.rb +50 -0
- data/lib/specbook/engine.rb +7 -0
- data/lib/specbook/recorders/playwright_trace.rb +91 -0
- data/lib/specbook/recorders/screenshot.rb +713 -0
- data/lib/specbook/rspec.rb +15 -0
- data/lib/specbook/version.rb +3 -0
- data/lib/specbook.rb +13 -0
- metadata +150 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: fbe6bd3cae173a5362be3eba0fa808aa86b61710b7b73fcd62a0cc3e0d2cdde2
|
|
4
|
+
data.tar.gz: d25e69ae37f61f774b7ba69d8852a2a20d086ee25f3b6192580d7507b94517ca
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 61f255a8d466af91f661b3dd9bbff207bea0ba6f05fe165818bde828c0d8ee9dbe80e3647936c53f8815a9acf292d543b7fd237c3b886b51cfe129ff3be8c961
|
|
7
|
+
data.tar.gz: b87c652c342e17667ad19e1cace665473ffee25813055dc24d0d35df1157e7764fd9d12541bd064fa1619aa5b908886148013c9b1814285b5d0ca2b0ab04c272
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to Specbook are documented here.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0] — 2026-04-28
|
|
10
|
+
|
|
11
|
+
First public release.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- Mountable Rails engine (`mount Specbook::Engine => "/specs"`).
|
|
15
|
+
- Screenshot recorder (`RECORD_SPECS=1`) — captures Capybara screenshots + element bounding boxes after each step.
|
|
16
|
+
- Playwright trace recorder (`RECORD_TRACES=1`) — captures Playwright traces and serves them via `npx playwright show-trace`.
|
|
17
|
+
- Configurable seams: `authorize_with`, `screenshot_root`, `trace_root`, `feature_root`, `actor_colors`, `ui_domains`, `setup_overlay_rules`, `back_link`, `editor_base`, `max_runs`, `trace_viewer_port`.
|
|
18
|
+
- Sensible default overlay rules (login → 🔑, exists → 🔧, redirected → ✅).
|
|
19
|
+
- Install generator: `rails generate specbook:install`.
|
|
20
|
+
- Engine-internal test suite with dummy Rails app at `spec/dummy/`.
|
|
21
|
+
- GitHub Actions CI matrix: Ruby 3.1–3.3 × Rails 7.1–8.0.
|
|
22
|
+
|
|
23
|
+
## [0.1.0.alpha] — 2026-04-25
|
|
24
|
+
|
|
25
|
+
Internal vendoring milestone; not published.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Steve Leung
|
|
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,105 @@
|
|
|
1
|
+
# Specbook
|
|
2
|
+
|
|
3
|
+
**Storybook for your Rails specs. Record. Replay. Review.**
|
|
4
|
+
|
|
5
|
+
Specbook is a Rails engine that turns your RSpec system specs and Turnip features into a browsable, animated walkthrough — screenshots with element overlays, Gherkin step cards with syntax-highlighted Ruby source, and a Playwright trace viewer launcher.
|
|
6
|
+
|
|
7
|
+
## Why
|
|
8
|
+
|
|
9
|
+
Reading test output is fine for CI. But when you want to:
|
|
10
|
+
|
|
11
|
+
- Show a designer what the new flow actually looks like
|
|
12
|
+
- Onboard a new dev to the codebase by showing what the specs cover
|
|
13
|
+
- Debug a flaky test by replaying the screenshot timeline
|
|
14
|
+
- Demo to a stakeholder what's been built without spinning up the app
|
|
15
|
+
|
|
16
|
+
…you want a viewer, not a log file.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
# Gemfile
|
|
22
|
+
group :development, :test do
|
|
23
|
+
gem "specbook"
|
|
24
|
+
end
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
bundle install
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Mount the engine in `config/routes.rb`:
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
mount Specbook::Engine => "/specs"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Configure in `config/initializers/specbook.rb`:
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
Specbook.configure do |config|
|
|
41
|
+
# Authorize who can view the player. Default: dev/test only.
|
|
42
|
+
config.authorize_with = ->(controller) { controller.current_user&.admin? }
|
|
43
|
+
|
|
44
|
+
# Optional: a "back" link in the top bar.
|
|
45
|
+
config.back_link = { href: "/admin", text: "← Back to admin" }
|
|
46
|
+
|
|
47
|
+
# Optional: VS Code link base for jumping to spec source.
|
|
48
|
+
config.editor_base = "vscode://file#{Rails.root}"
|
|
49
|
+
|
|
50
|
+
# Optional: per-actor colors for visual differentiation in the viewer.
|
|
51
|
+
config.actor_colors = { "Alice" => "#3b82f6", "Bob" => "#f59e0b" }
|
|
52
|
+
|
|
53
|
+
# Optional: directory groupings for the sidebar.
|
|
54
|
+
config.ui_domains = %w[admin public mobile]
|
|
55
|
+
end
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Add the recorder hooks to `spec/rails_helper.rb`:
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
require "specbook/rspec"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Record specs
|
|
65
|
+
|
|
66
|
+
Run your system specs with `RECORD_SPECS=1` to capture screenshots:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
CI=1 RECORD_SPECS=1 bundle exec rspec spec/system/
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
For Playwright traces:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
RECORD_TRACES=1 bundle exec rspec spec/system/
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Visit your mount point (e.g. `/specs`) to view.
|
|
79
|
+
|
|
80
|
+
## Configuration
|
|
81
|
+
|
|
82
|
+
| Option | Type | Default | Purpose |
|
|
83
|
+
|---|---|---|---|
|
|
84
|
+
| `authorize_with` | Proc | dev/test only | Lambda receiving the controller; return truthy to allow access |
|
|
85
|
+
| `screenshot_root` | Pathname | `Rails.root.join("tmp/spec_screenshots")` | Where screenshots + manifest land |
|
|
86
|
+
| `trace_root` | Pathname | `Rails.root.join("tmp/spec_traces")` | Where Playwright traces land |
|
|
87
|
+
| `feature_root` | Pathname | `Rails.root` | Project root for resolving `.feature` paths in the manifest |
|
|
88
|
+
| `max_runs` | Integer | 20 | Number of historical runs kept |
|
|
89
|
+
| `trace_viewer_port` | Integer | 9322 | Port for Playwright trace server |
|
|
90
|
+
| `actor_colors` | Hash | `{}` | Name → hex color for spec actors |
|
|
91
|
+
| `ui_domains` | Array | `[]` | Top-level sidebar groupings |
|
|
92
|
+
| `setup_overlay_rules` | Array | login/exists/redirect | Pattern → icon for non-screenshot steps |
|
|
93
|
+
| `back_link` | Hash | `nil` | `{ href:, text: }` — top-bar back link |
|
|
94
|
+
| `editor_base` | String | `nil` | URL prefix for opening files in editor (e.g. `"vscode://file/path"`) |
|
|
95
|
+
|
|
96
|
+
## Compatibility
|
|
97
|
+
|
|
98
|
+
- Rails 7.1+
|
|
99
|
+
- RSpec + Capybara
|
|
100
|
+
- Turnip (optional but supported)
|
|
101
|
+
- Capybara drivers: Selenium and Playwright tested
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
MIT — see [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
module Specbook
|
|
2
|
+
class ViewerController < ApplicationController
|
|
3
|
+
before_action :authenticate_user!, if: :devise_present?
|
|
4
|
+
before_action :require_authorization
|
|
5
|
+
|
|
6
|
+
def show
|
|
7
|
+
@manifest = load_manifest
|
|
8
|
+
@traces = load_traces
|
|
9
|
+
@features = load_features
|
|
10
|
+
@specbook_js_config = build_js_config
|
|
11
|
+
render "specbook/viewer/show", layout: false
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def screenshot
|
|
15
|
+
filename = params[:filename]
|
|
16
|
+
return head(:bad_request) unless filename.match?(/\Astep_\d{4}_\d{3}\.png\z/)
|
|
17
|
+
|
|
18
|
+
path = screenshot_dir.join(filename)
|
|
19
|
+
return head(:not_found) unless File.exist?(path)
|
|
20
|
+
|
|
21
|
+
send_file path, type: "image/png", disposition: "inline"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def trace
|
|
25
|
+
filename = params[:filename]
|
|
26
|
+
return head(:bad_request) unless filename.match?(/\A[\w\-]+\.zip\z/)
|
|
27
|
+
|
|
28
|
+
path = Specbook.config.trace_root.join(filename)
|
|
29
|
+
return head(:not_found) unless File.exist?(path)
|
|
30
|
+
|
|
31
|
+
send_file path, type: "application/zip", disposition: "attachment"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def view_trace
|
|
35
|
+
filename = params[:filename]
|
|
36
|
+
return head(:bad_request) unless filename.match?(/\A[\w\-]+\.zip\z/)
|
|
37
|
+
|
|
38
|
+
path = Specbook.config.trace_root.join(filename)
|
|
39
|
+
return head(:not_found) unless File.exist?(path)
|
|
40
|
+
|
|
41
|
+
port = Specbook.config.trace_viewer_port
|
|
42
|
+
|
|
43
|
+
# Kill any existing trace viewer on this port
|
|
44
|
+
system("lsof -ti:#{port} | xargs kill -9 2>/dev/null")
|
|
45
|
+
sleep 0.2
|
|
46
|
+
|
|
47
|
+
# Launch Playwright trace viewer on fixed port (headless — no browser open)
|
|
48
|
+
spawn(
|
|
49
|
+
"npx playwright show-trace --port #{port} --host 127.0.0.1 #{path}",
|
|
50
|
+
[:out, :err] => "/dev/null"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Wait for server to be ready
|
|
54
|
+
5.times do
|
|
55
|
+
sleep 0.3
|
|
56
|
+
break if port_open?(port)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
render json: { url: "http://127.0.0.1:#{port}" }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def build_js_config
|
|
65
|
+
rules = (Specbook.config.setup_overlay_rules || []).map do |r|
|
|
66
|
+
pattern = r[:pattern]
|
|
67
|
+
if pattern.is_a?(Regexp)
|
|
68
|
+
{ pattern: pattern.source,
|
|
69
|
+
flags: regexp_flags_to_string(pattern),
|
|
70
|
+
icon: r[:icon],
|
|
71
|
+
note: r[:note] }
|
|
72
|
+
else
|
|
73
|
+
{ pattern: pattern.to_s,
|
|
74
|
+
flags: r[:flags] || "",
|
|
75
|
+
icon: r[:icon],
|
|
76
|
+
note: r[:note] }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
{
|
|
81
|
+
actorColors: Specbook.config.actor_colors || {},
|
|
82
|
+
uiDomains: Specbook.config.ui_domains || [],
|
|
83
|
+
setupOverlayRules: rules,
|
|
84
|
+
editorBase: Specbook.config.editor_base
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def regexp_flags_to_string(regexp)
|
|
89
|
+
opts = regexp.options
|
|
90
|
+
flags = +""
|
|
91
|
+
flags << "i" if (opts & Regexp::IGNORECASE) != 0
|
|
92
|
+
flags << "m" if (opts & Regexp::MULTILINE) != 0
|
|
93
|
+
flags
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def devise_present?
|
|
97
|
+
defined?(Devise) && respond_to?(:authenticate_user!)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def require_authorization
|
|
101
|
+
allowed = if Specbook.config.authorize_with
|
|
102
|
+
Specbook.config.authorize_with.call(self)
|
|
103
|
+
else
|
|
104
|
+
Rails.env.development? || Rails.env.test?
|
|
105
|
+
end
|
|
106
|
+
redirect_to main_app.root_path unless allowed
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def screenshot_dir
|
|
110
|
+
# Use "latest" symlink, fall back to base dir for old-style flat layout
|
|
111
|
+
base = Specbook.config.screenshot_root
|
|
112
|
+
latest = base.join("latest")
|
|
113
|
+
File.exist?(latest) ? Pathname.new(File.realpath(latest)) : base
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def load_manifest
|
|
117
|
+
path = screenshot_dir.join("manifest.json")
|
|
118
|
+
return [] unless File.exist?(path)
|
|
119
|
+
|
|
120
|
+
JSON.parse(File.read(path))
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def load_features
|
|
124
|
+
# Find all .feature files referenced in the manifest
|
|
125
|
+
feature_files = @manifest.map { |e| e["file"] }.uniq.select { |f| f.end_with?(".feature") }
|
|
126
|
+
features = {}
|
|
127
|
+
feature_files.each do |rel_path|
|
|
128
|
+
path = Specbook.config.feature_root.join(rel_path)
|
|
129
|
+
next unless File.exist?(path)
|
|
130
|
+
features[rel_path] = parse_feature(File.read(path))
|
|
131
|
+
end
|
|
132
|
+
features
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def parse_feature(content)
|
|
136
|
+
result = { name: "", description: [], background: [], scenarios: [] }
|
|
137
|
+
current = nil # :description, :background, :scenario
|
|
138
|
+
|
|
139
|
+
content.each_line do |line|
|
|
140
|
+
stripped = line.rstrip
|
|
141
|
+
if stripped =~ /^\s*Feature:\s*(.+)/
|
|
142
|
+
result[:name] = $1.strip
|
|
143
|
+
current = :description
|
|
144
|
+
elsif stripped =~ /^\s*Background:/
|
|
145
|
+
current = :background
|
|
146
|
+
elsif stripped =~ /^\s*(@\S+.*)/
|
|
147
|
+
# Tag line — store for next scenario
|
|
148
|
+
@pending_tags = $1.strip
|
|
149
|
+
elsif stripped =~ /^\s*Scenario:\s*(.+)/
|
|
150
|
+
scenario = { name: $1.strip, steps: [], tags: @pending_tags }
|
|
151
|
+
@pending_tags = nil
|
|
152
|
+
result[:scenarios] << scenario
|
|
153
|
+
current = :scenario
|
|
154
|
+
elsif current == :description && stripped =~ /^\s+(.+)/
|
|
155
|
+
result[:description] << $1.strip
|
|
156
|
+
elsif current == :background && stripped =~ /^\s+(Given|And|When|Then)\s+(.+)/
|
|
157
|
+
result[:background] << { keyword: $1, text: $2.strip }
|
|
158
|
+
elsif current == :scenario && stripped =~ /^\s+(Given|And|When|Then)\s+(.+)/
|
|
159
|
+
result[:scenarios].last[:steps] << { keyword: $1, text: $2.strip }
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
@pending_tags = nil
|
|
163
|
+
result
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def load_traces
|
|
167
|
+
path = Specbook.config.trace_root.join("manifest.json")
|
|
168
|
+
return [] unless File.exist?(path)
|
|
169
|
+
|
|
170
|
+
JSON.parse(File.read(path))
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def port_open?(port)
|
|
174
|
+
Socket.tcp("127.0.0.1", port, connect_timeout: 0.3) { true }
|
|
175
|
+
rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT
|
|
176
|
+
false
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|