data_porter 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.
Files changed (159) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/commands/blog-status.md +10 -0
  3. data/.claude/commands/blog.md +109 -0
  4. data/.claude/commands/task-done.md +27 -0
  5. data/.claude/commands/tm/add-dependency.md +58 -0
  6. data/.claude/commands/tm/add-subtask.md +79 -0
  7. data/.claude/commands/tm/add-task.md +81 -0
  8. data/.claude/commands/tm/analyze-complexity.md +124 -0
  9. data/.claude/commands/tm/analyze-project.md +100 -0
  10. data/.claude/commands/tm/auto-implement-tasks.md +100 -0
  11. data/.claude/commands/tm/command-pipeline.md +80 -0
  12. data/.claude/commands/tm/complexity-report.md +120 -0
  13. data/.claude/commands/tm/convert-task-to-subtask.md +74 -0
  14. data/.claude/commands/tm/expand-all-tasks.md +52 -0
  15. data/.claude/commands/tm/expand-task.md +52 -0
  16. data/.claude/commands/tm/fix-dependencies.md +82 -0
  17. data/.claude/commands/tm/help.md +101 -0
  18. data/.claude/commands/tm/init-project-quick.md +49 -0
  19. data/.claude/commands/tm/init-project.md +53 -0
  20. data/.claude/commands/tm/install-taskmaster.md +118 -0
  21. data/.claude/commands/tm/learn.md +106 -0
  22. data/.claude/commands/tm/list-tasks-by-status.md +42 -0
  23. data/.claude/commands/tm/list-tasks-with-subtasks.md +30 -0
  24. data/.claude/commands/tm/list-tasks.md +46 -0
  25. data/.claude/commands/tm/next-task.md +69 -0
  26. data/.claude/commands/tm/parse-prd-with-research.md +51 -0
  27. data/.claude/commands/tm/parse-prd.md +52 -0
  28. data/.claude/commands/tm/project-status.md +67 -0
  29. data/.claude/commands/tm/quick-install-taskmaster.md +23 -0
  30. data/.claude/commands/tm/remove-all-subtasks.md +94 -0
  31. data/.claude/commands/tm/remove-dependency.md +65 -0
  32. data/.claude/commands/tm/remove-subtask.md +87 -0
  33. data/.claude/commands/tm/remove-subtasks.md +89 -0
  34. data/.claude/commands/tm/remove-task.md +110 -0
  35. data/.claude/commands/tm/setup-models.md +52 -0
  36. data/.claude/commands/tm/show-task.md +85 -0
  37. data/.claude/commands/tm/smart-workflow.md +58 -0
  38. data/.claude/commands/tm/sync-readme.md +120 -0
  39. data/.claude/commands/tm/tm-main.md +147 -0
  40. data/.claude/commands/tm/to-cancelled.md +58 -0
  41. data/.claude/commands/tm/to-deferred.md +50 -0
  42. data/.claude/commands/tm/to-done.md +47 -0
  43. data/.claude/commands/tm/to-in-progress.md +39 -0
  44. data/.claude/commands/tm/to-pending.md +35 -0
  45. data/.claude/commands/tm/to-review.md +43 -0
  46. data/.claude/commands/tm/update-single-task.md +122 -0
  47. data/.claude/commands/tm/update-task.md +75 -0
  48. data/.claude/commands/tm/update-tasks-from-id.md +111 -0
  49. data/.claude/commands/tm/validate-dependencies.md +72 -0
  50. data/.claude/commands/tm/view-models.md +52 -0
  51. data/.env.example +12 -0
  52. data/.mcp.json +24 -0
  53. data/.taskmaster/CLAUDE.md +435 -0
  54. data/.taskmaster/config.json +44 -0
  55. data/.taskmaster/docs/prd.txt +2044 -0
  56. data/.taskmaster/state.json +6 -0
  57. data/.taskmaster/tasks/task_001.md +19 -0
  58. data/.taskmaster/tasks/task_002.md +19 -0
  59. data/.taskmaster/tasks/task_003.md +19 -0
  60. data/.taskmaster/tasks/task_004.md +19 -0
  61. data/.taskmaster/tasks/task_005.md +19 -0
  62. data/.taskmaster/tasks/task_006.md +19 -0
  63. data/.taskmaster/tasks/task_007.md +19 -0
  64. data/.taskmaster/tasks/task_008.md +19 -0
  65. data/.taskmaster/tasks/task_009.md +19 -0
  66. data/.taskmaster/tasks/task_010.md +19 -0
  67. data/.taskmaster/tasks/task_011.md +19 -0
  68. data/.taskmaster/tasks/task_012.md +19 -0
  69. data/.taskmaster/tasks/task_013.md +19 -0
  70. data/.taskmaster/tasks/task_014.md +19 -0
  71. data/.taskmaster/tasks/task_015.md +19 -0
  72. data/.taskmaster/tasks/task_016.md +19 -0
  73. data/.taskmaster/tasks/task_017.md +19 -0
  74. data/.taskmaster/tasks/task_018.md +19 -0
  75. data/.taskmaster/tasks/task_019.md +19 -0
  76. data/.taskmaster/tasks/task_020.md +19 -0
  77. data/.taskmaster/tasks/tasks.json +299 -0
  78. data/.taskmaster/templates/example_prd.txt +47 -0
  79. data/.taskmaster/templates/example_prd_rpg.txt +511 -0
  80. data/CHANGELOG.md +29 -0
  81. data/CLAUDE.md +65 -0
  82. data/CODE_OF_CONDUCT.md +10 -0
  83. data/CONTRIBUTING.md +49 -0
  84. data/LICENSE +21 -0
  85. data/README.md +463 -0
  86. data/Rakefile +12 -0
  87. data/app/assets/stylesheets/data_porter/application.css +646 -0
  88. data/app/channels/data_porter/import_channel.rb +10 -0
  89. data/app/controllers/data_porter/imports_controller.rb +68 -0
  90. data/app/javascript/data_porter/progress_controller.js +33 -0
  91. data/app/jobs/data_porter/dry_run_job.rb +12 -0
  92. data/app/jobs/data_porter/import_job.rb +12 -0
  93. data/app/jobs/data_porter/parse_job.rb +12 -0
  94. data/app/models/data_porter/data_import.rb +49 -0
  95. data/app/views/data_porter/imports/index.html.erb +142 -0
  96. data/app/views/data_porter/imports/new.html.erb +88 -0
  97. data/app/views/data_porter/imports/show.html.erb +49 -0
  98. data/config/database.yml +3 -0
  99. data/config/routes.rb +12 -0
  100. data/docs/SPEC.md +2012 -0
  101. data/docs/UI.md +32 -0
  102. data/docs/blog/001-why-build-a-data-import-engine.md +166 -0
  103. data/docs/blog/002-scaffolding-a-rails-engine.md +188 -0
  104. data/docs/blog/003-configuration-dsl.md +222 -0
  105. data/docs/blog/004-store-model-jsonb.md +237 -0
  106. data/docs/blog/005-target-dsl.md +284 -0
  107. data/docs/blog/006-parsing-csv-sources.md +300 -0
  108. data/docs/blog/007-orchestrator.md +247 -0
  109. data/docs/blog/008-actioncable-stimulus.md +376 -0
  110. data/docs/blog/009-phlex-ui-components.md +446 -0
  111. data/docs/blog/010-controllers-routing.md +374 -0
  112. data/docs/blog/011-generators.md +364 -0
  113. data/docs/blog/012-json-api-sources.md +323 -0
  114. data/docs/blog/013-testing-rails-engine.md +618 -0
  115. data/docs/blog/014-dry-run.md +307 -0
  116. data/docs/blog/015-publishing-retro.md +264 -0
  117. data/docs/blog/016-erb-view-templates.md +431 -0
  118. data/docs/blog/017-showcase-final-retro.md +220 -0
  119. data/docs/blog/BACKLOG.md +8 -0
  120. data/docs/blog/SERIES.md +154 -0
  121. data/docs/screenshots/index-with-previewing.jpg +0 -0
  122. data/docs/screenshots/index.jpg +0 -0
  123. data/docs/screenshots/modal-new-import.jpg +0 -0
  124. data/docs/screenshots/preview.jpg +0 -0
  125. data/lib/data_porter/broadcaster.rb +29 -0
  126. data/lib/data_porter/components/base.rb +10 -0
  127. data/lib/data_porter/components/failure_alert.rb +20 -0
  128. data/lib/data_porter/components/preview_table.rb +54 -0
  129. data/lib/data_porter/components/progress_bar.rb +33 -0
  130. data/lib/data_porter/components/results_summary.rb +19 -0
  131. data/lib/data_porter/components/status_badge.rb +16 -0
  132. data/lib/data_porter/components/summary_cards.rb +30 -0
  133. data/lib/data_porter/components.rb +14 -0
  134. data/lib/data_porter/configuration.rb +25 -0
  135. data/lib/data_porter/dsl/api_config.rb +25 -0
  136. data/lib/data_porter/dsl/column.rb +17 -0
  137. data/lib/data_porter/engine.rb +15 -0
  138. data/lib/data_porter/orchestrator.rb +141 -0
  139. data/lib/data_porter/record_validator.rb +32 -0
  140. data/lib/data_porter/registry.rb +33 -0
  141. data/lib/data_porter/sources/api.rb +49 -0
  142. data/lib/data_porter/sources/base.rb +35 -0
  143. data/lib/data_porter/sources/csv.rb +43 -0
  144. data/lib/data_porter/sources/json.rb +45 -0
  145. data/lib/data_porter/sources.rb +20 -0
  146. data/lib/data_porter/store_models/error.rb +13 -0
  147. data/lib/data_porter/store_models/import_record.rb +52 -0
  148. data/lib/data_porter/store_models/report.rb +21 -0
  149. data/lib/data_porter/target.rb +89 -0
  150. data/lib/data_porter/type_validator.rb +46 -0
  151. data/lib/data_porter/version.rb +5 -0
  152. data/lib/data_porter.rb +32 -0
  153. data/lib/generators/data_porter/install/install_generator.rb +33 -0
  154. data/lib/generators/data_porter/install/templates/create_data_porter_imports.rb.erb +21 -0
  155. data/lib/generators/data_porter/install/templates/initializer.rb +30 -0
  156. data/lib/generators/data_porter/target/target_generator.rb +44 -0
  157. data/lib/generators/data_porter/target/templates/target.rb.tt +20 -0
  158. data/sig/data_porter.rbs +4 -0
  159. metadata +274 -0
