taski 0.7.0 → 0.8.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.
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Taski
4
+ module TestHelper
5
+ # Global registry that stores mock definitions for tests.
6
+ # Uses a Mutex for thread-safety when accessed from worker threads.
7
+ # Mocks should be reset in test setup/teardown to ensure test isolation.
8
+ module MockRegistry
9
+ @mutex = Mutex.new
10
+ @mocks = {}
11
+
12
+ class << self
13
+ # Registers a mock for a task class.
14
+ # If a mock already exists for this class, it is replaced.
15
+ # @param task_class [Class] The task class to mock
16
+ # @param mock_wrapper [MockWrapper] The mock wrapper instance
17
+ def register(task_class, mock_wrapper)
18
+ @mutex.synchronize do
19
+ @mocks[task_class] = mock_wrapper
20
+ end
21
+ end
22
+
23
+ # Retrieves the mock wrapper for a task class, if one exists.
24
+ # @param task_class [Class] The task class to look up
25
+ # @return [MockWrapper, nil] The mock wrapper or nil if not mocked
26
+ def mock_for(task_class)
27
+ @mutex.synchronize do
28
+ @mocks[task_class]
29
+ end
30
+ end
31
+
32
+ # Checks if any mocks are registered.
33
+ # Used for optimization to skip mock lookup in hot paths.
34
+ # @return [Boolean] true if mocks exist
35
+ def mocks_active?
36
+ @mutex.synchronize do
37
+ !@mocks.empty?
38
+ end
39
+ end
40
+
41
+ # Clears all registered mocks and resets args/env.
42
+ # Should be called in test setup/teardown.
43
+ def reset!
44
+ @mutex.synchronize do
45
+ @mocks = {}
46
+ end
47
+ Taski.reset_args!
48
+ Taski.reset_env!
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Taski
4
+ module TestHelper
5
+ # Wraps a mocked task and returns pre-configured values without executing the task.
6
+ # Tracks which methods were accessed for verification in tests.
7
+ class MockWrapper
8
+ attr_reader :task_class, :mock_values
9
+
10
+ # @param task_class [Class] The task class being mocked
11
+ # @param mock_values [Hash{Symbol => Object}] Method names mapped to their return values
12
+ def initialize(task_class, mock_values)
13
+ @task_class = task_class
14
+ @mock_values = mock_values
15
+ @access_counts = Hash.new(0)
16
+ end
17
+
18
+ # Returns the mocked value for a method and records the access.
19
+ # @param method_name [Symbol] The exported method name
20
+ # @return [Object] The pre-configured mock value
21
+ # @raise [KeyError] If method_name was not configured in the mock
22
+ def get_exported_value(method_name)
23
+ unless @mock_values.key?(method_name)
24
+ raise KeyError, "No mock value for method :#{method_name} on #{@task_class}"
25
+ end
26
+
27
+ @access_counts[method_name] += 1
28
+ @mock_values[method_name]
29
+ end
30
+
31
+ # Checks if a method was accessed at least once.
32
+ # @param method_name [Symbol] The method name to check
33
+ # @return [Boolean] true if accessed at least once
34
+ def accessed?(method_name)
35
+ @access_counts[method_name] > 0
36
+ end
37
+
38
+ # Returns the number of times a method was accessed.
39
+ # @param method_name [Symbol] The method name to check
40
+ # @return [Integer] Number of accesses (0 if never accessed)
41
+ def access_count(method_name)
42
+ @access_counts[method_name]
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_helper"
4
+
5
+ module Taski
6
+ module TestHelper
7
+ # RSpec integration module.
8
+ # Include this in your RSpec examples for automatic mock cleanup.
9
+ #
10
+ # @example In spec_helper.rb
11
+ # require 'taski/test_helper/rspec'
12
+ #
13
+ # RSpec.configure do |config|
14
+ # config.include Taski::TestHelper::RSpec
15
+ # end
16
+ #
17
+ # @example In individual specs
18
+ # RSpec.describe MyTask do
19
+ # include Taski::TestHelper::RSpec
20
+ #
21
+ # it "processes data" do
22
+ # mock_task(FetchData, result: "mocked")
23
+ # # ... test code ...
24
+ # end
25
+ # end
26
+ module RSpec
27
+ def self.included(base)
28
+ base.include(Taski::TestHelper)
29
+
30
+ # Add before/after hooks when included in RSpec
31
+ if base.respond_to?(:before) && base.respond_to?(:after)
32
+ base.before(:each) { Taski::TestHelper.reset_mocks! }
33
+ base.after(:each) { Taski::TestHelper.reset_mocks! }
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper/errors"
4
+ require_relative "test_helper/mock_wrapper"
5
+ require_relative "test_helper/mock_registry"
6
+
7
+ module Taski
8
+ # Test helper module for mocking Taski task dependencies in unit tests.
9
+ # Include this module in your test class to access mocking functionality.
10
+ #
11
+ # @example Minitest usage
12
+ # class BuildReportTest < Minitest::Test
13
+ # include Taski::TestHelper
14
+ #
15
+ # def test_builds_report
16
+ # mock_task(FetchData, users: [1, 2, 3])
17
+ # assert_equal 3, BuildReport.user_count
18
+ # end
19
+ # end
20
+ module TestHelper
21
+ # Module prepended to Task's singleton class to intercept define_class_accessor.
22
+ # @api private
23
+ module TaskExtension
24
+ def define_class_accessor(method)
25
+ singleton_class.undef_method(method) if singleton_class.method_defined?(method)
26
+
27
+ define_singleton_method(method) do
28
+ # Check for mock first
29
+ mock = MockRegistry.mock_for(self)
30
+ return mock.get_exported_value(method) if mock
31
+
32
+ # No mock - call original implementation via registry lookup
33
+ registry = Taski.current_registry
34
+ if registry
35
+ wrapper = registry.get_or_create(self) do
36
+ task_instance = allocate
37
+ task_instance.__send__(:initialize)
38
+ Execution::TaskWrapper.new(
39
+ task_instance,
40
+ registry: registry,
41
+ execution_context: Execution::ExecutionContext.current
42
+ )
43
+ end
44
+ wrapper.get_exported_value(method)
45
+ else
46
+ Taski.send(:with_env, root_task: self) do
47
+ Taski.send(:with_args, options: {}) do
48
+ validate_no_circular_dependencies!
49
+ fresh_wrapper.get_exported_value(method)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ # Module prepended to Scheduler to skip dependencies of mocked tasks.
58
+ # @api private
59
+ module SchedulerExtension
60
+ def build_dependency_graph(root_task_class)
61
+ queue = [root_task_class]
62
+
63
+ while (task_class = queue.shift)
64
+ next if @task_states.key?(task_class)
65
+
66
+ # Mocked tasks have no dependencies (isolates indirect dependencies)
67
+ mock = MockRegistry.mock_for(task_class)
68
+ deps = mock ? Set.new : task_class.cached_dependencies
69
+ @dependencies[task_class] = deps.dup
70
+ @task_states[task_class] = Taski::Execution::Scheduler::STATE_PENDING
71
+
72
+ deps.each { |dep| queue << dep }
73
+ end
74
+ end
75
+ end
76
+
77
+ # Module prepended to Executor to skip execution of mocked tasks.
78
+ # @api private
79
+ module ExecutorExtension
80
+ def execute_task(task_class, wrapper)
81
+ return if @registry.abort_requested?
82
+
83
+ # Skip execution if task is mocked
84
+ if MockRegistry.mock_for(task_class)
85
+ wrapper.mark_completed(nil)
86
+ @completion_queue.push({task_class: task_class, wrapper: wrapper})
87
+ return
88
+ end
89
+
90
+ super
91
+ end
92
+ end
93
+
94
+ class << self
95
+ # Checks if any mocks are currently registered.
96
+ # @return [Boolean] true if mocks exist
97
+ def mocks_active?
98
+ MockRegistry.mocks_active?
99
+ end
100
+
101
+ # Retrieves the mock wrapper for a task class.
102
+ # @param task_class [Class] The task class to look up
103
+ # @return [MockWrapper, nil] The mock wrapper or nil if not mocked
104
+ def mock_for(task_class)
105
+ MockRegistry.mock_for(task_class)
106
+ end
107
+
108
+ # Clears all registered mocks.
109
+ # Called automatically by test framework integrations.
110
+ def reset_mocks!
111
+ MockRegistry.reset!
112
+ end
113
+ end
114
+
115
+ # Sets mock args for the duration of the test.
116
+ # This allows testing code that depends on Taski.args without running full task execution.
117
+ # Args are automatically cleared when MockRegistry.reset! is called (in test teardown).
118
+ # @param options [Hash] User-defined options to include in args
119
+ # @return [Taski::Args] The created args instance
120
+ #
121
+ # @example
122
+ # mock_args(env: "test", debug: true)
123
+ # assert_equal "test", Taski.args[:env]
124
+ def mock_args(**options)
125
+ Taski.reset_args!
126
+ Taski.send(:start_args, options: options)
127
+ Taski.args
128
+ end
129
+
130
+ # Sets mock env for the duration of the test.
131
+ # This allows testing code that depends on Taski.env without running full task execution.
132
+ # Env is automatically cleared when MockRegistry.reset! is called (in test teardown).
133
+ # @param root_task [Class] The root task class (defaults to nil for testing)
134
+ # @return [Taski::Env] The created env instance
135
+ #
136
+ # @example
137
+ # mock_env(root_task: MyTask)
138
+ # assert_equal MyTask, Taski.env.root_task
139
+ def mock_env(root_task: nil)
140
+ Taski.reset_env!
141
+ Taski.send(:start_env, root_task: root_task)
142
+ Taski.env
143
+ end
144
+
145
+ # Registers a mock for a task class with specified return values.
146
+ # @param task_class [Class] A Taski::Task or Taski::Section subclass
147
+ # @param values [Hash{Symbol => Object}] Method names mapped to return values
148
+ # @return [MockWrapper] The created mock wrapper
149
+ # @raise [InvalidTaskError] If task_class is not a Taski::Task/Section subclass
150
+ # @raise [InvalidMethodError] If any method name is not an exported method
151
+ #
152
+ # @example
153
+ # mock_task(FetchData, result: { users: [1, 2, 3] })
154
+ # mock_task(Config, timeout: 30, retries: 3)
155
+ def mock_task(task_class, **values)
156
+ validate_task_class!(task_class)
157
+ validate_exported_methods!(task_class, values.keys)
158
+
159
+ mock_wrapper = MockWrapper.new(task_class, values)
160
+ MockRegistry.register(task_class, mock_wrapper)
161
+ mock_wrapper
162
+ end
163
+
164
+ # Asserts that a mocked task's method was accessed during the test.
165
+ # @param task_class [Class] The mocked task class
166
+ # @param method_name [Symbol] The exported method name
167
+ # @return [true] If assertion passes
168
+ # @raise [ArgumentError] If task_class was not mocked
169
+ # @raise [Minitest::Assertion, RSpec::Expectations::ExpectationNotMetError]
170
+ # If method was not accessed
171
+ def assert_task_accessed(task_class, method_name)
172
+ mock = fetch_mock!(task_class)
173
+
174
+ unless mock.accessed?(method_name)
175
+ raise assertion_error("Expected #{task_class}.#{method_name} to be accessed, but it was not")
176
+ end
177
+
178
+ true
179
+ end
180
+
181
+ # Asserts that a mocked task's method was NOT accessed during the test.
182
+ # @param task_class [Class] The mocked task class
183
+ # @param method_name [Symbol] The exported method name
184
+ # @return [true] If assertion passes
185
+ # @raise [ArgumentError] If task_class was not mocked
186
+ # @raise [Minitest::Assertion, RSpec::Expectations::ExpectationNotMetError]
187
+ # If method was accessed
188
+ def refute_task_accessed(task_class, method_name)
189
+ mock = fetch_mock!(task_class)
190
+
191
+ if mock.accessed?(method_name)
192
+ count = mock.access_count(method_name)
193
+ raise assertion_error(
194
+ "Expected #{task_class}.#{method_name} not to be accessed, but it was accessed #{count} time(s)"
195
+ )
196
+ end
197
+
198
+ true
199
+ end
200
+
201
+ private
202
+
203
+ def fetch_mock!(task_class)
204
+ mock = MockRegistry.mock_for(task_class)
205
+ return mock if mock
206
+
207
+ raise ArgumentError, "Task #{task_class} was not mocked. Call mock_task first."
208
+ end
209
+
210
+ def validate_task_class!(task_class)
211
+ valid = task_class.is_a?(Class) &&
212
+ (task_class < Taski::Task || task_class == Taski::Task)
213
+ return if valid
214
+
215
+ raise InvalidTaskError,
216
+ "Cannot mock #{task_class}: not a Taski::Task or Taski::Section subclass"
217
+ end
218
+
219
+ def validate_exported_methods!(task_class, method_names)
220
+ exported = task_class.exported_methods
221
+ method_names.each do |method_name|
222
+ unless exported.include?(method_name)
223
+ raise InvalidMethodError,
224
+ "Cannot mock :#{method_name} on #{task_class}: not an exported method. Exported: #{exported.inspect}"
225
+ end
226
+ end
227
+ end
228
+
229
+ def assertion_error(message)
230
+ # Use the appropriate assertion error class based on the test framework
231
+ # Use fully qualified names to avoid namespace conflicts
232
+ if defined?(::Minitest::Assertion)
233
+ ::Minitest::Assertion.new(message)
234
+ elsif defined?(::RSpec::Expectations::ExpectationNotMetError)
235
+ ::RSpec::Expectations::ExpectationNotMetError.new(message)
236
+ else
237
+ RuntimeError.new(message)
238
+ end
239
+ end
240
+ end
241
+ end
242
+
243
+ # Prepend extensions when test helper is loaded
244
+ Taski::Task.singleton_class.prepend(Taski::TestHelper::TaskExtension)
245
+ Taski::Execution::Scheduler.prepend(Taski::TestHelper::SchedulerExtension)
246
+ Taski::Execution::Executor.prepend(Taski::TestHelper::ExecutorExtension)
data/lib/taski/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Taski
4
- VERSION = "0.7.0"
4
+ VERSION = "0.8.0"
5
5
  end
