wheneverd 0.3.0 → 0.5.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -0
  3. data/CHANGELOG.md +16 -0
  4. data/Gemfile.lock +38 -33
  5. data/README.md +64 -7
  6. data/lib/wheneverd/cli/activate.rb +5 -5
  7. data/lib/wheneverd/cli/deactivate.rb +6 -6
  8. data/lib/wheneverd/cli/reload.rb +8 -8
  9. data/lib/wheneverd/cli/status.rb +13 -6
  10. data/lib/wheneverd/cli.rb +10 -8
  11. data/lib/wheneverd/dsl/context.rb +46 -0
  12. data/lib/wheneverd/dsl/period_parser.rb +8 -107
  13. data/lib/wheneverd/dsl/period_strategy/array_strategy.rb +29 -0
  14. data/lib/wheneverd/dsl/period_strategy/base.rb +65 -0
  15. data/lib/wheneverd/dsl/period_strategy/duration_strategy.rb +33 -0
  16. data/lib/wheneverd/dsl/period_strategy/string_strategy.rb +51 -0
  17. data/lib/wheneverd/dsl/period_strategy/symbol_strategy.rb +31 -0
  18. data/lib/wheneverd/dsl/period_strategy.rb +43 -0
  19. data/lib/wheneverd/duration.rb +1 -7
  20. data/lib/wheneverd/errors.rb +3 -0
  21. data/lib/wheneverd/interval.rb +22 -7
  22. data/lib/wheneverd/schedule.rb +15 -1
  23. data/lib/wheneverd/service.rb +105 -0
  24. data/lib/wheneverd/systemd/cron_parser/dow_parser.rb +208 -0
  25. data/lib/wheneverd/systemd/cron_parser/field_parser.rb +163 -0
  26. data/lib/wheneverd/systemd/cron_parser.rb +56 -303
  27. data/lib/wheneverd/systemd/renderer.rb +31 -66
  28. data/lib/wheneverd/systemd/unit_content_builder.rb +99 -0
  29. data/lib/wheneverd/systemd/unit_deleter.rb +2 -28
  30. data/lib/wheneverd/systemd/unit_lister.rb +2 -28
  31. data/lib/wheneverd/systemd/unit_namer.rb +6 -15
  32. data/lib/wheneverd/systemd/unit_path_utils.rb +54 -0
  33. data/lib/wheneverd/systemd/unit_writer.rb +2 -28
  34. data/lib/wheneverd/trigger/base.rb +22 -0
  35. data/lib/wheneverd/trigger/boot.rb +8 -6
  36. data/lib/wheneverd/trigger/calendar.rb +7 -0
  37. data/lib/wheneverd/trigger/interval.rb +8 -6
  38. data/lib/wheneverd/validation.rb +89 -0
  39. data/lib/wheneverd/version.rb +1 -1
  40. data/lib/wheneverd.rb +5 -1
  41. data/test/cli_activate_test.rb +27 -0
  42. data/test/cli_reload_test.rb +23 -0
  43. data/test/cli_status_test.rb +14 -4
  44. data/test/domain_model_test.rb +105 -0
  45. data/test/dsl_context_shell_test.rb +31 -0
  46. data/test/systemd_cron_parser_test.rb +41 -25
  47. data/test/systemd_renderer_errors_test.rb +1 -1
  48. data/test/systemd_renderer_test.rb +73 -0
  49. metadata +16 -2
@@ -16,7 +16,7 @@ module Wheneverd
16
16
  dest_dir = File.expand_path(unit_dir.to_s)
17
17
  return [] unless Dir.exist?(dest_dir)
18
18
 
19
- deleted = deletable_paths(dest_dir, basename_pattern(identifier))
19
+ deleted = deletable_paths(dest_dir, UnitPathUtils.basename_pattern(identifier))
20
20
  deleted.each { |path| FileUtils.rm_f(path) unless dry_run }
21
21
  deleted
22
22
  end
