taski 0.7.0 → 0.7.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.
@@ -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,214 @@
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.with_args(options: {}, root_task: self) do
47
+ validate_no_circular_dependencies!
48
+ fresh_wrapper.get_exported_value(method)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+
55
+ # Module prepended to Scheduler to skip dependencies of mocked tasks.
56
+ # @api private
57
+ module SchedulerExtension
58
+ def build_dependency_graph(root_task_class)
59
+ queue = [root_task_class]
60
+
61
+ while (task_class = queue.shift)
62
+ next if @task_states.key?(task_class)
63
+
64
+ # Mocked tasks have no dependencies (isolates indirect dependencies)
65
+ mock = MockRegistry.mock_for(task_class)
66
+ deps = mock ? Set.new : task_class.cached_dependencies
67
+ @dependencies[task_class] = deps.dup
68
+ @task_states[task_class] = Taski::Execution::Scheduler::STATE_PENDING
69
+
70
+ deps.each { |dep| queue << dep }
71
+ end
72
+ end
73
+ end
74
+
75
+ # Module prepended to Executor to skip execution of mocked tasks.
76
+ # @api private
77
+ module ExecutorExtension
78
+ def execute_task(task_class, wrapper)
79
+ return if @registry.abort_requested?
80
+
81
+ # Skip execution if task is mocked
82
+ if MockRegistry.mock_for(task_class)
83
+ wrapper.mark_completed(nil)
84
+ @completion_queue.push({task_class: task_class, wrapper: wrapper})
85
+ return
86
+ end
87
+
88
+ super
89
+ end
90
+ end
91
+
92
+ class << self
93
+ # Checks if any mocks are currently registered.
94
+ # @return [Boolean] true if mocks exist
95
+ def mocks_active?
96
+ MockRegistry.mocks_active?
97
+ end
98
+
99
+ # Retrieves the mock wrapper for a task class.
100
+ # @param task_class [Class] The task class to look up
101
+ # @return [MockWrapper, nil] The mock wrapper or nil if not mocked
102
+ def mock_for(task_class)
103
+ MockRegistry.mock_for(task_class)
104
+ end
105
+
106
+ # Clears all registered mocks.
107
+ # Called automatically by test framework integrations.
108
+ def reset_mocks!
109
+ MockRegistry.reset!
110
+ end
111
+ end
112
+
113
+ # Registers a mock for a task class with specified return values.
114
+ # @param task_class [Class] A Taski::Task or Taski::Section subclass
115
+ # @param values [Hash{Symbol => Object}] Method names mapped to return values
116
+ # @return [MockWrapper] The created mock wrapper
117
+ # @raise [InvalidTaskError] If task_class is not a Taski::Task/Section subclass
118
+ # @raise [InvalidMethodError] If any method name is not an exported method
119
+ #
120
+ # @example
121
+ # mock_task(FetchData, result: { users: [1, 2, 3] })
122
+ # mock_task(Config, timeout: 30, retries: 3)
123
+ def mock_task(task_class, **values)
124
+ validate_task_class!(task_class)
125
+ validate_exported_methods!(task_class, values.keys)
126
+
127
+ mock_wrapper = MockWrapper.new(task_class, values)
128
+ MockRegistry.register(task_class, mock_wrapper)
129
+ mock_wrapper
130
+ end
131
+
132
+ # Asserts that a mocked task's method was accessed during the test.
133
+ # @param task_class [Class] The mocked task class
134
+ # @param method_name [Symbol] The exported method name
135
+ # @return [true] If assertion passes
136
+ # @raise [ArgumentError] If task_class was not mocked
137
+ # @raise [Minitest::Assertion, RSpec::Expectations::ExpectationNotMetError]
138
+ # If method was not accessed
139
+ def assert_task_accessed(task_class, method_name)
140
+ mock = fetch_mock!(task_class)
141
+
142
+ unless mock.accessed?(method_name)
143
+ raise assertion_error("Expected #{task_class}.#{method_name} to be accessed, but it was not")
144
+ end
145
+
146
+ true
147
+ end
148
+
149
+ # Asserts that a mocked task's method was NOT accessed during the test.
150
+ # @param task_class [Class] The mocked task class
151
+ # @param method_name [Symbol] The exported method name
152
+ # @return [true] If assertion passes
153
+ # @raise [ArgumentError] If task_class was not mocked
154
+ # @raise [Minitest::Assertion, RSpec::Expectations::ExpectationNotMetError]
155
+ # If method was accessed
156
+ def refute_task_accessed(task_class, method_name)
157
+ mock = fetch_mock!(task_class)
158
+
159
+ if mock.accessed?(method_name)
160
+ count = mock.access_count(method_name)
161
+ raise assertion_error(
162
+ "Expected #{task_class}.#{method_name} not to be accessed, but it was accessed #{count} time(s)"
163
+ )
164
+ end
165
+
166
+ true
167
+ end
168
+
169
+ private
170
+
171
+ def fetch_mock!(task_class)
172
+ mock = MockRegistry.mock_for(task_class)
173
+ return mock if mock
174
+
175
+ raise ArgumentError, "Task #{task_class} was not mocked. Call mock_task first."
176
+ end
177
+
178
+ def validate_task_class!(task_class)
179
+ valid = task_class.is_a?(Class) &&
180
+ (task_class < Taski::Task || task_class == Taski::Task)
181
+ return if valid
182
+
183
+ raise InvalidTaskError,
184
+ "Cannot mock #{task_class}: not a Taski::Task or Taski::Section subclass"
185
+ end
186
+
187
+ def validate_exported_methods!(task_class, method_names)
188
+ exported = task_class.exported_methods
189
+ method_names.each do |method_name|
190
+ unless exported.include?(method_name)
191
+ raise InvalidMethodError,
192
+ "Cannot mock :#{method_name} on #{task_class}: not an exported method. Exported: #{exported.inspect}"
193
+ end
194
+ end
195
+ end
196
+
197
+ def assertion_error(message)
198
+ # Use the appropriate assertion error class based on the test framework
199
+ # Use fully qualified names to avoid namespace conflicts
200
+ if defined?(::Minitest::Assertion)
201
+ ::Minitest::Assertion.new(message)
202
+ elsif defined?(::RSpec::Expectations::ExpectationNotMetError)
203
+ ::RSpec::Expectations::ExpectationNotMetError.new(message)
204
+ else
205
+ RuntimeError.new(message)
206
+ end
207
+ end
208
+ end
209
+ end
210
+
211
+ # Prepend extensions when test helper is loaded
212
+ Taski::Task.singleton_class.prepend(Taski::TestHelper::TaskExtension)
213
+ Taski::Execution::Scheduler.prepend(Taski::TestHelper::SchedulerExtension)
214
+ 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.7.1"
5
5
  end