data/lib/taski.rb CHANGED
@@ -11,7 +11,10 @@ require_relative "taski/execution/scheduler"
11
11
  require_relative "taski/execution/worker_pool"
12
12
  require_relative "taski/execution/executor"
13
13
  require_relative "taski/execution/tree_progress_display"
14
+ require_relative "taski/execution/simple_progress_display"
15
+ require_relative "taski/execution/plain_progress_display"
14
16
  require_relative "taski/args"
17
+ require_relative "taski/env"
15
18
 
16
19
  module Taski
17
20
  class TaskAbortException < StandardError
@@ -31,13 +34,15 @@ module Taski
31
34
 
32
35
  # Represents a single task failure with its context
33
36
  class TaskFailure
34
- attr_reader :task_class, :error
37
+ attr_reader :task_class, :error, :output_lines
35
38
 
36
39
  # @param task_class [Class] The task class that failed
37
40
  # @param error [Exception] The exception that was raised
38
- def initialize(task_class:, error:)
41
+ # @param output_lines [Array<String>] Recent output lines from the failed task
42
+ def initialize(task_class:, error:, output_lines: [])
39
43
  @task_class = task_class
40
44
  @error = error
45
+ @output_lines = output_lines
41
46
  end
42
47
  end
43
48
 
@@ -117,12 +122,24 @@ module Taski
117
122
 