@@ -27,38 +27,12 @@ module Wheneverd
27
27
 
28
28
  path = File.join(dest_dir, basename)
29
29
  next unless File.file?(path)
30
- next unless generated_marker?(path)
30
+ next unless UnitPathUtils.generated_marker?(path)
31
31
 
32
32
  path
33
33
  end
34
34
  end
35
35
  private_class_method :deletable_paths
36
-
37
- def self.basename_pattern(identifier)
38
- id = sanitize_identifier(identifier)
39
- /\Awheneverd-#{Regexp.escape(id)}-(?:[0-9a-f]{12}(?:-\d+)?|e\d+-j\d+)\.(service|timer)\z/
40
- end
41
- private_class_method :basename_pattern
42
-
43
- def self.generated_marker?(path)
44
- first_line = File.open(path, "r") { |f| f.gets.to_s }
45
- first_line.start_with?(Wheneverd::Systemd::Renderer::MARKER_PREFIX)
46
- end
47
- private_class_method :generated_marker?
48
-
49
- def self.sanitize_identifier(identifier)
50
- raw = identifier.to_s.strip
51
- raise InvalidIdentifierError, "identifier must not be empty" if raw.empty?
52
-
53
- sanitized = raw.gsub(/[^A-Za-z0-9_-]/, "-").gsub(/-+/, "-").gsub(/\A-|-+\z/, "")
54
- if sanitized.empty?
55
- raise InvalidIdentifierError,
56
- "identifier must include at least one alphanumeric character"
57
- end
58
-
59
- sanitized
60
- end
61
- private_class_method :sanitize_identifier
62
36
  end
63
37
  end
64
38
  end
@@ -13,7 +13,7 @@ module Wheneverd
13
13
  dest_dir = File.expand_path(unit_dir.to_s)
14
14
  return [] unless Dir.exist?(dest_dir)
15
15
 
16
- unit_paths(dest_dir, basename_pattern(identifier))
16
+ unit_paths(dest_dir, UnitPathUtils.basename_pattern(identifier))
17
17
  end
18
18
 
19
19
  def self.unit_paths(dest_dir, pattern)
@@ -22,38 +22,12 @@ module Wheneverd
22
22
 
23
23
  path = File.join(dest_dir, basename)
24
24
  next unless File.file?(path)
25
- next unless generated_marker?(path)
25
+ next unless UnitPathUtils.generated_marker?(path)
26
26
 
27
27
  path
28
28
  end
29
29
  end
30
30
  private_class_method :unit_paths
31
-
32
- def self.basename_pattern(identifier)
33
- id = sanitize_identifier(identifier)
34
- /\Awheneverd-#{Regexp.escape(id)}-(?:[0-9a-f]{12}(?:-\d+)?|e\d+-j\d+)\.(service|timer)\z/
35
- end
36
- private_class_method :basename_pattern
37
-
38
- def self.generated_marker?(path)
39
- first_line = File.open(path, "r") { |f| f.gets.to_s }
40
- first_line.start_with?(Wheneverd::Systemd::Renderer::MARKER_PREFIX)
41
- end
42
- private_class_method :generated_marker?
43
-
44
- def self.sanitize_identifier(identifier)
45
- raw = identifier.to_s.strip
46
- raise InvalidIdentifierError, "identifier must not be empty" if raw.empty?
47
-
48
- sanitized = raw.gsub(/[^A-Za-z0-9_-]/, "-").gsub(/-+/, "-").gsub(/\A-|-+\z/, "")
49
- if sanitized.empty?
50
- raise InvalidIdentifierError,
51
- "identifier must include at least one alphanumeric character"
52
- end
53
-
54
- sanitized
55
- end
56
- private_class_method :sanitize_identifier
57
31
  end
58
32
  end
59
33
  end
@@ -37,33 +37,24 @@ module Wheneverd
37
37
  private_class_method :signature
38
38
 
39
39
  def self.trigger_signature(trigger)
