rubyn-code 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/LICENSE +21 -0
- data/README.md +620 -0
- data/db/migrations/000_create_schema_migrations.sql +4 -0
- data/db/migrations/001_create_sessions.sql +16 -0
- data/db/migrations/002_create_messages.sql +16 -0
- data/db/migrations/003_create_tasks.sql +17 -0
- data/db/migrations/004_create_task_dependencies.sql +8 -0
- data/db/migrations/005_create_memories.sql +44 -0
- data/db/migrations/006_create_cost_records.sql +16 -0
- data/db/migrations/007_create_hooks.sql +12 -0
- data/db/migrations/008_create_skills_cache.sql +8 -0
- data/db/migrations/009_create_teams.sql +27 -0
- data/db/migrations/010_create_instincts.sql +15 -0
- data/exe/rubyn-code +6 -0
- data/lib/rubyn_code/agent/conversation.rb +193 -0
- data/lib/rubyn_code/agent/loop.rb +517 -0
- data/lib/rubyn_code/agent/loop_detector.rb +78 -0
- data/lib/rubyn_code/auth/oauth.rb +174 -0
- data/lib/rubyn_code/auth/server.rb +126 -0
- data/lib/rubyn_code/auth/token_store.rb +153 -0
- data/lib/rubyn_code/autonomous/daemon.rb +233 -0
- data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
- data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
- data/lib/rubyn_code/background/job.rb +19 -0
- data/lib/rubyn_code/background/notifier.rb +44 -0
- data/lib/rubyn_code/background/worker.rb +146 -0
- data/lib/rubyn_code/cli/app.rb +118 -0
- data/lib/rubyn_code/cli/input_handler.rb +79 -0
- data/lib/rubyn_code/cli/renderer.rb +205 -0
- data/lib/rubyn_code/cli/repl.rb +519 -0
- data/lib/rubyn_code/cli/spinner.rb +100 -0
- data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
- data/lib/rubyn_code/config/defaults.rb +43 -0
- data/lib/rubyn_code/config/project_config.rb +120 -0
- data/lib/rubyn_code/config/settings.rb +127 -0
- data/lib/rubyn_code/context/auto_compact.rb +81 -0
- data/lib/rubyn_code/context/compactor.rb +89 -0
- data/lib/rubyn_code/context/manager.rb +91 -0
- data/lib/rubyn_code/context/manual_compact.rb +87 -0
- data/lib/rubyn_code/context/micro_compact.rb +135 -0
- data/lib/rubyn_code/db/connection.rb +176 -0
- data/lib/rubyn_code/db/migrator.rb +146 -0
- data/lib/rubyn_code/db/schema.rb +106 -0
- data/lib/rubyn_code/hooks/built_in.rb +124 -0
- data/lib/rubyn_code/hooks/registry.rb +99 -0
- data/lib/rubyn_code/hooks/runner.rb +88 -0
- data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
- data/lib/rubyn_code/learning/extractor.rb +191 -0
- data/lib/rubyn_code/learning/injector.rb +138 -0
- data/lib/rubyn_code/learning/instinct.rb +172 -0
- data/lib/rubyn_code/llm/client.rb +218 -0
- data/lib/rubyn_code/llm/message_builder.rb +116 -0
- data/lib/rubyn_code/llm/streaming.rb +203 -0
- data/lib/rubyn_code/mcp/client.rb +139 -0
- data/lib/rubyn_code/mcp/config.rb +83 -0
- data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
- data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
- data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
- data/lib/rubyn_code/memory/models.rb +62 -0
- data/lib/rubyn_code/memory/search.rb +181 -0
- data/lib/rubyn_code/memory/session_persistence.rb +194 -0
- data/lib/rubyn_code/memory/store.rb +199 -0
- data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
- data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
- data/lib/rubyn_code/observability/models.rb +29 -0
- data/lib/rubyn_code/observability/token_counter.rb +42 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
- data/lib/rubyn_code/output/diff_renderer.rb +212 -0
- data/lib/rubyn_code/output/formatter.rb +120 -0
- data/lib/rubyn_code/permissions/deny_list.rb +49 -0
- data/lib/rubyn_code/permissions/policy.rb +59 -0
- data/lib/rubyn_code/permissions/prompter.rb +80 -0
- data/lib/rubyn_code/permissions/tier.rb +22 -0
- data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
- data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
- data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
- data/lib/rubyn_code/skills/catalog.rb +70 -0
- data/lib/rubyn_code/skills/document.rb +80 -0
- data/lib/rubyn_code/skills/loader.rb +57 -0
- data/lib/rubyn_code/sub_agents/runner.rb +168 -0
- data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
- data/lib/rubyn_code/tasks/dag.rb +208 -0
- data/lib/rubyn_code/tasks/manager.rb +212 -0
- data/lib/rubyn_code/tasks/models.rb +31 -0
- data/lib/rubyn_code/teams/mailbox.rb +128 -0
- data/lib/rubyn_code/teams/manager.rb +175 -0
- data/lib/rubyn_code/teams/teammate.rb +38 -0
- data/lib/rubyn_code/tools/background_run.rb +41 -0
- data/lib/rubyn_code/tools/base.rb +84 -0
- data/lib/rubyn_code/tools/bash.rb +81 -0
- data/lib/rubyn_code/tools/bundle_add.rb +53 -0
- data/lib/rubyn_code/tools/bundle_install.rb +41 -0
- data/lib/rubyn_code/tools/compact.rb +57 -0
- data/lib/rubyn_code/tools/db_migrate.rb +52 -0
- data/lib/rubyn_code/tools/edit_file.rb +49 -0
- data/lib/rubyn_code/tools/executor.rb +62 -0
- data/lib/rubyn_code/tools/git_commit.rb +97 -0
- data/lib/rubyn_code/tools/git_diff.rb +61 -0
- data/lib/rubyn_code/tools/git_log.rb +59 -0
- data/lib/rubyn_code/tools/git_status.rb +59 -0
- data/lib/rubyn_code/tools/glob.rb +44 -0
- data/lib/rubyn_code/tools/grep.rb +81 -0
- data/lib/rubyn_code/tools/load_skill.rb +41 -0
- data/lib/rubyn_code/tools/memory_search.rb +77 -0
- data/lib/rubyn_code/tools/memory_write.rb +52 -0
- data/lib/rubyn_code/tools/rails_generate.rb +54 -0
- data/lib/rubyn_code/tools/read_file.rb +38 -0
- data/lib/rubyn_code/tools/read_inbox.rb +64 -0
- data/lib/rubyn_code/tools/registry.rb +48 -0
- data/lib/rubyn_code/tools/review_pr.rb +145 -0
- data/lib/rubyn_code/tools/run_specs.rb +75 -0
- data/lib/rubyn_code/tools/schema.rb +59 -0
- data/lib/rubyn_code/tools/send_message.rb +53 -0
- data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
- data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
- data/lib/rubyn_code/tools/task.rb +148 -0
- data/lib/rubyn_code/tools/web_fetch.rb +108 -0
- data/lib/rubyn_code/tools/web_search.rb +196 -0
- data/lib/rubyn_code/tools/write_file.rb +30 -0
- data/lib/rubyn_code/version.rb +5 -0
- data/lib/rubyn_code.rb +203 -0
- data/skills/code_quality/fits_in_your_head.md +189 -0
- data/skills/code_quality/naming_conventions.md +213 -0
- data/skills/code_quality/null_object.md +205 -0
- data/skills/code_quality/technical_debt.md +135 -0
- data/skills/code_quality/value_objects.md +216 -0
- data/skills/code_quality/yagni.md +176 -0
- data/skills/design_patterns/adapter.md +191 -0
- data/skills/design_patterns/bridge_memento_visitor.md +254 -0
- data/skills/design_patterns/builder.md +158 -0
- data/skills/design_patterns/command.md +126 -0
- data/skills/design_patterns/composite.md +147 -0
- data/skills/design_patterns/decorator.md +204 -0
- data/skills/design_patterns/facade.md +133 -0
- data/skills/design_patterns/factory_method.md +169 -0
- data/skills/design_patterns/iterator.md +116 -0
- data/skills/design_patterns/mediator.md +133 -0
- data/skills/design_patterns/observer.md +177 -0
- data/skills/design_patterns/proxy.md +140 -0
- data/skills/design_patterns/singleton.md +124 -0
- data/skills/design_patterns/state.md +207 -0
- data/skills/design_patterns/strategy.md +127 -0
- data/skills/design_patterns/template_method.md +173 -0
- data/skills/gems/devise.md +365 -0
- data/skills/gems/dry_rb.md +186 -0
- data/skills/gems/factory_bot.md +268 -0
- data/skills/gems/faraday.md +263 -0
- data/skills/gems/graphql_ruby.md +514 -0
- data/skills/gems/pundit.md +446 -0
- data/skills/gems/redis.md +219 -0
- data/skills/gems/rubocop.md +257 -0
- data/skills/gems/sidekiq.md +360 -0
- data/skills/gems/stripe.md +224 -0
- data/skills/minitest/assertions.md +185 -0
- data/skills/minitest/fixtures.md +238 -0
- data/skills/minitest/integration_tests.md +210 -0
- data/skills/minitest/mailers_and_jobs.md +218 -0
- data/skills/minitest/mocking_stubbing.md +202 -0
- data/skills/minitest/service_tests_and_performance.md +246 -0
- data/skills/minitest/structure_and_conventions.md +169 -0
- data/skills/minitest/system_tests.md +237 -0
- data/skills/rails/action_cable.md +160 -0
- data/skills/rails/active_record_basics.md +174 -0
- data/skills/rails/active_storage.md +242 -0
- data/skills/rails/api_design.md +212 -0
- data/skills/rails/associations.md +182 -0
- data/skills/rails/background_jobs.md +212 -0
- data/skills/rails/caching.md +158 -0
- data/skills/rails/callbacks.md +135 -0
- data/skills/rails/concerns_controllers.md +218 -0
- data/skills/rails/concerns_models.md +280 -0
- data/skills/rails/controllers.md +190 -0
- data/skills/rails/engines.md +201 -0
- data/skills/rails/form_objects.md +168 -0
- data/skills/rails/hotwire.md +229 -0
- data/skills/rails/internationalization.md +192 -0
- data/skills/rails/logging.md +198 -0
- data/skills/rails/mailers.md +180 -0
- data/skills/rails/migrations.md +200 -0
- data/skills/rails/multitenancy.md +207 -0
- data/skills/rails/n_plus_one.md +151 -0
- data/skills/rails/presenters.md +244 -0
- data/skills/rails/query_objects.md +177 -0
- data/skills/rails/routing.md +194 -0
- data/skills/rails/scopes.md +187 -0
- data/skills/rails/security.md +233 -0
- data/skills/rails/serializers.md +243 -0
- data/skills/rails/service_objects.md +184 -0
- data/skills/rails/testing_strategy.md +258 -0
- data/skills/rails/validations.md +206 -0
- data/skills/refactoring/code_smells.md +251 -0
- data/skills/refactoring/command_query_separation.md +166 -0
- data/skills/refactoring/encapsulate_collection.md +125 -0
- data/skills/refactoring/extract_class.md +138 -0
- data/skills/refactoring/extract_method.md +185 -0
- data/skills/refactoring/replace_conditional.md +211 -0
- data/skills/refactoring/value_objects.md +246 -0
- data/skills/rspec/build_stubbed.md +199 -0
- data/skills/rspec/factory_design.md +206 -0
- data/skills/rspec/let_vs_let_bang.md +161 -0
- data/skills/rspec/mocking_stubbing.md +209 -0
- data/skills/rspec/request_specs.md +212 -0
- data/skills/rspec/service_specs.md +262 -0
- data/skills/rspec/shared_examples.md +244 -0
- data/skills/rspec/system_specs.md +286 -0
- data/skills/rspec/test_performance.md +215 -0
- data/skills/ruby/blocks_procs_lambdas.md +204 -0
- data/skills/ruby/classes.md +155 -0
- data/skills/ruby/concurrency.md +194 -0
- data/skills/ruby/data_struct_openstruct.md +158 -0
- data/skills/ruby/debugging_profiling.md +204 -0
- data/skills/ruby/enumerable_patterns.md +168 -0
- data/skills/ruby/exception_handling.md +199 -0
- data/skills/ruby/file_io.md +217 -0
- data/skills/ruby/hashes.md +195 -0
- data/skills/ruby/metaprogramming.md +170 -0
- data/skills/ruby/modules.md +210 -0
- data/skills/ruby/pattern_matching.md +177 -0
- data/skills/ruby/regular_expressions.md +166 -0
- data/skills/ruby/result_objects.md +200 -0
- data/skills/ruby/strings.md +177 -0
- data/skills/ruby_project/bundler_dependencies.md +181 -0
- data/skills/ruby_project/cli_tools.md +224 -0
- data/skills/ruby_project/rake_tasks.md +146 -0
- data/skills/ruby_project/structure.md +261 -0
- data/skills/sinatra/application_structure.md +241 -0
- data/skills/sinatra/middleware_and_deployment.md +221 -0
- data/skills/sinatra/testing.md +233 -0
- data/skills/solid/dependency_inversion.md +195 -0
- data/skills/solid/interface_segregation.md +237 -0
- data/skills/solid/liskov_substitution.md +263 -0
- data/skills/solid/open_closed.md +212 -0
- data/skills/solid/single_responsibility.md +183 -0
- metadata +397 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# RSpec: Shared Examples
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Use `shared_examples` to DRY up specs that verify the same behavior across multiple contexts. Use `shared_context` for shared setup. Keep shared examples focused on one behavior. Pass parameters via `let`, block, or arguments.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# spec/support/shared_examples/authenticated_endpoint.rb
|
|
9
|
+
RSpec.shared_examples "an authenticated endpoint" do
|
|
10
|
+
context "without API key" do
|
|
11
|
+
it "returns 401" do
|
|
12
|
+
make_request(api_key: nil)
|
|
13
|
+
expect(response).to have_http_status(:unauthorized)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
context "with revoked API key" do
|
|
18
|
+
let(:api_key) { create(:api_key, :revoked) }
|
|
19
|
+
|
|
20
|
+
it "returns 401" do
|
|
21
|
+
make_request(api_key: api_key.raw_key)
|
|
22
|
+
expect(response).to have_http_status(:unauthorized)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
context "with expired API key" do
|
|
27
|
+
let(:api_key) { create(:api_key, :expired) }
|
|
28
|
+
|
|
29
|
+
it "returns 401" do
|
|
30
|
+
make_request(api_key: api_key.raw_key)
|
|
31
|
+
expect(response).to have_http_status(:unauthorized)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
# spec/support/shared_examples/credit_deducting_endpoint.rb
|
|
39
|
+
RSpec.shared_examples "a credit-deducting endpoint" do
|
|
40
|
+
it "deducts credits on success" do
|
|
41
|
+
expect { make_request }.to change { user.credit_ledger_entries.count }.by(1)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it "records the interaction" do
|
|
45
|
+
expect { make_request }.to change(Interaction, :count).by(1)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
context "with insufficient credits" do
|
|
49
|
+
before do
|
|
50
|
+
allow_any_instance_of(Credits::BalanceChecker).to receive(:sufficient?).and_return(false)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it "returns 402" do
|
|
54
|
+
make_request
|
|
55
|
+
expect(response).to have_http_status(:payment_required)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it "does not call Claude" do
|
|
59
|
+
make_request
|
|
60
|
+
expect(Ai::ClaudeClient).not_to have_received(:call)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
# spec/support/shared_examples/project_scoped_endpoint.rb
|
|
68
|
+
RSpec.shared_examples "a project-scoped endpoint" do
|
|
69
|
+
context "when user is not a member of the project" do
|
|
70
|
+
let(:other_project) { create(:project) }
|
|
71
|
+
|
|
72
|
+
it "returns 403" do
|
|
73
|
+
make_request(project_id: other_project.id)
|
|
74
|
+
expect(response).to have_http_status(:forbidden)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
context "when project does not exist" do
|
|
79
|
+
it "returns 404" do
|
|
80
|
+
make_request(project_id: 999999)
|
|
81
|
+
expect(response).to have_http_status(:not_found)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Using shared examples in specs:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
# spec/requests/api/v1/ai/refactor_spec.rb
|
|
91
|
+
RSpec.describe "POST /api/v1/ai/refactor", type: :request do
|
|
92
|
+
let(:user) { create(:user, :pro) }
|
|
93
|
+
let(:project) { create(:project) }
|
|
94
|
+
let(:membership) { create(:project_membership, user: user, project: project) }
|
|
95
|
+
|
|
96
|
+
def make_request(api_key: user.api_keys.first.raw_key, project_id: project.id)
|
|
97
|
+
post "/api/v1/ai/refactor",
|
|
98
|
+
params: { file_path: "app/controllers/orders_controller.rb", file_content: "...", project_id: project_id },
|
|
99
|
+
headers: { "Authorization" => "Bearer #{api_key}" }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
before { membership }
|
|
103
|
+
|
|
104
|
+
it_behaves_like "an authenticated endpoint"
|
|
105
|
+
it_behaves_like "a credit-deducting endpoint"
|
|
106
|
+
it_behaves_like "a project-scoped endpoint"
|
|
107
|
+
|
|
108
|
+
# Endpoint-specific tests
|
|
109
|
+
it "returns a streaming response" do
|
|
110
|
+
make_request
|
|
111
|
+
expect(response).to have_http_status(:ok)
|
|
112
|
+
expect(response.content_type).to include("text/event-stream")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Shared context for common setup:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
# spec/support/shared_contexts/with_stubbed_claude.rb
|
|
121
|
+
RSpec.shared_context "with stubbed Claude" do
|
|
122
|
+
let(:claude_response) { "Here is the refactored code..." }
|
|
123
|
+
|
|
124
|
+
before do
|
|
125
|
+
allow(Ai::ClaudeClient).to receive(:call).and_return(
|
|
126
|
+
OpenStruct.new(
|
|
127
|
+
content: claude_response,
|
|
128
|
+
input_tokens: 500,
|
|
129
|
+
output_tokens: 200,
|
|
130
|
+
cache_read_tokens: 400,
|
|
131
|
+
cache_write_tokens: 0
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Usage
|
|
138
|
+
RSpec.describe Orders::CreateService do
|
|
139
|
+
include_context "with stubbed Claude"
|
|
140
|
+
|
|
141
|
+
it "uses the stubbed response" do
|
|
142
|
+
# Claude is already stubbed
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Why This Is Good
|
|
148
|
+
|
|
149
|
+
- **DRY without obscuring.** Auth checks are the same for every endpoint. Writing them once in a shared example and including them with `it_behaves_like` is clearer than copy-pasting the same 20 lines into 30 spec files.
|
|
150
|
+
- **Consistent coverage.** When you add a new auth check (e.g., "suspended account returns 403"), you add it to the shared example once. Every endpoint that includes it gets the new test automatically.
|
|
151
|
+
- **Contract enforcement.** `it_behaves_like "a credit-deducting endpoint"` acts as a contract: every AI endpoint must deduct credits. If a new endpoint doesn't pass the shared example, it's missing credit deduction logic.
|
|
152
|
+
- **Readable spec files.** The endpoint spec reads like a checklist: it's authenticated, it deducts credits, it's project-scoped, and here are the endpoint-specific behaviors.
|
|
153
|
+
- **Shared contexts reduce boilerplate.** Stubbing Claude the same way in 20 spec files is noisy. A shared context does it once and every spec file includes it by name.
|
|
154
|
+
|
|
155
|
+
## Anti-Pattern
|
|
156
|
+
|
|
157
|
+
Shared examples that are too broad, too abstract, or tightly coupled to implementation:
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
# BAD: Shared example that does everything
|
|
161
|
+
RSpec.shared_examples "a standard API endpoint" do |method, path|
|
|
162
|
+
it "requires auth" do
|
|
163
|
+
send(method, path)
|
|
164
|
+
expect(response).to have_http_status(:unauthorized)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
it "requires project membership" do
|
|
168
|
+
send(method, path, headers: auth_headers)
|
|
169
|
+
expect(response).to have_http_status(:forbidden)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
it "deducts credits" do
|
|
173
|
+
expect { send(method, path, params: valid_params, headers: auth_headers) }
|
|
174
|
+
.to change(CreditLedger, :count)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
it "records the interaction" do
|
|
178
|
+
expect { send(method, path, params: valid_params, headers: auth_headers) }
|
|
179
|
+
.to change(Interaction, :count)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
it "returns success" do
|
|
183
|
+
send(method, path, params: valid_params, headers: auth_headers)
|
|
184
|
+
expect(response).to have_http_status(:ok)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Usage becomes cryptic
|
|
189
|
+
it_behaves_like "a standard API endpoint", :post, "/api/v1/ai/refactor"
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
```ruby
|
|
193
|
+
# BAD: Shared example in a deeply nested file nobody can find
|
|
194
|
+
# spec/support/shared_examples/concerns/models/trackable_behavior_for_auditable_records.rb
|
|
195
|
+
RSpec.shared_examples "trackable auditable behavior" do
|
|
196
|
+
# 50 lines of tests that are impossible to discover
|
|
197
|
+
end
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Why This Is Bad
|
|
201
|
+
|
|
202
|
+
- **God shared examples test too many things at once.** When `"a standard API endpoint"` fails, you don't know if it's an auth issue, a credit issue, or a response format issue. Smaller shared examples give focused feedback.
|
|
203
|
+
- **Parameterized shared examples are hard to read.** `it_behaves_like "a standard API endpoint", :post, "/api/v1/ai/refactor"` hides what's being tested. The reader has to open the shared example file to understand what 5 tests are running.
|
|
204
|
+
- **Over-abstracted names.** `"trackable auditable behavior"` doesn't communicate what it tests. `"an authenticated endpoint"` does. Name shared examples by the behavior they verify.
|
|
205
|
+
- **Hidden in deep directories.** If shared examples are buried in `spec/support/shared_examples/concerns/models/`, nobody will find or use them. Keep them in `spec/support/shared_examples/` at one level deep.
|
|
206
|
+
|
|
207
|
+
## When To Apply
|
|
208
|
+
|
|
209
|
+
- **Identical behavior across multiple specs.** Authentication, authorization, credit deduction, pagination, error handling — if 10+ specs verify the same behavior, extract it.
|
|
210
|
+
- **Contract testing.** "Every AI endpoint must deduct credits" is a contract. A shared example enforces it.
|
|
211
|
+
- **Setup that's the same across a describe group.** `shared_context` for stubbing external services, setting up test data, or configuring the test environment.
|
|
212
|
+
|
|
213
|
+
## When NOT To Apply
|
|
214
|
+
|
|
215
|
+
- **Behavior specific to one endpoint.** If only the refactor endpoint has a specific behavior, test it inline. Don't create a shared example for one consumer.
|
|
216
|
+
- **When the shared example needs more than 2 parameters.** If you're passing 4 arguments to configure the shared example, it's too abstract. Write the tests inline.
|
|
217
|
+
- **When the setup is simple.** A 2-line `before` block doesn't need a shared context. Just write the 2 lines.
|
|
218
|
+
|
|
219
|
+
## Edge Cases
|
|
220
|
+
|
|
221
|
+
**Shared examples that need different setup per includer:**
|
|
222
|
+
Use `let` overrides. The shared example references `let(:user)` — each including spec defines its own `user`:
|
|
223
|
+
|
|
224
|
+
```ruby
|
|
225
|
+
RSpec.shared_examples "a credit-deducting endpoint" do
|
|
226
|
+
it "deducts from the user's balance" do
|
|
227
|
+
expect { make_request }.to change { user.reload.credit_balance }
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Spec A
|
|
232
|
+
let(:user) { create(:user, :pro) }
|
|
233
|
+
it_behaves_like "a credit-deducting endpoint"
|
|
234
|
+
|
|
235
|
+
# Spec B — different user setup, same shared example
|
|
236
|
+
let(:user) { create(:user, :free) }
|
|
237
|
+
it_behaves_like "a credit-deducting endpoint"
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
**`it_behaves_like` vs `include_examples`:**
|
|
241
|
+
`it_behaves_like` creates a nested context (its own describe block). `include_examples` runs the examples in the current context. Use `it_behaves_like` when you want isolation. Use `include_examples` when the examples need access to the current context's `let` variables.
|
|
242
|
+
|
|
243
|
+
**Shared examples across spec types:**
|
|
244
|
+
An authenticated endpoint shared example works for both request specs and API specs. Keep them generic enough to work across contexts, using `make_request` as the interface contract.
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
# RSpec: System Specs (Capybara)
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
System specs drive a real browser to test complete user journeys — clicking, filling forms, seeing results. They're the most expensive specs but provide the highest confidence that the feature works end-to-end. Write them for critical paths, not every edge case.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# spec/system/order_checkout_spec.rb
|
|
9
|
+
require "rails_helper"
|
|
10
|
+
|
|
11
|
+
RSpec.describe "Order checkout", type: :system do
|
|
12
|
+
let(:user) { create(:user) }
|
|
13
|
+
let!(:product) { create(:product, name: "Widget", price: 25_00, stock: 10) }
|
|
14
|
+
|
|
15
|
+
before do
|
|
16
|
+
driven_by(:selenium_chrome_headless)
|
|
17
|
+
sign_in user
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it "completes a full checkout flow" do
|
|
21
|
+
# Browse products
|
|
22
|
+
visit products_path
|
|
23
|
+
expect(page).to have_content("Widget")
|
|
24
|
+
expect(page).to have_content("$25.00")
|
|
25
|
+
|
|
26
|
+
# Add to cart
|
|
27
|
+
within "#product_#{product.id}" do
|
|
28
|
+
click_button "Add to Cart"
|
|
29
|
+
end
|
|
30
|
+
expect(page).to have_content("Added to cart")
|
|
31
|
+
|
|
32
|
+
# View cart and proceed
|
|
33
|
+
visit cart_path
|
|
34
|
+
expect(page).to have_content("Widget")
|
|
35
|
+
fill_in "Quantity", with: "2"
|
|
36
|
+
click_button "Update"
|
|
37
|
+
expect(page).to have_content("$50.00")
|
|
38
|
+
|
|
39
|
+
# Checkout
|
|
40
|
+
click_link "Checkout"
|
|
41
|
+
fill_in "Shipping address", with: "123 Main St, Austin, TX 78701"
|
|
42
|
+
click_button "Place Order"
|
|
43
|
+
|
|
44
|
+
# Confirmation
|
|
45
|
+
expect(page).to have_content("Order placed")
|
|
46
|
+
expect(page).to have_content("ORD-")
|
|
47
|
+
expect(page).to have_content("$50.00")
|
|
48
|
+
expect(page).to have_content("123 Main St")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it "shows validation errors for incomplete checkout" do
|
|
52
|
+
visit new_order_path
|
|
53
|
+
click_button "Place Order"
|
|
54
|
+
|
|
55
|
+
expect(page).to have_content("Shipping address can't be blank")
|
|
56
|
+
expect(page).to have_selector(".field_with_errors")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it "prevents checkout when product is out of stock" do
|
|
60
|
+
product.update!(stock: 0)
|
|
61
|
+
visit product_path(product)
|
|
62
|
+
|
|
63
|
+
expect(page).to have_content("Out of Stock")
|
|
64
|
+
expect(page).not_to have_button("Add to Cart")
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Testing JavaScript Interactions
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
RSpec.describe "Order management", type: :system do
|
|
73
|
+
before do
|
|
74
|
+
driven_by(:selenium_chrome_headless)
|
|
75
|
+
sign_in create(:user, :admin)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it "filters orders with live search" do
|
|
79
|
+
create(:order, reference: "ORD-001", status: :pending)
|
|
80
|
+
create(:order, reference: "ORD-002", status: :shipped)
|
|
81
|
+
|
|
82
|
+
visit admin_orders_path
|
|
83
|
+
|
|
84
|
+
# Turbo Frame search — updates without page reload
|
|
85
|
+
fill_in "Search", with: "ORD-001"
|
|
86
|
+
|
|
87
|
+
# Capybara auto-waits for the DOM to update
|
|
88
|
+
expect(page).to have_content("ORD-001")
|
|
89
|
+
expect(page).not_to have_content("ORD-002")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it "toggles order details inline" do
|
|
93
|
+
order = create(:order, :with_line_items)
|
|
94
|
+
visit admin_orders_path
|
|
95
|
+
|
|
96
|
+
# Click to expand details (Stimulus controller)
|
|
97
|
+
within "#order_#{order.id}" do
|
|
98
|
+
click_button "Details"
|
|
99
|
+
expect(page).to have_content(order.line_items.first.product.name)
|
|
100
|
+
|
|
101
|
+
# Click again to collapse
|
|
102
|
+
click_button "Details"
|
|
103
|
+
expect(page).not_to have_content(order.line_items.first.product.name)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it "handles confirmation dialogs" do
|
|
108
|
+
order = create(:order, :pending)
|
|
109
|
+
visit admin_order_path(order)
|
|
110
|
+
|
|
111
|
+
accept_confirm "Are you sure you want to cancel this order?" do
|
|
112
|
+
click_button "Cancel Order"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
expect(page).to have_content("Order cancelled")
|
|
116
|
+
expect(page).to have_content("Cancelled")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it "handles dismiss of confirmation" do
|
|
120
|
+
order = create(:order, :pending)
|
|
121
|
+
visit admin_order_path(order)
|
|
122
|
+
|
|
123
|
+
dismiss_confirm do
|
|
124
|
+
click_button "Cancel Order"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
expect(page).to have_content("Pending") # Status unchanged
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Testing Authentication Flows
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
RSpec.describe "Authentication", type: :system do
|
|
136
|
+
before { driven_by(:selenium_chrome_headless) }
|
|
137
|
+
|
|
138
|
+
it "signs in with valid credentials" do
|
|
139
|
+
user = create(:user, email: "alice@example.com", password: "securepassword")
|
|
140
|
+
|
|
141
|
+
visit new_user_session_path
|
|
142
|
+
fill_in "Email", with: "alice@example.com"
|
|
143
|
+
fill_in "Password", with: "securepassword"
|
|
144
|
+
click_button "Sign In"
|
|
145
|
+
|
|
146
|
+
expect(page).to have_content("Signed in successfully")
|
|
147
|
+
expect(page).to have_content("alice@example.com")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it "rejects invalid credentials" do
|
|
151
|
+
create(:user, email: "alice@example.com", password: "securepassword")
|
|
152
|
+
|
|
153
|
+
visit new_user_session_path
|
|
154
|
+
fill_in "Email", with: "alice@example.com"
|
|
155
|
+
fill_in "Password", with: "wrongpassword"
|
|
156
|
+
click_button "Sign In"
|
|
157
|
+
|
|
158
|
+
expect(page).to have_content("Invalid Email or password")
|
|
159
|
+
expect(page).to have_current_path(new_user_session_path)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it "redirects unauthenticated users to sign in" do
|
|
163
|
+
visit orders_path
|
|
164
|
+
|
|
165
|
+
expect(page).to have_current_path(new_user_session_path)
|
|
166
|
+
expect(page).to have_content("You need to sign in")
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### Setup and Configuration
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
# spec/rails_helper.rb (relevant additions)
|
|
175
|
+
RSpec.configure do |config|
|
|
176
|
+
# System test configuration
|
|
177
|
+
config.before(:each, type: :system) do
|
|
178
|
+
driven_by :selenium_chrome_headless
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Use visible Chrome for debugging (override in specific tests)
|
|
182
|
+
# driven_by :selenium, using: :chrome, screen_size: [1400, 900]
|
|
183
|
+
end
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
```ruby
|
|
187
|
+
# spec/support/system_helpers.rb
|
|
188
|
+
module SystemHelpers
|
|
189
|
+
def sign_in(user)
|
|
190
|
+
visit new_user_session_path
|
|
191
|
+
fill_in "Email", with: user.email
|
|
192
|
+
fill_in "Password", with: "password" # Assumes factory default
|
|
193
|
+
click_button "Sign In"
|
|
194
|
+
expect(page).to have_content("Signed in")
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def sign_out
|
|
198
|
+
click_link "Sign Out"
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
RSpec.configure do |config|
|
|
203
|
+
config.include SystemHelpers, type: :system
|
|
204
|
+
end
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Capybara Matchers Cheat Sheet
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
# Content assertions
|
|
211
|
+
expect(page).to have_content("text") # Anywhere on page
|
|
212
|
+
expect(page).not_to have_content("text")
|
|
213
|
+
expect(page).to have_selector("h1", text: "Orders") # CSS + text
|
|
214
|
+
expect(page).to have_selector(".badge", count: 3) # Exact count
|
|
215
|
+
|
|
216
|
+
# Form assertions
|
|
217
|
+
expect(page).to have_field("Email", with: "alice@example.com")
|
|
218
|
+
expect(page).to have_checked_field("Remember me")
|
|
219
|
+
expect(page).to have_select("Status", selected: "Pending")
|
|
220
|
+
expect(page).to have_button("Submit")
|
|
221
|
+
expect(page).to have_link("Edit")
|
|
222
|
+
|
|
223
|
+
# Scoping
|
|
224
|
+
within "#order-form" do
|
|
225
|
+
fill_in "Address", with: "123 Main"
|
|
226
|
+
click_button "Save"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
within_table "orders" do
|
|
230
|
+
expect(page).to have_content("ORD-001")
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Waiting (Capybara auto-waits by default, up to Capybara.default_max_wait_time)
|
|
234
|
+
expect(page).to have_content("Loading complete") # Waits automatically
|
|
235
|
+
expect(page).to have_selector(".result", wait: 10) # Custom wait time
|
|
236
|
+
expect(page).to have_no_content("Loading...", wait: 5) # Wait for disappearance
|
|
237
|
+
|
|
238
|
+
# Navigation
|
|
239
|
+
expect(page).to have_current_path(orders_path)
|
|
240
|
+
expect(page).to have_current_path(/orders\/\d+/) # Regex match
|
|
241
|
+
|
|
242
|
+
# JavaScript
|
|
243
|
+
page.execute_script("window.scrollTo(0, document.body.scrollHeight)")
|
|
244
|
+
accept_alert { click_link "Dangerous" }
|
|
245
|
+
accept_confirm { click_button "Delete" }
|
|
246
|
+
dismiss_confirm { click_button "Delete" }
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
## Why This Is Good
|
|
250
|
+
|
|
251
|
+
- **Tests what users actually experience.** Click a button, fill a form, see a result. If this test passes, the feature works.
|
|
252
|
+
- **Catches integration bugs.** Broken JavaScript, missing CSRF tokens, Turbo Frame issues, CSS hiding elements — system specs catch what unit tests miss.
|
|
253
|
+
- **Capybara auto-waits.** `have_content` waits for text to appear (for async rendering, Turbo updates). No manual `sleep` calls for most cases.
|
|
254
|
+
- **`driven_by :selenium_chrome_headless`** runs fast without a visible browser window.
|
|
255
|
+
|
|
256
|
+
## Anti-Pattern
|
|
257
|
+
|
|
258
|
+
```ruby
|
|
259
|
+
# BAD: Testing model logic in a system spec
|
|
260
|
+
it "validates email format" do
|
|
261
|
+
visit signup_path
|
|
262
|
+
fill_in "Email", with: "invalid"
|
|
263
|
+
click_button "Sign Up"
|
|
264
|
+
expect(page).to have_content("Email is invalid")
|
|
265
|
+
end
|
|
266
|
+
# This takes 2-3 seconds. A model spec does it in 2ms:
|
|
267
|
+
# expect(User.new(email: "invalid")).not_to be_valid
|
|
268
|
+
|
|
269
|
+
# BAD: Testing every edge case in system specs
|
|
270
|
+
# 15 system specs for form validation ← TOO MANY
|
|
271
|
+
# 1 system spec for happy path + 14 model specs for validations ← RIGHT
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
## When To Apply
|
|
275
|
+
|
|
276
|
+
- **Critical user journeys.** Sign up, sign in, checkout, key CRUD flows — 1 system spec per journey.
|
|
277
|
+
- **JavaScript-dependent features.** Turbo Frames, Stimulus controllers, live search, modals, drag-and-drop.
|
|
278
|
+
- **Smoke tests.** One test per major page to verify it loads without errors.
|
|
279
|
+
- **Keep count low.** Target 10-30 system specs for a typical Rails app. Not 200.
|
|
280
|
+
|
|
281
|
+
## When NOT To Apply
|
|
282
|
+
|
|
283
|
+
- **Validation logic.** Test in model specs (2ms vs 2s).
|
|
284
|
+
- **API endpoints.** Test with request specs — no browser needed.
|
|
285
|
+
- **Service object logic.** Test in service specs.
|
|
286
|
+
- **Every permutation.** System specs for happy paths. Unit tests for edge cases.
|