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
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<div class="sidebar" id="screenshots-sidebar">
|
|
2
|
+
<div class="filter-bar" style="flex-wrap:wrap; gap:0.3rem 0.75rem;">
|
|
3
|
+
<span id="total-count" style="font-size:0.75rem; color:#94a3b8;"></span>
|
|
4
|
+
<span style="font-size:0.75rem;"><span id="pass-count" style="color:#4ade80;"></span> <span id="fail-count" style="color:#f87171;"></span></span>
|
|
5
|
+
<span style="margin-left:auto; font-size:0.75rem; display:flex; gap:0.5rem;">
|
|
6
|
+
<a href="#" id="expand-all" style="color:#94a3b8; text-decoration:none;">expand all</a>
|
|
7
|
+
<a href="#" id="collapse-all" style="color:#94a3b8; text-decoration:none;">collapse all</a>
|
|
8
|
+
</span>
|
|
9
|
+
</div>
|
|
10
|
+
<div class="filter-bar" style="gap:0.3rem; padding:0.4rem 1.25rem; border-bottom:1px solid #334155;">
|
|
11
|
+
<button class="filter-btn active" data-status-filter="all">All</button>
|
|
12
|
+
<button class="filter-btn" data-status-filter="passing" style="color:#4ade80;">Passing</button>
|
|
13
|
+
<button class="filter-btn" data-status-filter="failing" style="color:#f87171;">Failing</button>
|
|
14
|
+
<span style="color:#334155;margin:0 0.2rem;">|</span>
|
|
15
|
+
<button class="filter-btn active" data-type-filter="all">All</button>
|
|
16
|
+
<button class="filter-btn" data-type-filter="visual">š· Visual</button>
|
|
17
|
+
<button class="filter-btn" data-type-filter="code">š» Code</button>
|
|
18
|
+
</div>
|
|
19
|
+
<div style="padding:0.4rem 1.25rem; border-bottom:1px solid #334155;">
|
|
20
|
+
<input type="text" id="spec-search" placeholder="Search specs..." style="width:100%; padding:0.4rem 0.6rem; background:#0f172a; border:1px solid #475569; border-radius:4px; color:#e2e8f0; font-size:0.9rem; outline:none;" />
|
|
21
|
+
</div>
|
|
22
|
+
<div id="example-list"></div>
|
|
23
|
+
<div id="feature-list" class="feature-sidebar"></div>
|
|
24
|
+
</div>
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
<style>
|
|
2
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
3
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; height: 100vh; display: flex; flex-direction: column; overflow: hidden; font-size: 15px; }
|
|
4
|
+
|
|
5
|
+
.top-bar { background: #1e293b; border-bottom: 1px solid #334155; padding: 0.75rem 1.5rem; display: flex; align-items: center; gap: 1rem; flex-shrink: 0; }
|
|
6
|
+
.top-bar h1 { font-size: 1rem; font-weight: 600; white-space: nowrap; }
|
|
7
|
+
.top-bar a { margin-left: auto; font-size: 0.95rem; color: #94a3b8; text-decoration: none; }
|
|
8
|
+
.top-bar a:hover { color: #e2e8f0; }
|
|
9
|
+
|
|
10
|
+
.content-area { display: flex; flex: 1; overflow: hidden; }
|
|
11
|
+
.sidebar { width: 340px; background: #1e293b; border-right: 1px solid #334155; overflow-y: auto; flex-shrink: 0; }
|
|
12
|
+
.filter-bar { display: flex; gap: 0.5rem; padding: 0.75rem 1.25rem; border-bottom: 1px solid #334155; }
|
|
13
|
+
.filter-btn { padding: 0.3rem 0.6rem; border-radius: 4px; border: 1px solid #475569; background: transparent; color: #94a3b8; cursor: pointer; font-size: 1rem; }
|
|
14
|
+
.filter-btn.active { background: #334155; color: #e2e8f0; border-color: #60a5fa; }
|
|
15
|
+
|
|
16
|
+
.example-item { padding: 0.5rem 1.25rem 0.5rem 3.4rem; border-bottom: 1px solid #0f172a; cursor: pointer; font-size: 0.95rem; line-height: 1.4; transition: background 0.15s; }
|
|
17
|
+
.depth-3 .example-item { padding-left: 4.6rem; }
|
|
18
|
+
.example-item:hover { background: #334155; }
|
|
19
|
+
.example-item.active { background: #1d4ed8; }
|
|
20
|
+
.example-file { font-size: 1rem; color: #64748b; margin-top: 0.25rem; }
|
|
21
|
+
|
|
22
|
+
.group-header { padding: 0.5rem 1.25rem; border-bottom: 1px solid #334155; cursor: pointer; font-size: 0.95rem; font-weight: 600; color: #94a3b8; display: flex; align-items: center; gap: 0.5rem; transition: background 0.15s; }
|
|
23
|
+
.group-header:hover { background: #334155; color: #e2e8f0; }
|
|
24
|
+
.group-header .chevron { font-size: 0.6rem; display: inline-block; width: 0.8em; }
|
|
25
|
+
.subgroup-header .chevron { font-size: 0.6rem; display: inline-block; width: 0.8em; }
|
|
26
|
+
.group-header .count { margin-left: auto; font-size: 0.95rem; color: #475569; font-weight: 400; }
|
|
27
|
+
.group-header.has-active { color: #60a5fa; }
|
|
28
|
+
.group-body { overflow: hidden; }
|
|
29
|
+
.group-body.collapsed { display: none; }
|
|
30
|
+
.subgroup-header { padding: 0.5rem 1.25rem 0.5rem 2.2rem; cursor: pointer; font-size: 0.95rem; font-weight: 600; color: #94a3b8; display: flex; align-items: center; gap: 0.5rem; transition: background 0.15s; border-bottom: 1px solid #334155; }
|
|
31
|
+
.subgroup-header:hover { background: #334155; color: #e2e8f0; }
|
|
32
|
+
.subgroup-header .count { margin-left: auto; font-size: 0.95rem; color: #475569; font-weight: 400; }
|
|
33
|
+
.subgroup-body { overflow: hidden; }
|
|
34
|
+
.subgroup-body.collapsed { display: none; }
|
|
35
|
+
.example-item.active .example-file { color: #93c5fd; }
|
|
36
|
+
.badge { display: inline-block; font-size: 0.95rem; padding: 0.1rem 0.4rem; border-radius: 3px; margin-right: 0.4rem; font-weight: 600; }
|
|
37
|
+
.badge-happy { background: #166534; color: #4ade80; }
|
|
38
|
+
.badge-adversarial { background: #7f1d1d; color: #f87171; }
|
|
39
|
+
|
|
40
|
+
.viewer { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-height: 0; }
|
|
41
|
+
.screenshot-wrapper { flex: 1 1 0; display: flex; align-items: center; justify-content: center; background: #0f172a; overflow: hidden; position: relative; min-height: 0; }
|
|
42
|
+
.screenshot-wrapper img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 4px; box-shadow: 0 4px 20px rgba(0,0,0,0.5); }
|
|
43
|
+
|
|
44
|
+
/* Overlays container ā positioned over the image */
|
|
45
|
+
#overlays { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }
|
|
46
|
+
|
|
47
|
+
/* Highlight box for actions */
|
|
48
|
+
.overlay-box { position: absolute; border: 3px solid; border-radius: 4px; pointer-events: none; animation: pulse-highlight 1.5s ease-in-out infinite; }
|
|
49
|
+
.overlay-box.click { border-color: #f97316; background: rgba(249, 115, 22, 0.12); box-shadow: 0 0 12px rgba(249, 115, 22, 0.5); }
|
|
50
|
+
.overlay-box.fill { border-color: #f97316; background: rgba(249, 115, 22, 0.12); box-shadow: 0 0 12px rgba(249, 115, 22, 0.5); }
|
|
51
|
+
.overlay-box.select { border-color: #f97316; background: rgba(249, 115, 22, 0.12); box-shadow: 0 0 12px rgba(249, 115, 22, 0.5); }
|
|
52
|
+
.overlay-box.check { border-color: #f97316; background: rgba(249, 115, 22, 0.12); box-shadow: 0 0 12px rgba(249, 115, 22, 0.5); }
|
|
53
|
+
/* Tap circle ā semi-transparent orange dot indicating click/tap point */
|
|
54
|
+
.tap-circle { position: absolute; border-radius: 50%; background: rgba(249, 115, 22, 0.25); border: 2px solid rgba(249, 115, 22, 0.6); pointer-events: none; z-index: 4; animation: tap-pulse 1.5s ease-in-out infinite; }
|
|
55
|
+
@keyframes tap-pulse { 0%, 100% { transform: scale(0.9); opacity: 1; } 50% { transform: scale(1.1); opacity: 0.6; } }
|
|
56
|
+
.overlay-box.assert { border-color: #22c55e; background: rgba(34, 197, 94, 0.12); box-shadow: 0 0 10px rgba(34, 197, 94, 0.4); }
|
|
57
|
+
.overlay-box.assert-negative { border-color: #ef4444; background: rgba(239, 68, 68, 0.08); box-shadow: 0 0 10px rgba(239, 68, 68, 0.3); border-style: dashed; }
|
|
58
|
+
|
|
59
|
+
@keyframes pulse-highlight { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } }
|
|
60
|
+
|
|
61
|
+
/* Assertion label */
|
|
62
|
+
.overlay-label { position: absolute; font-size: 1rem; font-weight: 600; padding: 0.2rem 0.5rem; border-radius: 4px; white-space: nowrap; max-width: 300px; overflow: hidden; text-overflow: ellipsis; pointer-events: none; animation: fade-in 0.3s ease; }
|
|
63
|
+
.overlay-label.positive { background: rgba(34, 197, 94, 0.95); color: white; }
|
|
64
|
+
.overlay-label.negative { background: rgba(239, 68, 68, 0.95); color: white; }
|
|
65
|
+
|
|
66
|
+
/* Navigate arrow ā red arrow pointing at URL bar area at top of screenshot */
|
|
67
|
+
.nav-arrow { position: absolute; pointer-events: none; animation: arrow-bounce 1s ease-in-out infinite; }
|
|
68
|
+
@keyframes arrow-bounce { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-6px); } }
|
|
69
|
+
|
|
70
|
+
/* URL bar overlay */
|
|
71
|
+
.url-overlay { position: absolute; background: rgba(244, 63, 94, 0.12); border: 2px solid #f43f5e; border-radius: 6px; pointer-events: none; display: flex; align-items: center; padding: 0 0.75rem; animation: pulse-highlight 1.5s ease-in-out infinite; }
|
|
72
|
+
.url-overlay-text { color: #f43f5e; font-size: 1rem; font-weight: 700; font-family: monospace; }
|
|
73
|
+
|
|
74
|
+
/* Action badge */
|
|
75
|
+
.action-badge { position: absolute; top: 12px; left: 12px; padding: 0.4rem 0.75rem; border-radius: 6px; font-size: 1rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; pointer-events: none; backdrop-filter: blur(4px); z-index: 10; animation: fade-in 0.3s ease; }
|
|
76
|
+
.action-badge.click { background: rgba(249, 115, 22, 0.9); color: white; }
|
|
77
|
+
.action-badge.fill { background: rgba(249, 115, 22, 0.9); color: white; }
|
|
78
|
+
.action-badge.select { background: rgba(249, 115, 22, 0.9); color: white; }
|
|
79
|
+
.action-badge.check { background: rgba(249, 115, 22, 0.9); color: white; }
|
|
80
|
+
.action-badge.navigate { background: rgba(245, 158, 11, 0.9); color: #1e293b; }
|
|
81
|
+
.action-badge.assert { background: rgba(34, 197, 94, 0.9); color: white; }
|
|
82
|
+
.action-badge.confirm { background: rgba(239, 68, 68, 0.9); color: white; }
|
|
83
|
+
|
|
84
|
+
/* Cursor icon */
|
|
85
|
+
.cursor-icon { position: absolute; pointer-events: none; width: 24px; height: 24px; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.5)); animation: click-bounce 0.6s ease; z-index: 5; }
|
|
86
|
+
@keyframes click-bounce { 0% { transform: scale(1); } 30% { transform: scale(0.8); } 60% { transform: scale(1.1); } 100% { transform: scale(1); } }
|
|
87
|
+
|
|
88
|
+
@keyframes fade-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
|
|
89
|
+
|
|
90
|
+
/* Assertion list panel */
|
|
91
|
+
.assertion-list { position: absolute; bottom: 12px; right: 12px; max-width: 350px; pointer-events: none; display: flex; flex-direction: column; gap: 4px; z-index: 10; }
|
|
92
|
+
.assertion-item { padding: 0.3rem 0.6rem; border-radius: 4px; font-size: 1rem; font-weight: 500; backdrop-filter: blur(4px); animation: fade-in 0.3s ease; }
|
|
93
|
+
.assertion-item.positive { background: rgba(34, 197, 94, 0.9); color: white; }
|
|
94
|
+
.assertion-item.negative { background: rgba(239, 68, 68, 0.9); color: white; }
|
|
95
|
+
|
|
96
|
+
.step-info { background: #1e293b; border-top: 1px solid #334155; padding: 0.5rem 1rem; flex-shrink: 0; min-height: 5.5em; max-height: 18em; overflow-y: auto; }
|
|
97
|
+
.step-list { list-style: none; margin: 0; padding: 0; }
|
|
98
|
+
.step-list li { padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 1rem; color: #64748b; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; }
|
|
99
|
+
.step-list li:hover { background: #334155; }
|
|
100
|
+
.step-list li.active { background: #1d4ed8; color: #e2e8f0; font-weight: 500; }
|
|
101
|
+
.step-list li .step-icon { display: inline-block; width: 1.2em; text-align: center; margin-right: 0.3em; }
|
|
102
|
+
body.selecting .step-list li, body.selecting .scenario-block, body.selecting .example-item { cursor: text; }
|
|
103
|
+
|
|
104
|
+
.controls { background: #1e293b; border-top: 1px solid #334155; padding: 0.5rem 1.5rem; display: flex; align-items: center; gap: 1rem; flex-shrink: 0; }
|
|
105
|
+
.controls button { background: #334155; border: 1px solid #475569; color: #e2e8f0; padding: 0.5rem 1.25rem; border-radius: 6px; cursor: pointer; font-size: 1rem; transition: all 0.15s; }
|
|
106
|
+
.controls button:hover:not(:disabled) { background: #475569; }
|
|
107
|
+
.controls button:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
108
|
+
.controls button.primary { background: #2563eb; border-color: #3b82f6; }
|
|
109
|
+
.controls button.primary:hover:not(:disabled) { background: #1d4ed8; }
|
|
110
|
+
|
|
111
|
+
.progress-bar { flex: 1; height: 6px; background: #334155; border-radius: 3px; overflow: hidden; cursor: pointer; }
|
|
112
|
+
.progress-fill { height: 100%; background: #3b82f6; transition: width 0.2s; border-radius: 3px; }
|
|
113
|
+
|
|
114
|
+
.speed-control { display: flex; align-items: center; gap: 0.5rem; font-size: 0.95rem; color: #94a3b8; }
|
|
115
|
+
.speed-control select { background: #334155; border: 1px solid #475569; color: #e2e8f0; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.95rem; }
|
|
116
|
+
|
|
117
|
+
.autoplay-indicator { font-size: 1rem; color: #4ade80; display: none; }
|
|
118
|
+
.autoplay-indicator.active { display: inline; }
|
|
119
|
+
|
|
120
|
+
/* Setup step overlay ā shown when no screenshot exists */
|
|
121
|
+
.setup-overlay { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 1.5rem; color: #94a3b8; text-align: center; padding: 2rem; }
|
|
122
|
+
.setup-overlay .setup-icon { font-size: 3rem; opacity: 0.5; }
|
|
123
|
+
.setup-overlay .setup-keyword { color: #60a5fa; font-weight: 700; font-size: 1rem; text-transform: uppercase; letter-spacing: 0.1em; }
|
|
124
|
+
.setup-overlay .setup-text { color: #e2e8f0; font-size: 1.2rem; font-weight: 500; max-width: 500px; line-height: 1.6; }
|
|
125
|
+
.setup-overlay .setup-text .quoted { color: #4ade80; }
|
|
126
|
+
.setup-overlay .setup-note { font-size: 0.95rem; color: #64748b; font-style: italic; }
|
|
127
|
+
|
|
128
|
+
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 1rem; color: #64748b; }
|
|
129
|
+
.empty-state h2 { font-size: 1.3rem; color: #94a3b8; }
|
|
130
|
+
.empty-state p { max-width: 500px; text-align: center; line-height: 1.6; }
|
|
131
|
+
.empty-state code { background: #334155; padding: 0.5rem 1rem; border-radius: 6px; font-size: 1rem; color: #e2e8f0; }
|
|
132
|
+
|
|
133
|
+
/* Full gherkin view ā for service/model specs with no screenshots */
|
|
134
|
+
.gherkin-card { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; padding: 2rem; }
|
|
135
|
+
.gherkin-card .gherkin-title { font-size: 1rem; color: #64748b; text-transform: uppercase; letter-spacing: 0.15em; margin-bottom: 0.5rem; }
|
|
136
|
+
.gherkin-card .gherkin-scenario-name { font-size: 1.3rem; color: #e2e8f0; font-weight: 600; margin-bottom: 1.5rem; text-align: center; max-width: 600px; }
|
|
137
|
+
.gherkin-card .gherkin-steps-list { list-style: none; padding: 0; margin: 0; max-width: 600px; width: 100%; }
|
|
138
|
+
.gherkin-card .gherkin-steps-list li { padding: 0.4rem 0.75rem; font-size: 1rem; color: #cbd5e1; line-height: 1.5; border-radius: 4px; }
|
|
139
|
+
.gherkin-card .gherkin-steps-list li .kw { color: #60a5fa; font-weight: 600; display: inline-block; min-width: 3.5em; }
|
|
140
|
+
.gherkin-card .gherkin-steps-list li .kw-then { color: #4ade80; }
|
|
141
|
+
.gherkin-card .gherkin-steps-list li .quoted { color: #4ade80; }
|
|
142
|
+
.gherkin-card .gherkin-steps-list .bg-label { color: #64748b; font-size: 1rem; font-style: italic; padding: 0.5rem 0.75rem 0.15rem; }
|
|
143
|
+
.gherkin-card .gherkin-status { margin-top: 1.5rem; font-size: 1rem; font-weight: 500; }
|
|
144
|
+
.gherkin-card .gherkin-status.passed { color: #4ade80; }
|
|
145
|
+
.gherkin-card .gherkin-status.failed { color: #f87171; }
|
|
146
|
+
|
|
147
|
+
/* Step source code block */
|
|
148
|
+
.step-source { margin: 0.2rem 0 0.4rem 3.5em; background: #1e293b; border: 1px solid #334155; border-radius: 4px; padding: 0.4rem 0.6rem; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 1rem; color: #94a3b8; white-space: pre-wrap; line-height: 1.5; max-height: 200px; overflow-y: auto; }
|
|
149
|
+
.step-source .src-file { color: #64748b; font-size: 0.95rem; display: block; margin-bottom: 0.3rem; text-decoration: none; pointer-events: auto; }
|
|
150
|
+
.step-source .src-file:hover { color: #94a3b8; text-decoration: underline; }
|
|
151
|
+
.step-source .src-keyword { color: #c084fc; }
|
|
152
|
+
.step-source .src-string { color: #4ade80; }
|
|
153
|
+
.step-source .src-comment { color: #475569; font-style: italic; }
|
|
154
|
+
.step-source .src-method { color: #60a5fa; }
|
|
155
|
+
|
|
156
|
+
/* Gherkin feature sidebar */
|
|
157
|
+
.feature-sidebar { padding: 0; font-family: 'SF Mono', 'Menlo', 'Monaco', monospace; font-size: 1rem; line-height: 1.6; }
|
|
158
|
+
.feature-header { padding: 1rem 1.25rem 0.5rem; border-bottom: 1px solid #334155; }
|
|
159
|
+
.feature-keyword { color: #c084fc; font-weight: 600; }
|
|
160
|
+
.feature-name { color: #e2e8f0; font-weight: 600; font-size: 1rem; }
|
|
161
|
+
.feature-desc { color: #94a3b8; font-style: italic; padding: 0.25rem 0 0 1rem; font-size: 1rem; }
|
|
162
|
+
.feature-background { padding: 0.75rem 1.25rem; border-bottom: 1px solid #334155; background: #1a2332; }
|
|
163
|
+
.feature-bg-label { color: #94a3b8; font-weight: 600; font-size: 1rem; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.25rem; }
|
|
164
|
+
.gherkin-step { padding: 0.1rem 0 0.1rem 1rem; color: #cbd5e1; }
|
|
165
|
+
.gherkin-step .kw { color: #60a5fa; font-weight: 600; display: inline-block; min-width: 3.5em; }
|
|
166
|
+
.gherkin-step .quoted { color: #4ade80; }
|
|
167
|
+
.scenario-block { border-bottom: 1px solid #334155; cursor: pointer; transition: background 0.15s; }
|
|
168
|
+
.scenario-block:hover { background: #253348; }
|
|
169
|
+
.scenario-block.active { background: #1e3a5f; border-left: 3px solid #3b82f6; }
|
|
170
|
+
.scenario-header { padding: 0.6rem 1.25rem 0.3rem; display: flex; flex-direction: column; gap: 0.1rem; }
|
|
171
|
+
.scenario-tag { color: #f87171; font-size: 1rem; }
|
|
172
|
+
.scenario-name { color: #e2e8f0; font-weight: 500; }
|
|
173
|
+
.scenario-status { margin-left: auto; font-size: 1rem; }
|
|
174
|
+
.scenario-steps { padding: 0 1.25rem 0.6rem; }
|
|
175
|
+
.scenario-steps .gherkin-step { font-size: 1rem; opacity: 0.7; }
|
|
176
|
+
.scenario-block.active .scenario-steps .gherkin-step { opacity: 1; }
|
|
177
|
+
.scenario-block.active .gherkin-step.current-step { background: #1d4ed8; border-radius: 3px; margin: 0 -0.5rem; padding-left: 1.5rem; }
|
|
178
|
+
</style>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<div class="top-bar">
|
|
2
|
+
<h1>Specbook</h1>
|
|
3
|
+
<% if @traces.present? && @manifest.present? %>
|
|
4
|
+
<div style="display:flex; gap:0.5rem; margin-left:1rem;">
|
|
5
|
+
<button onclick="switchTab('screenshots')" id="tab-screenshots" style="padding:0.3rem 0.8rem; border-radius:4px; border:1px solid #475569; background:#334155; color:#e2e8f0; cursor:pointer; font-size:0.8rem;">Screenshots (<%= @manifest.size %>)</button>
|
|
6
|
+
<button onclick="switchTab('traces')" id="tab-traces" style="padding:0.3rem 0.8rem; border-radius:4px; border:1px solid #475569; background:transparent; color:#94a3b8; cursor:pointer; font-size:0.8rem;">Traces (<%= @traces.size %>)</button>
|
|
7
|
+
</div>
|
|
8
|
+
<% end %>
|
|
9
|
+
<span class="autoplay-indicator" id="autoplay-indicator">▶ Playing</span>
|
|
10
|
+
<% if Specbook.config.back_link %>
|
|
11
|
+
<a href="<%= Specbook.config.back_link[:href] %>"><%= Specbook.config.back_link[:text] || "ā Back" %></a>
|
|
12
|
+
<% end %>
|
|
13
|
+
</div>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<div class="sidebar" id="traces-sidebar" style="display:none;">
|
|
2
|
+
<div style="padding:0.75rem 1.25rem; border-bottom:1px solid #334155;">
|
|
3
|
+
<input type="text" id="trace-search" placeholder="Filter specs..." style="width:100%; background:#0f172a; border:1px solid #475569; color:#e2e8f0; padding:0.4rem 0.6rem; border-radius:4px; font-size:0.8rem;">
|
|
4
|
+
</div>
|
|
5
|
+
<div id="trace-list" style="overflow-y:auto;">
|
|
6
|
+
<% @traces.each_with_index do |t, i| %>
|
|
7
|
+
<div class="example-item trace-item" data-index="<%= i %>" data-name="<%= t["name"].downcase %>" onclick="selectTrace(<%= i %>)">
|
|
8
|
+
<span class="badge badge-<%= t["type"] %>"><%= t["type"] %></span>
|
|
9
|
+
<span style="font-size:0.7rem; padding:0.1rem 0.3rem; border-radius:3px; background:<%= t["status"] == "passed" ? "#166534" : "#7f1d1d" %>; color:<%= t["status"] == "passed" ? "#4ade80" : "#f87171" %>; margin-right:0.3rem;"><%= t["status"] == "passed" ? "ā" : "ā" %></span>
|
|
10
|
+
<%= t["name"] %>
|
|
11
|
+
<div class="example-file"><%= t["file"] %>:<%= t["line"] %></div>
|
|
12
|
+
</div>
|
|
13
|
+
<% end %>
|
|
14
|
+
</div>
|
|
15
|
+
</div>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<div class="viewer">
|
|
2
|
+
<div id="traces-viewer" style="flex:1; display:none; align-items:center; justify-content:center;">
|
|
3
|
+
<div id="trace-detail" style="text-align:center; color:#64748b;">
|
|
4
|
+
<h2 style="font-size:1.2rem; color:#94a3b8; margin-bottom:0.5rem;">Click a spec to view its trace</h2>
|
|
5
|
+
</div>
|
|
6
|
+
</div>
|
|
7
|
+
|
|
8
|
+
<div id="screenshots-viewer" style="flex:1; display:flex; flex-direction:column;">
|
|
9
|
+
<% if @manifest.empty? && @traces.empty? %>
|
|
10
|
+
<div class="empty-state">
|
|
11
|
+
<h2>No recordings yet</h2>
|
|
12
|
+
<p>Run specs with recording enabled:</p>
|
|
13
|
+
<code>RECORD_SPECS=1 CI=1 bundle exec rspec spec/system/</code>
|
|
14
|
+
<p style="margin-top:0.5rem;">Playwright traces give you DOM snapshots, network activity, and action timeline.</p>
|
|
15
|
+
<p>Then refresh this page.</p>
|
|
16
|
+
</div>
|
|
17
|
+
<% elsif @manifest.present? %>
|
|
18
|
+
<div class="screenshot-wrapper" id="screenshot-wrapper">
|
|
19
|
+
<img id="screenshot-img" src="" alt="Spec screenshot">
|
|
20
|
+
<div id="overlays"></div>
|
|
21
|
+
</div>
|
|
22
|
+
<div class="step-info" id="step-info">
|
|
23
|
+
<ol class="step-list" id="step-list"></ol>
|
|
24
|
+
</div>
|
|
25
|
+
<div class="controls">
|
|
26
|
+
<button id="btn-prev" title="Previous (ā)">◀ Prev</button>
|
|
27
|
+
<button id="btn-next" class="primary" title="Next (ā)">Next ▶</button>
|
|
28
|
+
<div class="progress-bar" id="progress-bar">
|
|
29
|
+
<div class="progress-fill" id="progress-fill"></div>
|
|
30
|
+
</div>
|
|
31
|
+
<button id="btn-play" title="Play/Pause (Space)">▶ Play</button>
|
|
32
|
+
<div class="speed-control">
|
|
33
|
+
<label>Speed:</label>
|
|
34
|
+
<select id="speed-select">
|
|
35
|
+
<option value="3000">Slow (3s)</option>
|
|
36
|
+
<option value="1500" selected>Normal (1.5s)</option>
|
|
37
|
+
<option value="800">Fast (0.8s)</option>
|
|
38
|
+
<option value="400">Very Fast</option>
|
|
39
|
+
</select>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
<% end %>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<%= csrf_meta_tags %>
|
|
7
|
+
<title>Specbook</title>
|
|
8
|
+
<%= render "styles" %>
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
|
|
12
|
+
<%= render "top_bar" %>
|
|
13
|
+
|
|
14
|
+
<div class="content-area">
|
|
15
|
+
<%= render "traces_sidebar" %>
|
|
16
|
+
<%= render "screenshots_sidebar" %>
|
|
17
|
+
<%= render "viewer_panel" %>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<script>
|
|
21
|
+
window.Specbook = window.Specbook || {};
|
|
22
|
+
window.Specbook.config = <%= raw @specbook_js_config.to_json %>;
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<%= render "app_js" %>
|
|
26
|
+
</body>
|
|
27
|
+
</html>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
Specbook::Engine.routes.draw do
|
|
2
|
+
root to: "viewer#show"
|
|
3
|
+
get "screenshots/:filename", to: "viewer#screenshot", as: :screenshot, constraints: { filename: /[^\/]+/ }
|
|
4
|
+
get "traces/:filename", to: "viewer#trace", as: :trace, constraints: { filename: /[^\/]+/ }
|
|
5
|
+
post "traces/:filename/view", to: "viewer#view_trace", as: :view_trace, constraints: { filename: /[^\/]+/ }
|
|
6
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Installs Specbook: writes config/initializers/specbook.rb and prints
|
|
3
|
+
further setup steps.
|
|
4
|
+
|
|
5
|
+
Example:
|
|
6
|
+
rails generate specbook:install
|
|
7
|
+
|
|
8
|
+
This will create:
|
|
9
|
+
config/initializers/specbook.rb
|
|
10
|
+
|
|
11
|
+
You will then need to:
|
|
12
|
+
|
|
13
|
+
1. Mount the engine in config/routes.rb:
|
|
14
|
+
|
|
15
|
+
mount Specbook::Engine => "/specs"
|
|
16
|
+
|
|
17
|
+
2. Add to spec/rails_helper.rb (or test_helper):
|
|
18
|
+
|
|
19
|
+
require "specbook/rspec"
|
|
20
|
+
|
|
21
|
+
3. Record specs with:
|
|
22
|
+
|
|
23
|
+
RECORD_SPECS=1 bundle exec rspec
|
|
24
|
+
|
|
25
|
+
4. Visit /specs in your dev server.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
|
|
3
|
+
module Specbook
|
|
4
|
+
module Generators
|
|
5
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
6
|
+
source_root File.expand_path("templates", __dir__)
|
|
7
|
+
|
|
8
|
+
desc "Install Specbook: copies an initializer and prints next steps."
|
|
9
|
+
|
|
10
|
+
def copy_initializer
|
|
11
|
+
template "specbook.rb", "config/initializers/specbook.rb"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def show_readme
|
|
15
|
+
readme "README" if behavior == :invoke
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
|
|
2
|
+
==================================================================
|
|
3
|
+
Specbook installed!
|
|
4
|
+
|
|
5
|
+
Next steps:
|
|
6
|
+
|
|
7
|
+
1. Mount the engine in config/routes.rb:
|
|
8
|
+
|
|
9
|
+
mount Specbook::Engine => "/specs"
|
|
10
|
+
|
|
11
|
+
2. Add to spec/rails_helper.rb:
|
|
12
|
+
|
|
13
|
+
require "specbook/rspec"
|
|
14
|
+
|
|
15
|
+
3. Record specs with:
|
|
16
|
+
|
|
17
|
+
RECORD_SPECS=1 bundle exec rspec
|
|
18
|
+
|
|
19
|
+
4. Visit /specs in your dev server.
|
|
20
|
+
|
|
21
|
+
Configure Specbook in config/initializers/specbook.rb.
|
|
22
|
+
==================================================================
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Specbook configuration.
|
|
2
|
+
#
|
|
3
|
+
# Specbook is a viewer + recorder for Rails system specs. See README for full docs.
|
|
4
|
+
# All options below are optional ā defaults are sensible.
|
|
5
|
+
Specbook.configure do |config|
|
|
6
|
+
# Authorization. Default: dev/test environments only. Provide a lambda
|
|
7
|
+
# taking the controller; return truthy to allow access.
|
|
8
|
+
#
|
|
9
|
+
# config.authorize_with = ->(controller) { controller.current_user&.admin? }
|
|
10
|
+
|
|
11
|
+
# A "back" link in the top bar. Default: no link.
|
|
12
|
+
#
|
|
13
|
+
# config.back_link = { href: "/admin", text: "ā Back to admin" }
|
|
14
|
+
|
|
15
|
+
# URL prefix for opening files in your editor. Default: nil (file:line is
|
|
16
|
+
# rendered as plain text).
|
|
17
|
+
#
|
|
18
|
+
# config.editor_base = "vscode://file#{Rails.root}"
|
|
19
|
+
|
|
20
|
+
# Per-actor colors for visual differentiation in the viewer.
|
|
21
|
+
#
|
|
22
|
+
# config.actor_colors = {
|
|
23
|
+
# "Alice" => "#3b82f6",
|
|
24
|
+
# "Bob" => "#f59e0b"
|
|
25
|
+
# }
|
|
26
|
+
|
|
27
|
+
# Top-level directory groupings for the sidebar.
|
|
28
|
+
#
|
|
29
|
+
# config.ui_domains = %w[admin public mobile]
|
|
30
|
+
|
|
31
|
+
# Pattern ā icon mapping for non-screenshot Gherkin steps. Defaults provide
|
|
32
|
+
# generic login/setup/redirect rules; add domain-specific rules here.
|
|
33
|
+
#
|
|
34
|
+
# config.setup_overlay_rules = [
|
|
35
|
+
# { pattern: /booking exists/i, icon: "š
", note: "Test setup" }
|
|
36
|
+
# ] + Specbook::Configuration.new.setup_overlay_rules
|
|
37
|
+
|
|
38
|
+
# Where artifacts are written. Defaults below match Rails.root paths.
|
|
39
|
+
#
|
|
40
|
+
# config.screenshot_root = Rails.root.join("tmp/spec_screenshots")
|
|
41
|
+
# config.trace_root = Rails.root.join("tmp/spec_traces")
|
|
42
|
+
# config.feature_root = Rails.root
|
|
43
|
+
# config.max_runs = 20
|
|
44
|
+
# config.trace_viewer_port = 9322
|
|
45
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module Specbook
|
|
2
|
+
class Configuration
|
|
3
|
+
attr_accessor :max_runs,
|
|
4
|
+
:trace_viewer_port,
|
|
5
|
+
:actor_colors,
|
|
6
|
+
:ui_domains,
|
|
7
|
+
:setup_overlay_rules,
|
|
8
|
+
:back_link,
|
|
9
|
+
:editor_base,
|
|
10
|
+
:authorize_with
|
|
11
|
+
|
|
12
|
+
attr_writer :screenshot_root, :trace_root, :feature_root
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@max_runs = 20
|
|
16
|
+
@trace_viewer_port = 9322
|
|
17
|
+
@actor_colors = {}
|
|
18
|
+
@ui_domains = []
|
|
19
|
+
@setup_overlay_rules = [
|
|
20
|
+
{ pattern: /\b(?:logs in|signs in|signed in)\b/i, icon: "š", note: "Authentication ā switching user session" },
|
|
21
|
+
{ pattern: /\bexists?\b/i, icon: "š§", note: "Test setup ā creating test data" },
|
|
22
|
+
{ pattern: /\bredirected to\b/i, icon: "ā
", note: "Assertion passed ā verified redirect" }
|
|
23
|
+
]
|
|
24
|
+
@back_link = nil
|
|
25
|
+
@editor_base = nil
|
|
26
|
+
@authorize_with = nil
|
|
27
|
+
@screenshot_root = nil
|
|
28
|
+
@trace_root = nil
|
|
29
|
+
@feature_root = nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def screenshot_root
|
|
33
|
+
@screenshot_root || rails_root.join("tmp/spec_screenshots")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def trace_root
|
|
37
|
+
@trace_root || rails_root.join("tmp/spec_traces")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def feature_root
|
|
41
|
+
@feature_root || rails_root
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def rails_root
|
|
47
|
+
defined?(Rails) ? Rails.root : Pathname.new(Dir.pwd)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Playwright trace recorder for spec presentations.
|
|
4
|
+
# Enable with: RECORD_TRACES=1 CI=1 bundle exec rspec spec/system/
|
|
5
|
+
#
|
|
6
|
+
# Captures Playwright traces (DOM snapshots, network, console, action timeline)
|
|
7
|
+
# for each system spec. View with: npx playwright show-trace tmp/spec_traces/<name>.zip
|
|
8
|
+
|
|
9
|
+
if ENV["RECORD_TRACES"]
|
|
10
|
+
require "fileutils"
|
|
11
|
+
|
|
12
|
+
module Specbook
|
|
13
|
+
module Recorders
|
|
14
|
+
module PlaywrightTrace
|
|
15
|
+
TRACE_DIR = Specbook.config.trace_root
|
|
16
|
+
mattr_accessor :manifest
|
|
17
|
+
self.manifest = []
|
|
18
|
+
|
|
19
|
+
def self.slug_for(example)
|
|
20
|
+
example.full_description
|
|
21
|
+
.gsub(/[^a-zA-Z0-9]+/, "_")
|
|
22
|
+
.gsub(/\A_|_\z/, "")
|
|
23
|
+
.truncate(120, omission: "")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.write_manifest!
|
|
27
|
+
FileUtils.mkdir_p(TRACE_DIR)
|
|
28
|
+
File.write(
|
|
29
|
+
TRACE_DIR.join("manifest.json"),
|
|
30
|
+
JSON.pretty_generate(manifest)
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Patch visit to start tracing on first navigation
|
|
36
|
+
module TraceVisitPatch
|
|
37
|
+
def visit(path, **)
|
|
38
|
+
if !@_trace_started && page.driver.respond_to?(:with_playwright_page)
|
|
39
|
+
page.driver.with_playwright_page do |pw_page|
|
|
40
|
+
pw_page.context.tracing.start(screenshots: true, snapshots: true, sources: false)
|
|
41
|
+
end
|
|
42
|
+
@_trace_started = true
|
|
43
|
+
end
|
|
44
|
+
super
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
RSpec.configure do |config|
|
|
51
|
+
config.include Specbook::Recorders::TraceVisitPatch, type: :system
|
|
52
|
+
|
|
53
|
+
config.before(:each, type: :system) do
|
|
54
|
+
@_trace_started = false
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
config.after(:each, type: :system) do |example|
|
|
58
|
+
next unless @_trace_started
|
|
59
|
+
next unless page.driver.respond_to?(:with_playwright_page)
|
|
60
|
+
|
|
61
|
+
slug = Specbook::Recorders::PlaywrightTrace.slug_for(example)
|
|
62
|
+
filename = "#{slug}.zip"
|
|
63
|
+
filepath = Specbook::Recorders::PlaywrightTrace::TRACE_DIR.join(filename)
|
|
64
|
+
FileUtils.mkdir_p(Specbook::Recorders::PlaywrightTrace::TRACE_DIR)
|
|
65
|
+
|
|
66
|
+
begin
|
|
67
|
+
page.driver.with_playwright_page do |pw_page|
|
|
68
|
+
pw_page.context.tracing.stop(path: filepath.to_s)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
Specbook::Recorders::PlaywrightTrace.manifest << {
|
|
72
|
+
name: example.full_description,
|
|
73
|
+
file: example.metadata[:file_path].sub("./", ""),
|
|
74
|
+
line: example.metadata[:line_number],
|
|
75
|
+
status: example.exception ? "failed" : "passed",
|
|
76
|
+
type: example.metadata[:adversarial] ? "adversarial" : "happy",
|
|
77
|
+
trace: filename
|
|
78
|
+
}
|
|
79
|
+
rescue => e
|
|
80
|
+
Rails.logger.warn("[Specbook::Recorders::PlaywrightTrace] stop failed: #{e.message}")
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
config.after(:suite) do
|
|
85
|
+
Specbook::Recorders::PlaywrightTrace.write_manifest!
|
|
86
|
+
count = Specbook::Recorders::PlaywrightTrace.manifest.size
|
|
87
|
+
puts "\nš¬ Traces saved to tmp/spec_traces/ (#{count} examples recorded)"
|
|
88
|
+
puts " View with: npx playwright show-trace tmp/spec_traces/<name>.zip" if count > 0
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|