40
- case trigger
41
- when Wheneverd::Trigger::Interval
42
- "interval:#{trigger.seconds}"
43
- when Wheneverd::Trigger::Boot
44
- "boot:#{trigger.seconds}"
45
- when Wheneverd::Trigger::Calendar
46
- "calendar:#{trigger.on_calendar.sort.join('|')}"
47
- else
40
+ unless trigger.respond_to?(:signature)
48
41
  raise ArgumentError, "Unsupported trigger type: #{trigger.class}"
49
42
  end
43
+
44
+ trigger.signature
50
45
  end
51
46
  private_class_method :trigger_signature
52
47
 
53
48
  def self.job_signature(job)
54
- case job
55
- when Wheneverd::Job::Command
56
- job.signature
57
- else
58
- raise ArgumentError, "Unsupported job type: #{job.class}"
59
- end
49
+ raise ArgumentError, "Unsupported job type: #{job.class}" unless job.respond_to?(:signature)
50
+
51
+ job.signature
60
52
  end
61
53
  private_class_method :job_signature
62
54
 
63
55
  def self.stable_id_for(signature)
64
56
  Digest::SHA256.hexdigest(signature).slice(0, 12)
65
57
  end
66
- private_class_method :stable_id_for
67
58
  end
68
59
  end
69
60
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ module Systemd
5
+ # Shared utilities for unit file path operations.
6
+ #
7
+ # These methods are used by {UnitWriter}, {UnitDeleter}, {UnitLister}, and {Renderer}
8
+ # to ensure consistent identifier sanitization, path pattern matching, and marker detection.
9
+ module UnitPathUtils
10
+ # Sanitizes an identifier for use in unit file names.
11
+ #
12
+ # Invalid characters are replaced with `-`, and consecutive/leading/trailing dashes
13
+ # are collapsed or removed.
14
+ #
15
+ # @param identifier [String]
16
+ # @return [String]
17
+ # @raise [InvalidIdentifierError] if the identifier is empty or contains no alphanumeric chars
18
+ def self.sanitize_identifier(identifier)
19
+ raw = identifier.to_s.strip
20
+ raise InvalidIdentifierError, "identifier must not be empty" if raw.empty?
21
+
22
+ sanitized = raw.gsub(/[^A-Za-z0-9_-]/, "-").gsub(/-+/, "-").gsub(/\A-|-+\z/, "")
23
+ if sanitized.empty?
24
+ raise InvalidIdentifierError,
25
+ "identifier must include at least one alphanumeric character"
26
+ end
27
+
28
+ sanitized
29
+ end
30
+
31
+ # Returns a regex pattern matching unit basenames for the given identifier.
32
+ #
33
+ # Matches both new-style (SHA-based) and legacy (eN-jN) unit names.
34
+ #
35
+ # @param identifier [String]
36
+ # @return [Regexp]
37
+ def self.basename_pattern(identifier)
38
+ id = sanitize_identifier(identifier)
39
+ /\Awheneverd-#{Regexp.escape(id)}-(?:[0-9a-f]{12}(?:-\d+)?|e\d+-j\d+)\.(service|timer)\z/
40
+ end
41
+
42
+ # Checks if a unit file was generated by wheneverd.
43
+ #
44
+ # Reads the first line of the file and checks for the marker prefix.
45
+ #
46
+ # @param path [String] path to the unit file
47
+ # @return [Boolean]
48
+ def self.generated_marker?(path)
49
+ first_line = File.open(path, "r") { |f| f.gets.to_s }
50
+ first_line.start_with?(Wheneverd::Systemd::Renderer::MARKER_PREFIX)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -67,7 +67,7 @@ module Wheneverd
67
67
  private_class_method :prune_stale_units
68
68
 
69
69
  def self.stale_unit_paths(dest_dir, identifier:, keep:)
70
- pattern = basename_pattern(identifier)
70
+ pattern = UnitPathUtils.basename_pattern(identifier)
71
71
  Dir.children(dest_dir).filter_map do |basename|
