aidp 0.17.0 → 0.17.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 84451cdcc0886f1a70e799ab53d9b8898633f324cdd80c5f07a5f204848a3de2
4
- data.tar.gz: e93982382c700564db24fac6ff1de2276e5b0301b91f6cade19aa8dfacd3f7d3
3
+ metadata.gz: 949670fdea4721406643f4fd8da096e943740effbd836407311b812350919b14
4
+ data.tar.gz: 8034a3c798571e4626b273c4a9abe9a5da38cef5dd4f25de8794a0ef63bf6022
5
5
  SHA512:
6
- metadata.gz: 3de783cb25c3eedc9056f77c2b294918a2f5ab46c009280897b6094dd157c45c681fc5c8fe22c6eea401ba74e0c4151c132a85e3c8ceedfef7722e9580f96f43
7
- data.tar.gz: de86c77e08a8f9b837d501d8e69786b6f969c0e2a1538f1a9d2062267f01d6e8c855f0885c31d0bdc3f2c9362fa78439fccdcfe5e09a12cd1ab61282365cdc4a
6
+ metadata.gz: cdabc6ba04d06a89dbc4ad19b27a64862e960daa350029db4fada5a4f6d32d3fba27cb32dda291871a79ad8f319d27e7dd1348ddcb7fdb8b0f0a1390b9493fc1
7
+ data.tar.gz: 39c384f4c01bf00bef550ac9190d27ddd268437be92fc4cdfcdeac3dff09c79c055b2e5b5582e553af6fa838b3f904cce354e9db62ee3638f658eec05673e4f5
@@ -80,7 +80,7 @@ module Aidp
80
80
  display_message(box)
81
81
  end
82
82
 
83
- def load_kb_data
83
+ def load_kb_data(suppress_parse_warnings: false)
84
84
  data = {}
85
85
 
86
86
  %w[symbols imports calls metrics seams hotspots tests cycles].each do |type|
@@ -89,8 +89,7 @@ module Aidp
89
89
  begin
90
90
  data[type.to_sym] = JSON.parse(File.read(file_path), symbolize_names: true)
91
91
  rescue JSON::ParserError => e
92
- # Suppress warnings in test mode to avoid CI failures
93
- unless ENV["RACK_ENV"] == "test" || defined?(RSpec)
92
+ unless suppress_parse_warnings
94
93
  display_message("Warning: Could not parse #{file_path}: #{e.message}", type: :warn)
95
94
  end
96
95
  data[type.to_sym] = []
@@ -9,9 +9,10 @@ module Aidp
9
9
  class Progress
10
10
  attr_reader :project_dir, :progress_file
11
11
 
12
- def initialize(project_dir)
12
+ def initialize(project_dir, skip_persistence: false)
13
13
  @project_dir = project_dir
14
14
  @progress_file = File.join(project_dir, ".aidp", "progress", "analyze.yml")
15
+ @skip_persistence = skip_persistence
15
16
  load_progress
16
17
  end
17
18
 
@@ -60,26 +61,20 @@ module Aidp
60
61
  private
61
62
 
62
63
  def load_progress
63
- # In test mode, only skip file operations if no progress file exists
64
- if (ENV["RACK_ENV"] == "test" || defined?(RSpec)) && !File.exist?(@progress_file)
64
+ if @skip_persistence && !File.exist?(@progress_file)
65
65
  @progress = {}
66
66
  return
67
67
  end
68
-
69
- @progress = if File.exist?(@progress_file)
68
+ @progress = if !@skip_persistence && File.exist?(@progress_file)
70
69
  YAML.safe_load_file(@progress_file, permitted_classes: [Date, Time, Symbol], aliases: true) || {}
71
70
  else
72
71
  {}
73
72
  end
74
-
75
- # Ensure @progress is never nil
76
73
  @progress = {} if @progress.nil?
77
74
  end
78
75
 
79
76
  def save_progress
80
- # In test mode, skip file operations to avoid hanging
81
- return if ENV["RACK_ENV"] == "test" || defined?(RSpec)
82
-
77
+ return if @skip_persistence
83
78
  FileUtils.mkdir_p(File.dirname(@progress_file))
84
79
  File.write(@progress_file, @progress.to_yaml)
85
80
  end
@@ -102,7 +102,7 @@ module Aidp
102
102
 
103
103
  providers.each do |provider|