data/lib/taski.rb CHANGED
@@ -11,6 +11,8 @@ 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"
15
17
 
16
18
  module Taski
@@ -31,13 +33,15 @@ module Taski
31
33
 
32
34
  # Represents a single task failure with its context
33
35
  class TaskFailure
34
- attr_reader :task_class, :error
36
+ attr_reader :task_class, :error, :output_lines
35
37
 
36
38
  # @param task_class [Class] The task class that failed
37
39
  # @param error [Exception] The exception that was raised
38
- def initialize(task_class:, error:)
40
+ # @param output_lines [Array<String>] Recent output lines from the failed task
41
+ def initialize(task_class:, error:, output_lines: [])
39
42
  @task_class = task_class
40
43
  @error = error
44
+ @output_lines = output_lines
41
45
  end
42
46
  end
43
47
 
@@ -117,8 +121,19 @@ module Taski
117
121
 
118
122
  def build_message
119
123
  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")
124
+ parts = ["#{errors.size} #{task_word} failed:"]
125
+
126
+ errors.each do |f|
127
+ parts << " - #{f.task_class.name}: #{f.error.message}"
128
+
129
+ # Include captured output if available
130
+ if f.output_lines && !f.output_lines.empty?
131
+ parts << " Output:"
132
+ f.output_lines.each { |line| parts << " #{line}" }
133
+ end
134
+ end
135
+
136
+ parts.join("\n")
122
137
  end
123
138
  end
124
139
 
@@ -132,10 +147,12 @@ module Taski
132
147
 
133
148
  # Start new runtime arguments (internal use only)
134
149
  # @api private
150
+ # @return [Boolean] true if this call created the args, false if args already existed
135
151
  def self.start_args(options:, root_task:)
136
152
  @args_monitor.synchronize do
137
- return if @args
153
+ return false if @args
138
154
  @args = Args.new(options: options, root_task: root_task)
155
+ true
139
156
  end
140
157
  end
141
158
 
@@ -145,21 +162,77 @@ module Taski
145
162
  @args_monitor.synchronize { @args = nil }