118
123
  def build_message
119
124
  task_word = (errors.size == 1) ? "task" : "tasks"
120
- "#{errors.size} #{task_word} failed:\n" +
121
- errors.map { |f| " - #{f.task_class.name}: #{f.error.message}" }.join("\n")
125
+ parts = ["#{errors.size} #{task_word} failed:"]
126
+
127
+ errors.each do |f|
128
+ parts << " - #{f.task_class.name}: #{f.error.message}"
129
+
130
+ # Include captured output if available
131
+ if f.output_lines && !f.output_lines.empty?
132
+ parts << " Output:"
133
+ f.output_lines.each { |line| parts << " #{line}" }
134
+ end
135
+ end
136
+
137
+ parts.join("\n")
122
138
  end
123
139
  end
124
140
 
125
141
  @args_monitor = Monitor.new
142
+ @env_monitor = Monitor.new
126
143
 
127
144
  # Get the current runtime arguments
128
145
  # @return [Args, nil] The current args or nil if no task is running
@@ -130,12 +147,51 @@ module Taski
130
147
  @args_monitor.synchronize { @args }
131
148
  end
132
149
 
150
+ # Get the current execution environment
151
+ # @return [Env, nil] The current env or nil if no task is running
152
+ def self.env
153
+ @env_monitor.synchronize { @env }
154
+ end
155
+
156
+ # Start new execution environment (internal use only)
157
+ # @api private
158
+ # @return [Boolean] true if this call created the env, false if env already existed
159
+ def self.start_env(root_task:)
160
+ @env_monitor.synchronize do
161
+ return false if @env
162
+ @env = Env.new(root_task: root_task)
163
+ true
164
+ end
165
+ end
166
+
167
+ # Reset the execution environment (internal use only)
168
+ # @api private
169
+ def self.reset_env!
170
+ @env_monitor.synchronize { @env = nil }
171
+ end
172
+
173
+ # Execute a block with env lifecycle management.
174
+ # Creates env if it doesn't exist, and resets it only if this call created it.
175
+ # This prevents race conditions in concurrent execution.
176
+ #
177
+ # @param root_task [Class] The root task class
178
+ # @yield The block to execute with env available
179
+ # @return [Object] The result of the block
180
+ def self.with_env(root_task:)
181
+ created_env = start_env(root_task: root_task)
182
+ yield
183
+ ensure
184
+ reset_env! if created_env
185
+ end
186
+
133
187
  # Start new runtime arguments (internal use only)