72
72
  next if keep.key?(basename)
73
73
 
@@ -83,36 +83,10 @@ module Wheneverd
83
83
  return false unless pattern.match?(basename)
84
84
  return false unless File.file?(path)
85
85
 
86
- generated_marker?(path)
86
+ UnitPathUtils.generated_marker?(path)
87
87
  end
88
88
  private_class_method :stale_unit_path?
89
89
 
90
- def self.basename_pattern(identifier)
91
- id = sanitize_identifier(identifier)
92
- /\Awheneverd-#{Regexp.escape(id)}-(?:[0-9a-f]{12}(?:-\d+)?|e\d+-j\d+)\.(service|timer)\z/
93
- end
94
- private_class_method :basename_pattern
95
-
96
- def self.generated_marker?(path)
97
- first_line = File.open(path, "r") { |f| f.gets.to_s }
98
- first_line.start_with?(Wheneverd::Systemd::Renderer::MARKER_PREFIX)
99
- end
100
- private_class_method :generated_marker?
101
-
102
- def self.sanitize_identifier(identifier)
103
- raw = identifier.to_s.strip
104
- raise InvalidIdentifierError, "identifier must not be empty" if raw.empty?
105
-
106
- sanitized = raw.gsub(/[^A-Za-z0-9_-]/, "-").gsub(/-+/, "-").gsub(/\A-|-+\z/, "")
107
- if sanitized.empty?
108
- raise InvalidIdentifierError,
109
- "identifier must include at least one alphanumeric character"
110
- end
111
-
112
- sanitized
113
- end
114
- private_class_method :sanitize_identifier
115
-
116
90
  def self.atomic_write(dest_path, contents, dir:)
117
91
  basename = File.basename(dest_path)
118
92
  tmp = Tempfile.new([".#{basename}.", ".tmp"], dir)
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ module Trigger
5
+ # Base module for trigger types.
6
+ #
7
+ # All trigger types must implement:
8
+ # - `#systemd_timer_lines` - returns Array<String> of systemd [Timer] lines
9
+ # - `#signature` - returns a String signature for stable unit naming
10
+ module Base
11
+ # @return [Array<String>] systemd `[Timer]` lines for this trigger
12
+ def systemd_timer_lines
13
+ raise NotImplementedError, "#{self.class} must implement #systemd_timer_lines"
14
+ end
15
+
16
+ # @return [String] stable signature for unit naming
17
+ def signature
18
+ raise NotImplementedError, "#{self.class} must implement #signature"
19
+ end
20
+ end
21
+ end
22
+ end
@@ -4,23 +4,25 @@ module Wheneverd
4
4
  module Trigger
5
5
  # A boot trigger, rendered as `OnBootSec=`.
6
6
  class Boot
7
+ include Base
8
+
7
9
  # @return [Integer]
8
10
  attr_reader :seconds
9
11
 
10
12
  # @param seconds [Integer] seconds after boot (must be positive)
11
13
  def initialize(seconds:)
12
- unless seconds.is_a?(Integer)
13
- raise ArgumentError, "Boot seconds must be an Integer (got #{seconds.class})"
14
- end
15
- raise ArgumentError, "Boot seconds must be positive (got #{seconds})" if seconds <= 0
16
-
17
- @seconds = seconds
14
+ @seconds = Validation.positive_integer(seconds, name: "Boot seconds")
18
15
  end
19
16
 
20
17
  # @return [Array<String>] systemd `[Timer]` lines for this trigger
21
18
  def systemd_timer_lines
22
19
  ["OnBootSec=#{seconds}"]
23
20
  end
21
+
22
+ # @return [String] stable signature for unit naming
23
+ def signature
24
+ "boot:#{seconds}"
25
+ end
24
26
  end
25
27
  end
26
28
  end
@@ -4,6 +4,8 @@ module Wheneverd
4
4
  module Trigger
5
5
  # A calendar trigger, rendered as one or more `OnCalendar=` lines.