104
104
  provider_info = Aidp::Harness::ProviderInfo.new(provider, @root_dir)
105
- info = provider_info.get_info
105
+ info = provider_info.info
106
106
 
107
107
  next unless info[:mcp_support]
108
108
 
data/lib/aidp/cli.rb CHANGED
@@ -147,6 +147,10 @@ module Aidp
147
147
  class << self
148
148
  extend Aidp::MessageDisplay::ClassMethods
149
149
 
150
+ def create_prompt
151
+ ::TTY::Prompt.new
152
+ end
153
+
150
154
  def run(args = ARGV)
151
155
  # Handle subcommands first (status, jobs, kb, harness)
152
156
  return run_subcommand(args) if subcommand?(args)
@@ -173,7 +177,7 @@ module Aidp
173
177
 
174
178
  # Handle configuration setup
175
179
  # Create a prompt for the wizard
176
- prompt = TTY::Prompt.new
180
+ prompt = create_prompt
177
181
 
178
182
  if options[:setup_config]
179
183
  # Force setup/reconfigure even if config exists
@@ -398,7 +402,7 @@ module Aidp
398
402
 
399
403
  def run_jobs_command(args = [])
400
404
  require_relative "cli/jobs_command"
401
- jobs_cmd = Aidp::CLI::JobsCommand.new(prompt: TTY::Prompt.new)
405
+ jobs_cmd = Aidp::CLI::JobsCommand.new(prompt: create_prompt)
402
406
  subcommand = args.shift
403
407
  jobs_cmd.run(subcommand, args)
404
408
  end
@@ -504,21 +508,6 @@ module Aidp
504
508
  if step
505
509
  display_message("Running #{mode} step '#{step}' with enhanced TUI harness", type: :highlight)
506
510
  display_message("progress indicators", type: :info)