134
188
  # @api private
135
- def self.start_args(options:, root_task:)
189
+ # @return [Boolean] true if this call created the args, false if args already existed
190
+ def self.start_args(options:)
136
191
  @args_monitor.synchronize do
137
- return if @args
138
- @args = Args.new(options: options, root_task: root_task)
192
+ return false if @args
193
+ @args = Args.new(options: options)
194
+ true
139
195
  end
140
196
  end
141
197
 
@@ -145,21 +201,76 @@ module Taski
145
201
  @args_monitor.synchronize { @args = nil }
146
202
  end
147
203
 
204
+ # Execute a block with args lifecycle management.
205
+ # Creates args if they don't exist, and resets them only if this call created them.
206
+ # This prevents race conditions in concurrent execution.
207
+ #
208
+ # @param options [Hash] User-defined options
209
+ # @yield The block to execute with args available
210
+ # @return [Object] The result of the block
211
+ def self.with_args(options:)
212
+ created_args = start_args(options: options)
213
+ yield
214
+ ensure
215
+ reset_args! if created_args
216
+ end
217
+
148
218
  # Progress display is enabled by default (tree-style).
149
219
  # Environment variables:
150
220
  # - TASKI_PROGRESS_DISABLE=1: Disable progress display entirely
221
+ # - TASKI_PROGRESS_MODE=simple|tree: Set display mode (default: tree)
151
222
  def self.progress_display