6
6
  class Calendar
7
+ include Base
8
+
7
9
  # @return [Array<String>] calendar specs (already in `systemd` OnCalendar format)
8
10
  attr_reader :on_calendar
9
11
 
@@ -21,6 +23,11 @@ module Wheneverd
21
23
  def systemd_timer_lines
22
24
  on_calendar.map { |spec| "OnCalendar=#{spec}" }
23
25
  end
26
+
27
+ # @return [String] stable signature for unit naming
28
+ def signature
29
+ "calendar:#{on_calendar.sort.join('|')}"
30
+ end
24
31
  end
25
32
  end
26
33
  end
@@ -8,23 +8,25 @@ module Wheneverd
8
8
  # - `OnActiveSec=` to schedule the first run relative to timer activation.
9
9
  # - `OnUnitActiveSec=` to schedule subsequent runs relative to the last run.
10
10
  class Interval
11
+ include Base
12
+
11
13
  # @return [Integer]
12
14
  attr_reader :seconds
13
15
 
14
16
  # @param seconds [Integer] seconds between runs (must be positive)
15
17
  def initialize(seconds:)
16
- unless seconds.is_a?(Integer)
17
- raise ArgumentError, "Interval seconds must be an Integer (got #{seconds.class})"
18
- end
19
- raise ArgumentError, "Interval seconds must be positive (got #{seconds})" if seconds <= 0
20
-
21
- @seconds = seconds
18
+ @seconds = Validation.positive_integer(seconds, name: "Interval seconds")
22
19
  end
23
20
 
24
21
  # @return [Array<String>] systemd `[Timer]` lines for this trigger
25
22
  def systemd_timer_lines
26
23
  ["OnActiveSec=#{seconds}", "OnUnitActiveSec=#{seconds}"]
27
24
  end
25
+
26
+ # @return [String] stable signature for unit naming
27
+ def signature
28
+ "interval:#{seconds}"
29
+ end
28
30
  end
29
31
  end
30
32
  end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ # Common validation utilities for consistent error handling across the codebase.
5
+ #
6
+ # These validators follow a consistent pattern:
7
+ # - Return the validated value if valid
8
+ # - Raise an appropriate error with a descriptive message if invalid
9
+ #
10
+ # @example Type validation
11
+ # Validation.type(value, Integer, name: "seconds")
12
+ # # => value or raises ArgumentError
13
+ #
14
+ # @example Positive integer validation
15
+ # Validation.positive_integer(value, name: "seconds")
16
+ # # => value or raises ArgumentError
17
+ module Validation
18
+ # Validate that a value is of the expected type.
19
+ #
20
+ # @param value [Object] the value to validate
21
+ # @param expected_type [Class, Module] the expected type
22
+ # @param name [String] the parameter name for error messages
23
+ # @return [Object] the validated value
24
+ # @raise [ArgumentError] if the value is not of the expected type
25
+ def self.type(value, expected_type, name:)
26
+ return value if value.is_a?(expected_type)
27
+
28
+ raise ArgumentError,
29
+ "#{name} must be #{expected_type_name(expected_type)} (got #{value.class})"
30
+ end
31
+
32
+ # Validate that a value is a positive integer.
33
+ #
34
+ # @param value [Object] the value to validate
35
+ # @param name [String] the parameter name for error messages
36
+ # @return [Integer] the validated value
37
+ # @raise [ArgumentError] if the value is not a positive integer
38
+ def self.positive_integer(value, name:)
39
+ type(value, Integer, name: name)
40
+ return value if value.positive?
41
+
42
+ raise ArgumentError, "#{name} must be positive (got #{value})"
43
+ end
44
+
45
+ # Validate that a string is non-empty after stripping whitespace.
46
+ #
47
+ # @param value [String] the value to validate
48
+ # @param name [String] the parameter name for error messages
49
+ # @return [String] the stripped value
50
+ # @raise [ArgumentError] if the value is empty after stripping
51
+ def self.non_empty_string(value, name:)
52
+ stripped = value.to_s.strip
53
+ return stripped unless stripped.empty?
54
+
55
+ raise ArgumentError, "#{name} must not be empty"
56
+ end
57
+
58
+ # Validate that an array is non-empty.
59
+ #
60
+ # @param value [Array] the value to validate
61
+ # @param name [String] the parameter name for error messages
62
+ # @return [Array] the validated value
63
+ # @raise [ArgumentError] if the array is empty
64
+ def self.non_empty_array(value, name:)
65
+ type(value, Array, name: name)
66
+ return value unless value.empty?
67
+
68
+ raise ArgumentError, "#{name} must not be empty"
69
+ end
70
+
71
+ # Validate that a value is within a range.
72
+ #
73
+ # @param value [Comparable] the value to validate
74
+ # @param range [Range] the valid range
75
+ # @param name [String] the parameter name for error messages
76
+ # @return [Comparable] the validated value
77
+ # @raise [ArgumentError] if the value is outside the range
78
+ def self.in_range(value, range, name:)
79
+ return value if range.cover?(value)
80
+
81
+ raise ArgumentError, "#{name} must be in #{range} (got #{value})"
82
+ end
83
+
84
+ def self.expected_type_name(expected_type)
85
+ "a #{expected_type}"
86
+ end
87
+ private_class_method :expected_type_name
88
+ end
89
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Wheneverd
4
4
  # Gem version.