507
- if step.start_with?("00_PRD") && (defined?(RSpec) || ENV["RSPEC_RUNNING"])
508
- # Simulate questions & completion similar to TUI test mode
509
- root = ENV["AIDP_ROOT"] || Dir.pwd
510
- file = Dir.glob(File.join(root, "templates", (mode == :execute) ? "EXECUTE" : "ANALYZE", "00_PRD*.md")).first
511
- if file && File.file?(file)
512
- content = File.read(file)
513
- questions_section = content.split(/## Questions/i)[1]
514
- if questions_section
515
- questions_section.lines.select { |l| l.strip.start_with?("-") }.each do |line|
516
- display_message(line.strip.sub(/^-\s*/, ""), type: :info)
517
- end
518
- end
519
- end
520
- display_message("PRD completed", type: :success)
521
- end
522
511
  return
523
512
  end
524
513
  display_message("Starting enhanced TUI harness", type: :highlight)
@@ -601,7 +590,7 @@ module Aidp
601
590
  when "clear"
602
591
  force = args.include?("--force")
603
592
  unless force
604
- prompt = TTY::Prompt.new
593
+ prompt = create_prompt
605
594
  confirm = prompt.yes?("Are you sure you want to clear all checkpoint data?")
606
595
  return unless confirm
607
596
  end
@@ -696,7 +685,7 @@ module Aidp
696
685
  end
697
686
  end
698
687
  config_manager = Aidp::Harness::ConfigManager.new(Dir.pwd)
699
- pm = Aidp::Harness::ProviderManager.new(config_manager, prompt: TTY::Prompt.new)
688
+ pm = Aidp::Harness::ProviderManager.new(config_manager, prompt: create_prompt)
700
689
 
701
690
  # Use TTY::Spinner for progress indication
702
691
  require "tty-spinner"
@@ -738,7 +727,12 @@ module Aidp
738
727
  end
739
728
  tokens = (r[:total_tokens].to_i > 0) ? r[:total_tokens].to_s : "0"
740
729
  reason = r[:unhealthy_reason] || "-"
741
- if no_color || !$stdout.tty?
730
+ is_tty = begin
731
+ $stdout.respond_to?(:tty?) && $stdout.tty?
732
+ rescue
733
+ false
734
+ end
735
+ if no_color || !is_tty
742
736
  [r[:provider], r[:status], (r[:available] ? "yes" : "no"), cb, rl, tokens, last_used, reason]
743
737
  else
744
738
  [
@@ -757,7 +751,7 @@ module Aidp
757
751
  table = TTY::Table.new header, table_rows
758
752
  display_message(table.render(:basic), type: :info)
759
753
  rescue => e
760
- log_rescue(e, component: "cli", action: "display_provider_health", fallback: "error_message")
754
+ Aidp.logger.warn("cli", "Failed to display provider health", error_class: e.class.name, error_message: e.message)
761
755
  display_message("Failed to display provider health: #{e.message}", type: :error)
762
756
  end
763
757
 
@@ -777,7 +771,7 @@ module Aidp
777
771
  display_message("=" * 60, type: :muted)
778
772
 
779
773
  provider_info = Aidp::Harness::ProviderInfo.new(provider_name, Dir.pwd)
780
- info = provider_info.get_info(force_refresh: force_refresh)
774
+ info = provider_info.info(force_refresh: force_refresh)
781
775
 
782
776
  if info.nil?
783
777
  display_message("No information available for provider: #{provider_name}", type: :error)
@@ -1044,7 +1038,7 @@ module Aidp
1044
1038
  return
1045
1039
  end
1046
1040
 
1047
- wizard = Aidp::Setup::Wizard.new(Dir.pwd, prompt: TTY::Prompt.new, dry_run: dry_run)
1041
+ wizard = Aidp::Setup::Wizard.new(Dir.pwd, prompt: create_prompt, dry_run: dry_run)
1048
1042
  wizard.run
1049
1043
  end
1050
1044
 
@@ -1071,7 +1065,7 @@ module Aidp
1071
1065
  end
1072
1066
 
1073
1067
  require_relative "init/runner"
1074
- runner = Aidp::Init::Runner.new(Dir.pwd, prompt: TTY::Prompt.new, options: options)
1068
+ runner = Aidp::Init::Runner.new(Dir.pwd, prompt: create_prompt, options: options)
1075
1069
  runner.run
1076
1070
  end
1077
1071
 
@@ -1127,7 +1121,7 @@ module Aidp
1127
1121
  project_dir: Dir.pwd,
1128
1122
  once: once,
1129
1123
  use_workstreams: use_workstreams,
1130
- prompt: TTY::Prompt.new
1124
+ prompt: create_prompt
1131
1125
  )
1132
1126
  runner.start
1133
1127
  rescue ArgumentError => e
@@ -1244,7 +1238,7 @@ module Aidp
1244
1238
 
1245
1239
  # Confirm removal unless --force
1246
1240
  unless force
1247
- prompt = TTY::Prompt.new
1241
+ prompt = create_prompt
1248
1242
  confirm = prompt.yes?("Remove workstream '#{slug}'?#{" (will also delete branch)" if delete_branch}")
1249
1243
  return unless confirm
1250
1244
  end
@@ -2124,7 +2118,7 @@ module Aidp
2124
2118
 
2125
2119
  # Confirm deletion
2126
2120
  require "tty-prompt"
2127
- prompt = TTY::Prompt.new
2121
+ prompt = create_prompt
2128
2122
  confirmed = prompt.yes?("Delete skill '#{skill.name}' (#{skill_id})? This cannot be undone.")
2129
2123
 
2130
2124
  unless confirmed
@@ -9,9 +9,10 @@ module Aidp
9
9
  class Progress
10
10
  attr_reader :project_dir, :progress_file
11
11
 
12
- def initialize(project_dir)
12
+ def initialize(project_dir, skip_persistence: false)
13
13
  @project_dir = project_dir
14
14
  @progress_file = File.join(project_dir, ".aidp", "progress", "execute.yml")
15
+ @skip_persistence = skip_persistence
15
16
  load_progress
16
17
  end
17
18
 
@@ -61,13 +62,11 @@ module Aidp
61
62
  private
62
63
 
63
64
  def load_progress
64
- # In test mode, only skip file operations if no progress file exists
65
- if (ENV["RACK_ENV"] == "test" || defined?(RSpec)) && !File.exist?(@progress_file)
65
+ if @skip_persistence && !File.exist?(@progress_file)
66
66
  @progress = {}
67
67
  return
68
68
  end
69
-
70
- @progress = if File.exist?(@progress_file)
69
+ @progress = if !@skip_persistence && File.exist?(@progress_file)
71
70
  YAML.safe_load_file(@progress_file, permitted_classes: [Date, Time, Symbol], aliases: true) || {}
72
71
  else
73
72
  {}
@@ -75,9 +74,7 @@ module Aidp
75
74
  end
76
75
 
77
76
  def save_progress
78
- # In test mode, skip file operations to avoid hanging
79
- return if ENV["RACK_ENV"] == "test" || defined?(RSpec)
80
-
77
+ return if @skip_persistence
81
78
  FileUtils.mkdir_p(File.dirname(@progress_file))
82
79
  File.write(@progress_file, @progress.to_yaml)
83
80
  end
@@ -9,9 +9,9 @@ module Aidp
9
9
  module Harness
10
10
  # Enhanced configuration loader for harness
11
11
  class ConfigLoader
12
- def initialize(project_dir = Dir.pwd)
12
+ def initialize(project_dir = Dir.pwd, validator: nil)
13
13
  @project_dir = project_dir
14
- @validator = ConfigValidator.new(project_dir)
14
+ @validator = validator || ConfigValidator.new(project_dir)
15
15
  @config_cache = nil
16
16
  @last_loaded = nil
17
17
  @last_signature = nil # stores {mtime:, size:, hash:}
@@ -22,10 +22,19 @@ module Aidp
22
22
  error: "error"
23
23
  }.freeze
