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 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,4 @@
1
+ module Specbook
2
+ class ApplicationController < ActionController::Base
3
+ end
4
+ end
@@ -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