5
- VERSION = "0.3.0"
5
+ VERSION = "0.5.0"
6
6
  end
data/lib/wheneverd.rb CHANGED
@@ -11,14 +11,16 @@ module Wheneverd
11
11
  # - {Wheneverd::DSL::Loader} for evaluating `config/schedule.rb`
12
12
  # - {Wheneverd::Systemd::Renderer} for generating unit contents
13
13
  # - {Wheneverd::CLI} for the command-line interface
14
- class Error < StandardError; end
15
14
  end
16
15
 
17
16
  require_relative "wheneverd/errors"
17
+ require_relative "wheneverd/validation"
18
18
  require_relative "wheneverd/duration"
19
19
  require_relative "wheneverd/interval"
20
20
  require_relative "wheneverd/core_ext/numeric_duration"
21
21
  require_relative "wheneverd/job/command"
22
+ require_relative "wheneverd/service"
23
+ require_relative "wheneverd/trigger/base"
22
24
  require_relative "wheneverd/trigger/interval"
23
25
  require_relative "wheneverd/trigger/calendar"
24
26
  require_relative "wheneverd/trigger/boot"
@@ -30,10 +32,12 @@ require_relative "wheneverd/dsl/period_parser"
30
32
  require_relative "wheneverd/dsl/context"
31
33
  require_relative "wheneverd/dsl/loader"
32
34
  require_relative "wheneverd/systemd/errors"
35
+ require_relative "wheneverd/systemd/unit_path_utils"
33
36
  require_relative "wheneverd/systemd/time_parser"
34
37
  require_relative "wheneverd/systemd/cron_parser"
35
38
  require_relative "wheneverd/systemd/calendar_spec"
36
39
  require_relative "wheneverd/systemd/unit_namer"
40
+ require_relative "wheneverd/systemd/unit_content_builder"
37
41
  require_relative "wheneverd/systemd/renderer"
38
42
  require_relative "wheneverd/systemd/analyze"
39
43
  require_relative "wheneverd/systemd/systemctl"
@@ -37,6 +37,33 @@ class CLIActivateSuccessTest < Minitest::Test
37
37
  includes: expected_timer_basenames)
38
38
  end
39
39
  end