24
24
 
25
- def initialize(project_dir, mode = :analyze, options = {})
25
+ # Simple sleeper abstraction for test control
26
+ class Sleeper
27
+ def sleep(duration)
28
+ Kernel.sleep(duration)
29
+ end
30
+ end
31
+
32
+ def initialize(project_dir, mode = :analyze, options = {}, prompt: TTY::Prompt.new, sleeper: Sleeper.new)
26
33
  @project_dir = project_dir
27
34
  @mode = mode.to_sym
28
35
  @options = options
36
+ @prompt = prompt
37
+ @sleeper = sleeper
29
38
  @state = STATES[:idle]
30
39
  @start_time = nil
31
40
  @current_step = nil
@@ -50,7 +59,7 @@ module Aidp
50
59
  @configuration = Configuration.new(project_dir)
51
60
  @state_manager = StateManager.new(project_dir, @mode)
52
61
  @condition_detector = ConditionDetector.new
53
- @provider_manager = ProviderManager.new(@configuration, prompt: TTY::Prompt.new)
62
+ @provider_manager = ProviderManager.new(@configuration, prompt: @prompt)
54
63
  @error_handler = ErrorHandler.new(@provider_manager, @configuration)
55
64
  @completion_checker = CompletionChecker.new(@project_dir, @workflow_type)
56
65
  end
@@ -240,7 +249,7 @@ module Aidp
240
249
  # Remove job after a delay to show completion
241
250
  # UI delay to let user see completion status before removal
242
251
  Thread.new do
243
- sleep 2 # Acceptable for UI timing
252
+ @sleeper.sleep(2) # UI timing delay
244
253
  @tui.remove_job(step_job_id)
245
254
  end
246
255
 
@@ -349,9 +358,9 @@ module Aidp
349
358
  def get_mode_runner
350
359
  case @mode
351
360
  when :analyze
352
- Aidp::Analyze::Runner.new(@project_dir, self, prompt: TTY::Prompt.new)
361
+ Aidp::Analyze::Runner.new(@project_dir, self, prompt: @prompt)
353
362
  when :execute
354
- Aidp::Execute::Runner.new(@project_dir, self, prompt: TTY::Prompt.new)
363
+ Aidp::Execute::Runner.new(@project_dir, self, prompt: @prompt)
355
364
  else
356
365
  raise ArgumentError, "Unsupported mode: #{@mode}"
357
366
  end
@@ -376,7 +385,7 @@ module Aidp
376
385
  def handle_pause_condition
377
386
  case @state
378
387
  when STATES[:paused]
379
- sleep(1)
388
+ @sleeper.sleep(1)
380
389
  when STATES[:waiting_for_user]
381
390
  # User interface handles this
382
391
  nil
@@ -503,7 +512,7 @@ module Aidp
503
512
  while Time.now < reset_time && @state == STATES[:waiting_for_rate_limit]
504
513
  remaining = reset_time - Time.now
505
514
  @tui.show_message("⏳ Rate limit reset in #{remaining.to_i} seconds", :info)
506
- sleep(1)
515
+ @sleeper.sleep(1)
507
516
  end
508
517
  end