@@ -0,0 +1,376 @@
1
+ ---
2
+ title: "Building DataPorter #8 -- Real-time Progress with ActionCable & Stimulus"
3
+ series: "Building DataPorter - A Data Import Engine for Rails"
4
+ part: 8
5
+ tags: [ruby, rails, rails-engine, gem-development, actioncable, stimulus, real-time, websockets]
6
+ published: false
7
+ ---
8
+
9
+ # Real-time Progress with ActionCable & Stimulus
10
+
11
+ > How to push live progress updates from a background import job to the browser using a Broadcaster service, an ActionCable channel, and a Stimulus controller -- so users never stare at a dead spinner again.
12
+
13
+ ## Context
14
+
15
+ This is part 8 of the series where we build **DataPorter**, a mountable Rails engine for data import workflows. In [part 7](#), we built the Orchestrator -- the class that coordinates the parse-then-import workflow, transitions state, handles per-record errors, and delegates to ActiveJob for background processing.
16
+
17
+ The Orchestrator works, but it works silently. The user clicks "Import", the job goes into the queue, and the page sits there. There is no indication of whether the import is 10% done, 90% done, or failed entirely. The only way to find out is to refresh the page and check the status column. For a 50,000-row CSV that takes two minutes, that is a terrible experience.
18
+
19
+ In this article, we build the real-time progress layer: a server-side Broadcaster that pushes updates over ActionCable, a channel that routes those updates to the right browser tab, and a Stimulus controller that animates a progress bar and auto-reloads when the import finishes.
20
+
21
+ ## The problem
22
+
23
+ Background jobs are invisible by default. ActiveJob processes work in a separate process (or even a separate server), and the browser has no built-in way to know when a job completes or how far along it is. The standard workarounds are polling (the browser asks "are we done yet?" every few seconds) or server-sent events. Polling works but wastes requests and introduces latency equal to half the polling interval on average. SSE is one-directional and requires holding an HTTP connection open, which complicates deployments behind load balancers.
24
+
25
+ Rails ships with ActionCable, a WebSocket framework that integrates with the same authentication and session infrastructure the rest of the app uses. Since DataPorter is a Rails engine, it can assume ActionCable is available. The challenge is designing the broadcasting layer so it stays decoupled from the Orchestrator, uses a channel naming scheme that does not collide with the host app, and provides a clean Stimulus integration that works without the host developer writing any JavaScript.
26
+
27
+ ## What we're building
28
+
29
+ Here is the flow from server to browser:
30
+
31
+ ```
32
+ Orchestrator#import!
33
+ |
34
+ |-- for each record:
35
+ | Broadcaster#progress(current, total)
36
+ | --> ActionCable.server.broadcast("data_porter/imports/42", { status: :processing, percentage: 65, ... })
37
+ | --> ImportChannel streams to subscriber
38
+ | --> Stimulus progress_controller updates the bar
39
+ |
40
+ |-- on success:
41
+ | Broadcaster#success
42
+ | --> { status: :success }
43
+ | --> Stimulus reloads the page
44
+ |
45
+ |-- on failure:
46
+ Broadcaster#failure(message)
47
+ --> { status: :failure, error: "..." }
48
+ --> Stimulus reloads the page
49
+ ```
50
+
51
+ Three objects, three layers. The Broadcaster knows how to format messages. The ImportChannel knows how to route them. The Stimulus controller knows how to render them. None of them knows about the others' internals.
52
+
53
+ ## Implementation
54
+
55
+ ### Step 1 -- The Broadcaster service
56
+
57
+ The Broadcaster is a plain Ruby object that wraps `ActionCable.server.broadcast` with import-specific semantics:
58
+
59
+ ```ruby
60
+ # lib/data_porter/broadcaster.rb
61
+ module DataPorter
62
+ class Broadcaster
63
+ def initialize(import_id)
64
+ prefix = DataPorter.configuration.cable_channel_prefix
65
+ @channel = "#{prefix}/imports/#{import_id}"
66
+ end
67
+
68
+ def progress(current, total)
69
+ percentage = ((current.to_f / total) * 100).round
70
+ broadcast(status: :processing, percentage: percentage, current: current, total: total)
71
+ end
72
+
73
+ def success
74
+ broadcast(status: :success)
75
+ end
76
+
77
+ def failure(message)
78
+ broadcast(status: :failure, error: message)
79
+ end
80
+
81
+ private
82
+
83
+ def broadcast(message)
84
+ ActionCable.server.broadcast(@channel, message)
85
+ end
86
+ end
87
+ end
88
+ ```
89
+
90
+ The constructor builds the channel name from the configured prefix and the import ID. This is the only place the naming convention lives -- the channel and the Stimulus controller both derive from it, but neither constructs it independently. The three public methods correspond to the three states the browser cares about: work is in progress, work succeeded, or work failed. The `progress` method computes the percentage server-side so the client does not need to do arithmetic.
91
+
92
+ The channel name uses a path-style format (`data_porter/imports/42`) rather than a class-style format (`DataPorter::ImportChannel:42`). This is a deliberate choice: the path format reads naturally in logs, avoids Ruby namespace syntax in JavaScript, and the prefix segment makes collisions with host app channels impossible.
93
+
94
+ The Broadcaster is designed to be instantiated inside the Orchestrator's import loop. Here is how it plugs in:
95
+
96
+ ```ruby
97
+ # Inside Orchestrator#import_records (conceptual)
98
+ broadcaster = Broadcaster.new(@data_import.id)
99
+ importable.each_with_index do |record, index|
100
+ persist_record(record, context, results)
101
+ broadcaster.progress(index + 1, importable.size)
102
+ end
103
+ broadcaster.success
104
+ ```
105
+
106
+ One `progress` call per record. For a 10,000-row import, that means 10,000 WebSocket messages. This is acceptable because ActionCable broadcasts are cheap (an in-memory pub/sub when using the async adapter, a Redis PUBLISH when using Redis), and the Stimulus controller handles them idempotently -- it just sets a CSS width, so skipped frames cause no harm.
107
+
108
+ ### Step 2 -- The ImportChannel
109
+
110
+ The channel is the thinnest class in the entire engine:
111
+
112
+ ```ruby
113
+ # app/channels/data_porter/import_channel.rb
114
+ module DataPorter
115
+ class ImportChannel < ActionCable::Channel::Base
116
+ def subscribed
117
+ prefix = DataPorter.configuration.cable_channel_prefix
118
+ stream_from "#{prefix}/imports/#{params[:id]}"
119
+ end
120
+ end
121
+ end
122
+ ```
123
+
124
+ That is the entire file. When a browser subscribes with `{ channel: "DataPorter::ImportChannel", id: 42 }`, the channel constructs the same stream name the Broadcaster uses and calls `stream_from`. No authorization logic, no custom actions, no rejection handling.
125
+
126
+ This simplicity is intentional. Authorization for import access should happen at the controller level (before the page renders), not at the channel level. By the time the user sees the progress bar, they have already been authorized to view that import. Adding channel-level auth would duplicate the host app's authorization logic inside the engine, and the engine does not know whether the host uses Devise, Pundit, or a custom system.
127
+
128
+ The channel name symmetry is critical: the Broadcaster writes to `"#{prefix}/imports/#{id}"` and the channel reads from `"#{prefix}/imports/#{params[:id]}"`. If these ever diverge, messages go nowhere. Using the same `cable_channel_prefix` configuration value in both classes guarantees they stay in sync.
129
+
130
+ ### Step 3 -- The Stimulus progress controller
131
+
132
+ On the browser side, a Stimulus controller subscribes to the channel and updates the DOM:
133
+
134
+ ```javascript
135
+ // app/javascript/data_porter/progress_controller.js
136
+ import { Controller } from "@hotwired/stimulus"
137
+ import { createConsumer } from "@rails/actioncable"
138
+
139
+ export default class extends Controller {
140
+ static targets = ["bar", "text"]
141
+ static values = { id: Number }
142
+
143
+ connect() {
144
+ this.subscription = createConsumer().subscriptions.create(
145
+ { channel: "DataPorter::ImportChannel", id: this.idValue },
146
+ {
147
+ received: (data) => {
148
+ if (data.status === "processing") {
149
+ this.updateProgress(data.percentage)
150
+ } else {
151
+ window.location.reload()
152
+ }
153
+ }
154
+ }
155
+ )
156
+ }
157
+
158
+ updateProgress(percentage) {
159
+ if (this.hasBarTarget) {
160
+ this.barTarget.style.width = `${percentage}%`
161
+ this.textTarget.textContent = `${percentage}%`
162
+ }
163
+ }
164
+
165
+ disconnect() {
166
+ this.subscription?.unsubscribe()
167
+ }
168
+ }
169
+ ```
170
+
171
+ The controller declares two targets (`bar` and `text`) and one value (`id`). The corresponding HTML looks like this:
172
+
173
+ ```html
174
+ <div data-controller="data-porter--progress" data-data-porter--progress-id-value="42">
175
+ <div data-data-porter--progress-target="bar" style="width: 0%"></div>
176
+ <span data-data-porter--progress-target="text">0%</span>
177
+ </div>
178
+ ```
179
+
180
+ On `connect`, the controller creates an ActionCable subscription. The `received` callback handles the two-branch logic: if the status is `processing`, update the progress bar width and text; for anything else (`success` or `failure`), reload the page. The reload is the simplest possible terminal action -- the server-rendered page will show the completed import with its report, or the failed import with its error. No client-side state management needed.
181
+
182
+ The `disconnect` callback unsubscribes from the channel, which is important for single-page-app-style navigation where Stimulus controllers connect and disconnect as the user moves between pages. Without it, orphaned subscriptions would accumulate and leak memory.
183
+
184
+ Three design choices are worth noting here. First, we use `createConsumer()` rather than importing a shared consumer instance. In an engine context, we cannot assume the host app exports its consumer, so we create our own. ActionCable deduplicates connections internally, so multiple consumers pointing at the same WebSocket endpoint share a single connection. Second, the `hasBarTarget` guard means the controller degrades gracefully if the HTML does not include the targets -- useful during Turbo transitions where the DOM might be partially rendered. Third, the page reload on completion means the engine does not need to ship Turbo Stream templates or partial update logic for the result screen. The server renders the final state once, and the client gets there by reloading.
185
+
186
+ ### Step 4 -- The cable_channel_prefix configuration
187
+
188
+ The channel name prefix is configurable through the engine's Configuration class:
189
+
190
+ ```ruby
191
+ # lib/data_porter/configuration.rb
192
+ class Configuration
193
+ attr_accessor :parent_controller,
194
+ :queue_name,
195
+ :storage_service,
196
+ :cable_channel_prefix,
197
+ :context_builder,
198
+ :preview_limit,
199
+ :enabled_sources,
200
+ :scope
201
+
202
+ def initialize
203
+ @cable_channel_prefix = "data_porter"
204
+ # ...
205
+ end
206
+ end
207
+ ```
208
+
209
+ The default prefix is `"data_porter"`, which produces channel names like `"data_porter/imports/42"`. A host app can override it:
210
+
211
+ ```ruby
212
+ # config/initializers/data_porter.rb
213
+ DataPorter.configure do |config|
214
+ config.cable_channel_prefix = "my_app_imports"
215
+ end
216
+ ```
217
+
218
+ This matters for two reasons. First, if the host app runs multiple engines that use ActionCable, unique prefixes prevent channel name collisions. Second, if the host app's ActionCable adapter uses Redis, the prefix becomes part of the Redis pub/sub channel name, and operations teams may want it to match their naming conventions.
219
+
220
+ ## Decisions & tradeoffs
221
+
222
+ | Decision | We chose | Over | Because |
223
+ |----------|----------|------|---------|
224
+ | Real-time transport | ActionCable (WebSockets) | Polling or SSE | Rails ships ActionCable; no extra dependencies, integrates with existing auth, bidirectional even though we only need server-to-client |
225
+ | Broadcast granularity | One message per record | Batched (every N records) or throttled (every N seconds) | Simplicity; ActionCable broadcasts are cheap, and the Stimulus controller handles high-frequency updates idempotently by just setting a CSS width |
226
+ | Completion behavior | `window.location.reload()` | Turbo Stream partial updates | The engine cannot predict what the host app's result page looks like; a full reload lets the server render the final state with its own layout and components |
227
+ | Channel authorization | None (deferred to controller) | `reject` in `subscribed` based on user ownership | The engine does not know the host's auth system; by the time the user sees the progress bar, the controller has already authorized access |
228
+ | Consumer creation | `createConsumer()` per controller | Shared global consumer | The engine cannot assume the host app exports a consumer; ActionCable deduplicates WebSocket connections internally |
229
+ | Channel naming | Configurable prefix (`cable_channel_prefix`) | Hardcoded `"data_porter"` | Avoids collisions in multi-engine apps and lets ops teams control Redis pub/sub channel naming |
230
+
231
+ ## Testing it
232
+
233
+ ### Broadcaster specs
234
+
235
+ The Broadcaster specs stub `ActionCable.server.broadcast` and verify the message payloads:
236
+
237
+ ```ruby
238
+ # spec/data_porter/broadcaster_spec.rb
239
+ RSpec.describe DataPorter::Broadcaster do
240
+ let(:broadcaster) { described_class.new(42) }
241
+
242
+ before do
243
+ allow(ActionCable.server).to receive(:broadcast)
244
+ end
245
+
246
+ describe "#progress" do
247
+ it "broadcasts processing status with percentage" do
248
+ broadcaster.progress(50, 100)
249
+
250
+ expect(ActionCable.server).to have_received(:broadcast).with(
251
+ "data_porter/imports/42",
252
+ { status: :processing, percentage: 50, current: 50, total: 100 }
253
+ )
254
+ end
255
+
256
+ it "rounds percentage" do
257
+ broadcaster.progress(1, 3)
258
+
259
+ expect(ActionCable.server).to have_received(:broadcast).with(
260
+ "data_porter/imports/42",
261
+ { status: :processing, percentage: 33, current: 1, total: 3 }
262
+ )
263
+ end
264
+ end
265
+
266
+ describe "#success" do
267
+ it "broadcasts success status" do
268
+ broadcaster.success
269
+
270
+ expect(ActionCable.server).to have_received(:broadcast).with(
271
+ "data_porter/imports/42",
272
+ { status: :success }
273
+ )
274
+ end
275
+ end
276
+
277
+ describe "#failure" do
278
+ it "broadcasts failure with error message" do
279
+ broadcaster.failure("Something went wrong")
280
+
281
+ expect(ActionCable.server).to have_received(:broadcast).with(
282
+ "data_porter/imports/42",
283
+ { status: :failure, error: "Something went wrong" }
284
+ )
285
+ end
286
+ end
287
+ end
288
+ ```
289
+
290
+ The key assertion pattern: verify the exact channel name and the exact message hash. Because the Broadcaster is a pure wrapper around `ActionCable.server.broadcast`, stubbing that single method lets us test all behavior without a WebSocket server or a browser.
291
+
292
+ The prefix configuration is also tested:
293
+
294
+ ```ruby
295
+ describe "channel name" do
296
+ it "uses configured cable_channel_prefix" do
297
+ DataPorter.configuration.cable_channel_prefix = "custom"
298
+ custom_broadcaster = described_class.new(99)
299
+
300
+ custom_broadcaster.success
301
+
302
+ expect(ActionCable.server).to have_received(:broadcast).with(
303
+ "custom/imports/99",
304
+ { status: :success }
305
+ )
306
+ ensure
307
+ DataPorter.configuration.cable_channel_prefix = "data_porter"
308
+ end
309
+ end
310
+ ```
311
+
312
+ The `ensure` block resets the prefix after the test, preventing configuration leakage between examples. This is a common pattern when testing configurable singletons.
313
+
314
+ ### Channel spec
315
+
316
+ The ImportChannel spec is minimal because the class itself is minimal:
317
+
318
+ ```ruby
319
+ # spec/data_porter/import_channel_spec.rb
320
+ RSpec.describe DataPorter::ImportChannel do
321
+ it "inherits from ActionCable::Channel::Base" do
322
+ expect(described_class.superclass).to eq(ActionCable::Channel::Base)
323
+ end
324
+ end
325
+ ```
326
+
327
+ A fuller integration test would use `ActionCable::Channel::TestCase` to assert that `subscribed` calls `stream_from` with the correct channel name. For now, we rely on the fact that the Broadcaster specs prove the channel name format, and the channel uses the same construction logic. If the two diverge, the Broadcaster specs will catch it before any integration test would.
328
+
329
+ ### JavaScript spec
330
+
331
+ Testing a Stimulus controller from RSpec is unconventional, but for an engine that ships JavaScript, it is valuable to at least verify the source file's structure:
332
+
333
+ ```ruby
334
+ # spec/data_porter/progress_controller_js_spec.rb
335
+ RSpec.describe "progress_controller.js" do
336
+ let(:js_path) { File.expand_path("../../app/javascript/data_porter/progress_controller.js", __dir__) }
337
+ let(:content) { File.read(js_path) }
338
+
339
+ it "imports Stimulus Controller" do
340
+ expect(content).to include('import { Controller } from "@hotwired/stimulus"')
341
+ end
342
+
343
+ it "imports ActionCable consumer" do
344
+ expect(content).to include('import { createConsumer } from "@rails/actioncable"')
345
+ end
346
+
347
+ it "defines bar and text targets" do
348
+ expect(content).to include('static targets = ["bar", "text"]')
349
+ end
350
+
351
+ it "subscribes to ImportChannel on connect" do
352
+ expect(content).to include("DataPorter::ImportChannel")
353
+ end
354
+
355
+ it "unsubscribes on disconnect" do
356
+ expect(content).to include("this.subscription?.unsubscribe()")
357
+ end
358
+ end
359
+ ```
360
+
361
+ These are not behavioral tests -- they are structural assertions that guard against accidental breakage. If someone renames a target or removes the unsubscribe call, the spec fails. For real behavioral testing of the Stimulus controller, you would use a JavaScript test runner (Jest, Vitest) or a system test with Capybara and a real WebSocket connection. That level of integration testing is covered in part 13 when we set up the full test infrastructure.
362
+
363
+ ## Recap
364
+
365
+ - The **Broadcaster** is a plain Ruby service that wraps `ActionCable.server.broadcast` with three semantic methods: `progress`, `success`, and `failure`. It constructs the channel name from a configurable prefix and the import ID.
366
+ - The **ImportChannel** is a one-method ActionCable channel that streams from the same channel name the Broadcaster writes to. It contains no authorization logic -- that responsibility stays in the controller layer.
367
+ - The **Stimulus progress controller** subscribes on `connect`, updates a progress bar on `processing` messages, reloads the page on `success` or `failure`, and cleans up the subscription on `disconnect`.
368
+ - The **cable_channel_prefix** configuration option prevents channel name collisions with the host app and gives operations teams control over naming.
369
+
370
+ ## Next up
371
+
372
+ The import now runs in the background and pushes live progress to the browser. But the progress bar needs a page to live on, and right now the engine has no UI. In part 9, we build the **view layer with Phlex and Tailwind** -- auto-generated preview tables from the target DSL, status badges, and scoped CSS that does not leak into the host app.
373
+
374
+ ---
375
+
376
+ *This is part 8 of the series "Building DataPorter - A Data Import Engine for Rails". [Previous: The Orchestrator](#) | [Next: Building the UI with Phlex & Tailwind](#)*