40
+
41
+ def test_runs_enable_now_for_standalone_services
42
+ with_service_project_dir do
43
+ status, out, err, calls = run_activate_with_capture3_stub
44
+ assert_cli_success(status, err)
45
+ service = expected_standalone_service_basenames.fetch(0)
46
+ assert_includes out, service
47
+ assert_systemctl_call_starts_with(calls, 1, SYSTEMCTL_USER_PREFIX + ["enable", "--now"],
48
+ includes: service)
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def with_service_project_dir
55
+ with_project_dir do
56
+ FileUtils.mkdir_p("config")
57
+ File.write("config/schedule.rb", <<~RUBY)
58
+ service "worker", shell: "bin/worker"
59
+ RUBY
60
+ yield
61
+ end
62
+ end
63
+
64
+ def expected_standalone_service_basenames
65
+ expected_units.select { |unit| unit.activation == :service }.map(&:path_basename)
66
+ end
40
67
  end
41
68
 
42
69
  class CLIActivateScheduleMissingTest < Minitest::Test
@@ -41,6 +41,29 @@ class CLIReloadSuccessTest < Minitest::Test
41
41
  includes: expected_timer_basenames)
42
42
  end
43
43
  end
44
+
45
+ def test_restarts_standalone_services
46
+ with_service_project_dir do |project_dir|
47
+ unit_dir = File.join(project_dir, "tmp_units")
48
+ status, _out, err, calls = run_reload_with_capture3_stub(unit_dir: unit_dir)
49
+ assert_cli_success(status, err)
50
+ service = expected_units.find { |unit| unit.activation == :service }.path_basename
51
+ assert_systemctl_call_starts_with(calls, 1, SYSTEMCTL_USER_PREFIX + ["restart"],
52
+ includes: service)
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def with_service_project_dir
59
+ with_project_dir do |project_dir|
60
+ FileUtils.mkdir_p("config")
61
+ File.write("config/schedule.rb", <<~RUBY)
62
+ service "worker", shell: "bin/worker"
63
+ RUBY
64
+ yield project_dir
65
+ end
66
+ end
44
67
  end
45
68
 
46
69
  class CLIReloadScheduleMissingTest < Minitest::Test
@@ -6,11 +6,11 @@ require_relative "support/cli_test_helpers"
6
6
  class CLIStatusTest < Minitest::Test
7
7
  include CLITestHelpers
8
8
 
9
- def test_runs_list_timers_and_status_for_each_installed_timer
9
+ def test_runs_list_timers_and_status_for_each_installed_unit
10
10
  with_installed_units do |unit_dir|
11
11
  status, _out, err, calls = run_status(unit_dir)
12
12
  assert_cli_success(status, err)
13
- assert_status_calls(calls, expected_timer_units)
13
+ assert_status_calls(calls, expected_timer_units, expected_service_units)
14
14
  end
15
15
  end
16
16
 
@@ -39,6 +39,9 @@ class CLIStatusTest < Minitest::Test
39
39
 
40
40
  def with_installed_units
41
41
  with_inited_project_dir do |project_dir|
42
+ File.open(File.join(project_dir, "config", "schedule.rb"), "a") do |file|
43
+ file.puts 'service "worker", shell: "bin/worker"'
44
+ end
42
45
  unit_dir = File.join(project_dir, "tmp_units")
43
46
  assert_equal 0, run_cli(["write", "--identifier", "demo", "--unit-dir", unit_dir]).first
44
47
  yield unit_dir
@@ -53,9 +56,16 @@ class CLIStatusTest < Minitest::Test
53
56
  expected_timer_basenames(identifier: "demo").sort
54
57
  end
55
58
 
56
- def assert_status_calls(calls, expected_timers)
59
+ def expected_service_units
60
+ expected_units(identifier: "demo")
61
+ .select { |unit| unit.activation == :service }
62
+ .map(&:path_basename)
63
+ .sort
64
+ end
65
+
66
+ def assert_status_calls(calls, expected_timers, expected_services)
57
67
  assert_list_timers_call(calls, expected_timers)
58
- assert_status_unit_calls(calls, expected_timers)
68
+ assert_status_unit_calls(calls, (expected_timers + expected_services).sort)
59
69
  end
60
70
 
61
71
  def assert_list_timers_call(calls, expected_timers)