509
518
 
@@ -10,10 +10,19 @@ module Aidp
10
10
  class ErrorHandler
11
11
  include Aidp::DebugMixin
12
12
 
13
- def initialize(provider_manager, configuration, metrics_manager = nil)
13
+ # Simple wrapper to allow dependency injection of sleep behavior in tests
14
+ class Sleeper
15
+ def sleep(seconds)
16
+ Kernel.sleep(seconds)
17
+ end
18
+ end
19
+
20
+ # @param sleeper [#sleep] object responding to sleep(seconds); injectable for tests
21
+ def initialize(provider_manager, configuration, metrics_manager = nil, sleeper: nil)
14
22
  @provider_manager = provider_manager
15
23
  @configuration = configuration
16
24
  @metrics_manager = metrics_manager
25
+ @sleeper = sleeper || Sleeper.new
17
26
  @retry_strategies = {}
18
27
  @retry_counts = {}
19
28
  @error_history = []
@@ -112,7 +121,7 @@ module Aidp
112
121
  if should_retry?(error_info, strategy)
113
122
  delay = @backoff_calculator.calculate_delay(attempt, strategy[:backoff_strategy] || :exponential, 1, 10)
114
123
  debug_log("🔁 Retry attempt #{attempt} for #{current_provider}", level: :info, data: {delay: delay, error_type: error_info[:error_type]})
115
- sleep(delay) if delay > 0
124
+ @sleeper.sleep(delay) if delay > 0
116
125
  retry
117
126
  end
118
127
  end
@@ -191,9 +200,7 @@ module Aidp
191
200
  )
192
201
 
193
202
  # Wait for backoff delay
194
- if delay > 0
195
- sleep(delay)
196
- end
203
+ @sleeper.sleep(delay) if delay > 0
197
204
 
198
205
  # Execute the retry
199
206
  retry_result = execute_retry_attempt(error_info, strategy, context)
@@ -12,9 +12,10 @@ module Aidp
12
12
  include Aidp::MessageDisplay
13
13
  include Aidp::RescueLogging
14
14
 
15
- def initialize(configuration, prompt: TTY::Prompt.new)
15
+ def initialize(configuration, prompt: TTY::Prompt.new, binary_checker: Aidp::Util)
16
16
  @configuration = configuration
17
17
  @prompt = prompt
18
+ @binary_checker = binary_checker
18
19
  @current_provider = nil
19
20
  @current_model = nil
20
21
  @provider_history = []
@@ -1069,18 +1070,6 @@ module Aidp
1069
1070
  def provider_cli_available?(provider_name)
1070
1071
  normalized = normalize_provider_name(provider_name)
1071
1072
 
1072
- # Handle test environment overrides
1073
- if defined?(RSpec) || ENV["RSPEC_RUNNING"]
1074
- # Force claude to be missing for testing
1075
- if ENV["AIDP_FORCE_CLAUDE_MISSING"] == "1" && normalized == "claude"
1076
- return [false, "binary_missing"]
1077
- end
1078
- # Force claude to be available for testing
1079
- if ENV["AIDP_FORCE_CLAUDE_AVAILABLE"] == "1" && normalized == "claude"
1080
- return [true, "available"]
1081
- end
1082
- end
1083
-
1084
1073
  cache_key = "#{provider_name}:#{normalized}"
1085
1074
  cached = @binary_check_cache[cache_key]
1086
1075
  if cached && (Time.now - cached[:checked_at] < @binary_check_ttl)
@@ -1098,7 +1087,7 @@ module Aidp
1098
1087
  return [true, nil]
1099
1088
  end
1100
1089
  path = begin
1101
- Aidp::Util.which(binary)
1090
+ @binary_checker.which(binary)
1102
1091
  rescue => e
1103
1092
  log_rescue(e, component: "provider_manager", action: "locate_binary", fallback: nil, binary: binary)
1104
1093
  nil
@@ -1164,10 +1153,6 @@ module Aidp
1164
1153
  statuses = provider_health_status
1165
1154
  metrics = all_metrics
1166
1155
  configured = configured_providers
1167
- # Ensure fresh binary probe results in test mode so stubs of Aidp::Util.which take effect
1168
- if defined?(RSpec) || ENV["RSPEC_RUNNING"]
1169
- @binary_check_cache.clear
1170
- end
1171
1156
  rows_by_normalized = {}