146
163
  end
147
164
 
165
+ # Execute a block with args lifecycle management.
166
+ # Creates args if they don't exist, and resets them only if this call created them.
167
+ # This prevents race conditions in concurrent execution.
168
+ #
169
+ # @param options [Hash] User-defined options
170
+ # @param root_task [Class] The root task class
171
+ # @yield The block to execute with args available
172
+ # @return [Object] The result of the block
173
+ def self.with_args(options:, root_task:)
174
+ created_args = start_args(options: options, root_task: root_task)
175
+ yield
176
+ ensure
177
+ reset_args! if created_args
178
+ end
179
+
148
180
  # Progress display is enabled by default (tree-style).
149
181
  # Environment variables:
150
182
  # - TASKI_PROGRESS_DISABLE=1: Disable progress display entirely
183
+ # - TASKI_PROGRESS_MODE=simple|tree: Set display mode (default: tree)
151
184
  def self.progress_display
152
185
  return nil if progress_disabled?
153
- @progress_display ||= Execution::TreeProgressDisplay.new
186
+ @progress_display ||= create_progress_display
154
187
  end
155
188
 
156
189
  def self.progress_disabled?
157
190
  ENV["TASKI_PROGRESS_DISABLE"] == "1"
158
191
  end
159
192
 
193
+ # Get the current progress mode (:tree or :simple)
194
+ # @return [Symbol] The current progress mode
195
+ def self.progress_mode
196
+ @progress_mode || progress_mode_from_env
197
+ end
198
+
199
+ # Set the progress mode (:tree or :simple)
200
+ # @param mode [Symbol] The mode to use (:tree or :simple)
201
+ def self.progress_mode=(mode)
202
+ @progress_mode = mode.to_sym
203
+ # Reset display so it will be recreated with new mode
204
+ @progress_display&.stop
205
+ @progress_display = nil
206
+ end
207
+
160
208
  def self.reset_progress_display!
161
209
  @progress_display&.stop
162
210
  @progress_display = nil
211
+ @progress_mode = nil
212
+ end
213
+
214
+ # @api private
215
+ def self.create_progress_display
216
+ case progress_mode
217
+ when :simple
218
+ Execution::SimpleProgressDisplay.new
219
+ when :plain
220
+ Execution::PlainProgressDisplay.new
221
+ else
222
+ Execution::TreeProgressDisplay.new
223
+ end
224
+ end
225
+
226
+ # @api private
227
+ def self.progress_mode_from_env
228
+ case ENV["TASKI_PROGRESS_MODE"]
229
+ when "simple"
230
+ :simple
231
+ when "plain"
232
+ :plain
233
+ else
234
+ :tree
235
+ end
163
236
  end
164
237
 
165
238
  # 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.7.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - ahogappa
@@ -61,19 +61,24 @@ 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/execution/base_progress_display.rb
73
76
  - lib/taski/execution/execution_context.rb
74
77
  - lib/taski/execution/executor.rb
78
+ - lib/taski/execution/plain_progress_display.rb
75
79
  - lib/taski/execution/registry.rb
76
80
  - lib/taski/execution/scheduler.rb
81
+ - lib/taski/execution/simple_progress_display.rb
77
82
  - lib/taski/execution/task_output_pipe.rb
78
83
  - lib/taski/execution/task_output_router.rb
79
84
  - lib/taski/execution/task_wrapper.rb
@@ -84,6 +89,12 @@ files:
84
89
  - lib/taski/static_analysis/dependency_graph.rb
85
90
  - lib/taski/static_analysis/visitor.rb
86
91
  - lib/taski/task.rb
92
+ - lib/taski/test_helper.rb
93
+ - lib/taski/test_helper/errors.rb
94
+ - lib/taski/test_helper/minitest.rb
95
+ - lib/taski/test_helper/mock_registry.rb
96
+ - lib/taski/test_helper/mock_wrapper.rb
97
+ - lib/taski/test_helper/rspec.rb
87
98
  - lib/taski/version.rb
88
99
  - rbs_collection.lock.yaml
89
100
  - rbs_collection.yaml
@@ -108,7 +119,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
108
119
  - !ruby/object:Gem::Version
109
120
  version: '0'
110
121
  requirements: []
111
- rubygems_version: 3.6.9
122
+ rubygems_version: 4.0.3
112
123
  specification_version: 4
113
124
  summary: A simple yet powerful Ruby task runner with static dependency resolution
114
125
  (in development).