152
223
  return nil if progress_disabled?
153
- @progress_display ||= Execution::TreeProgressDisplay.new
224
+ @progress_display ||= create_progress_display
154
225
  end
155
226
 
156
227
  def self.progress_disabled?
157
228
  ENV["TASKI_PROGRESS_DISABLE"] == "1"
158
229
  end
159
230
 
231
+ # Get the current progress mode (:tree or :simple)
232
+ # @return [Symbol] The current progress mode
233
+ def self.progress_mode
234
+ @progress_mode || progress_mode_from_env
235
+ end
236
+
237
+ # Set the progress mode (:tree or :simple)
238
+ # @param mode [Symbol] The mode to use (:tree or :simple)
239
+ def self.progress_mode=(mode)
240
+ @progress_mode = mode.to_sym
241
+ # Reset display so it will be recreated with new mode
242
+ @progress_display&.stop
243
+ @progress_display = nil
244
+ end
245
+
160
246
  def self.reset_progress_display!
161
247
  @progress_display&.stop
162
248
  @progress_display = nil
249
+ @progress_mode = nil
250
+ end
251
+
252
+ # @api private
253
+ def self.create_progress_display
254
+ case progress_mode
255
+ when :simple
256
+ Execution::SimpleProgressDisplay.new
257
+ when :plain
258
+ Execution::PlainProgressDisplay.new
259
+ else
260
+ Execution::TreeProgressDisplay.new
261
+ end
262
+ end
263
+
264
+ # @api private
265
+ def self.progress_mode_from_env
266
+ case ENV["TASKI_PROGRESS_MODE"]
267
+ when "simple"
268
+ :simple
269
+ when "plain"
270
+ :plain
271
+ else
272
+ :tree
273
+ end
163
274
  end