1172
1157
  configured.each do |prov|
1173
1158
  # Temporarily hide macos provider until it's user-configurable
@@ -1384,7 +1369,7 @@ module Aidp
1384
1369
  @current_provider = provider_type
1385
1370
 
1386
1371
  # Execute the prompt with the provider
1387
- result = provider.send(prompt: prompt, session: nil)
1372
+ result = provider.send_message(prompt: prompt, session: nil)
1388
1373
 
1389
1374
  # Return structured result
1390
1375
  {
@@ -188,9 +188,9 @@ module Aidp
188
188
  def get_mode_runner
189
189
  case @mode
190
190
  when :analyze
191
- Aidp::Analyze::Runner.new(@project_dir, self, prompt: TTY::Prompt.new)
191
+ Aidp::Analyze::Runner.new(@project_dir, self, prompt: @prompt)
192
192
  when :execute
193
- Aidp::Execute::Runner.new(@project_dir, self, prompt: TTY::Prompt.new)
193
+ Aidp::Execute::Runner.new(@project_dir, self, prompt: @prompt)
194
194
  else
195
195
  raise ArgumentError, "Unsupported mode: #{@mode}"
196
196
  end
@@ -8,22 +8,25 @@ module Aidp
8
8
  module State
9
9
  # Handles file I/O and persistence for state management
10
10
  class Persistence
11
- def initialize(project_dir, mode)
11
+ def initialize(project_dir, mode, skip_persistence: false)
12
12
  @project_dir = project_dir
13
13
  @mode = mode
14
14
  @state_dir = File.join(project_dir, ".aidp", "harness")
15
15
  @state_file = File.join(@state_dir, "#{mode}_state.json")
16
16
  @lock_file = File.join(@state_dir, "#{mode}_state.lock")
17
+ # Use explicit skip_persistence flag for dependency injection
18
+ # Callers should set skip_persistence: true for test/dry-run scenarios
19
+ @skip_persistence = skip_persistence
17
20
  ensure_state_directory
18
21
  end
19
22
 
20
23
  def has_state?
21
- return false if test_mode?
24
+ return false if @skip_persistence
22
25
  File.exist?(@state_file)
23
26
  end
24
27
 
25
28
  def load_state
26
- return {} if test_mode? || !has_state?
29
+ return {} if @skip_persistence || !has_state?
27
30
 
28
31
  with_lock do
29
32
  content = File.read(@state_file)
@@ -35,7 +38,7 @@ module Aidp
35
38
  end
36
39
 
37
40
  def save_state(state_data)
38
- return if test_mode?
41
+ return if @skip_persistence
39
42
 
40
43
  with_lock do
41
44
  state_with_metadata = add_metadata(state_data)
@@ -44,7 +47,7 @@ module Aidp
44
47
  end
45
48
 
46
49
  def clear_state
47
- return if test_mode?
50
+ return if @skip_persistence
48
51
 
49
52
  with_lock do
50
53
  File.delete(@state_file) if File.exist?(@state_file)
@@ -53,10 +56,6 @@ module Aidp
53
56
 
54
57
  private
55
58
 
56
- def test_mode?
57
- ENV["RACK_ENV"] == "test" || defined?(RSpec)
58
- end
59
-
60
59
  def add_metadata(state_data)
61
60
  state_data.merge(
62
61
  mode: @mode,
@@ -76,7 +75,7 @@ module Aidp
76
75
  end
77
76
 
78
77
  def with_lock(&block)
79
- return yield if test_mode?
78
+ return yield if @skip_persistence
80
79
 
81
80
  acquire_lock_with_timeout(&block)
82
81
  ensure
@@ -10,11 +10,12 @@ module Aidp
10
10
  module State
11
11
  # Manages workflow-specific state and progress tracking
12
12
  class WorkflowState
13
- def initialize(persistence, project_dir, mode)
13
+ def initialize(persistence, project_dir, mode, progress_tracker_factory: nil)
14
14
  @persistence = persistence
15
15
  @project_dir = project_dir
16
16
  @mode = mode
17
- @progress_tracker = create_progress_tracker
17
+ @progress_tracker_factory = progress_tracker_factory
18
+ @progress_tracker = @progress_tracker_factory ? @progress_tracker_factory.call : create_progress_tracker
18
19
  end
19
20
 
20
21
  def completed_steps