164
275
 
165
276
  # Get the worker count from the current args (set via Task.run(workers: n))
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: taski
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ahogappa
@@ -61,19 +61,25 @@ files:
61
61
  - examples/clean_demo.rb
62
62
  - examples/data_pipeline_demo.rb
63
63
  - examples/group_demo.rb
64
+ - examples/large_tree_demo.rb
64
65
  - examples/nested_section_demo.rb
65
66
  - examples/parallel_progress_demo.rb
66
67
  - examples/quick_start.rb
67
68
  - examples/reexecution_demo.rb
68
69
  - examples/section_demo.rb
70
+ - examples/simple_progress_demo.rb
69
71
  - examples/system_call_demo.rb
70
72
  - examples/tree_progress_demo.rb
71
73
  - lib/taski.rb
72
74
  - lib/taski/args.rb
75
+ - lib/taski/env.rb
76
+ - lib/taski/execution/base_progress_display.rb
73
77
  - lib/taski/execution/execution_context.rb
74
78
  - lib/taski/execution/executor.rb
79
+ - lib/taski/execution/plain_progress_display.rb
75
80
  - lib/taski/execution/registry.rb
76
81
  - lib/taski/execution/scheduler.rb
82
+ - lib/taski/execution/simple_progress_display.rb
77
83
  - lib/taski/execution/task_output_pipe.rb
78
84
  - lib/taski/execution/task_output_router.rb
79
85
  - lib/taski/execution/task_wrapper.rb
@@ -84,6 +90,12 @@ files:
84
90
  - lib/taski/static_analysis/dependency_graph.rb
85
91
  - lib/taski/static_analysis/visitor.rb
86
92
  - lib/taski/task.rb
93
+ - lib/taski/test_helper.rb
94
+ - lib/taski/test_helper/errors.rb
95
+ - lib/taski/test_helper/minitest.rb
96
+ - lib/taski/test_helper/mock_registry.rb
97
+ - lib/taski/test_helper/mock_wrapper.rb
98
+ - lib/taski/test_helper/rspec.rb
87
99
  - lib/taski/version.rb
88
100
  - rbs_collection.lock.yaml
89
101
  - rbs_collection.yaml
@@ -108,7 +120,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
108
120
  - !ruby/object:Gem::Version
109
121
  version: '0'
110
122
  requirements: []
111
- rubygems_version: 3.6.9
123
+ rubygems_version: 4.0.3
112
124
  specification_version: 4
113
125
  summary: A simple yet powerful Ruby task runner with static dependency resolution
114
126
  (in development).