ace-test-runner 0.26.0 → 0.27.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: 6fe8e1df02be6272e1ba2297d8316feef7e962e0ca1982be28e11bab4d23e1b0
4
- data.tar.gz: bf3feac3f566d4def3de575cbaf3d620edfbb3c9354c5f1e771b432ba879506e
3
+ metadata.gz: 8b9e68c09f883cccd0d324be3b33f54de394293a8b0a4e7421ed697b8dffff7e
4
+ data.tar.gz: 92f8e49cccbf575821c3195bcdc92b7cf393c728474d7d116f77f3f94ad3fcae
5
5
  SHA512:
6
- metadata.gz: 59d649278ab5cd98deed1b3444f2d4afb9eaccd1dd612486df2317cb622234eecc17d4e250b747bf8e111acf7fc55bc08cafd6e4131c6f3b90b9470fad092980
7
- data.tar.gz: ae8e02f75588bf6406e00d1a34c3d028f0e367c437d280b47cbbcec7f80450431317760e11eeef040e99654d414f5561b59e6a7f57017cfb5510db17182b2f29
6
+ metadata.gz: 0dd0f2a17a9bf8f99bc4fd6c3a7004d293a0f43a1838c0654bd98743e2277d734f42043cb2a56a3c7e0e994aa863c4ab3fac0771ae7709f28dadb7ed3ebd46fb
7
+ data.tar.gz: cca845831f57a5b706dd547560c351ea30d19e17b99d838a8f0e1f7fadb157fd14eb35aa08a60715a49d3a24e02193e638135c075ecbb79e52fa23e493a4a9ac
data/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.27.1] - 2026-09-29
11
+
12
+ ### Fixed
13
+ - Suite children now launch with the suite's own Ruby binary plus `-rbundler/setup`, so bundler discovers the checkout Gemfile by walking up from each package directory. This gives hermetic children a deterministic source-of-truth dependency surface instead of silently relying on PATH-resolved interpreters (which can fail through manager shims under fixture HOME/XDG overrides) or on globally installed ace gems. Closes the runner-level workspace LOAD_PATH follow-up from the hermetic-tests line.
14
+
15
+ ## [0.27.0] - 2026-09-28
16
+
17
+ ### Added
18
+ - Hermetic test environments by default: deterministic `ace-test` and `ace-test-suite` runs launch every test child (serial, parallel, nested, in-process, suite workers) from a documented minimal environment allowlist with fixture-owned `HOME`/XDG directories, never inheriting ambient `LAB_*`, ACE/runtime/provider, or user configuration.
19
+ - Fixture override configuration (`environment.preserve`/`overrides`/`require` in runner or suite configuration) with deterministic setup errors naming missing keys without exposing values. Suite-level `test_suite.environment` configuration propagates to package workers; package-level configuration wins for the same key.
20
+ - Deterministic/live mode labeling in CLI output; live integration remains opt-in through E2E entrypoints.
21
+
22
+ ### Fixed
23
+ - Test subprocess launching used environment merging, so ambient `LAB_*`/`ACE_*`/provider variables leaked into test children; child environments are now the hermetic fixture environment exactly (`unsetenv_others`).
24
+ - `ace-test-suite` resolves `ace-test` co-located with the suite itself instead of an ambient PATH selection.
25
+ - Rake task integration loads in a fresh process without the runner preloaded.
26
+ - Failed fixture environment builds clean up their temporary root before raising.
27
+
10
28
  ## [0.26.0] - 2026-09-27
11
29
 
12
30
  ### Added
data/README.md CHANGED
@@ -26,6 +26,17 @@
26
26
  2. Execute the matching tests with failure-oriented output and optional profiling of the slowest cases.
27
27
  3. Persist structured run reports for historical debugging and searchable triage across runs.
28
28
 
29
+ ## Hermetic by Default
30
+
31
+ Package tests are deterministic: every test child starts from a documented minimal environment, never from ambient `LAB_*`, ACE/runtime/provider configuration, or user configuration. Output labels the mode (`Test mode: deterministic (hermetic environment)`). Live integration remains opt-in through the E2E entrypoints with explicit target configuration.
32
+
33
+ - Preserved from the parent: `PATH`, `LANG`, `LC_ALL`, `TMPDIR`, `TMP`, `TEMP`.
34
+ - Replaced with test-owned fixture directories: `HOME`, `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, `XDG_DATA_HOME`.
35
+ - Everything else (ambient Lab/runtime sockets, provider credentials, proxy and project-selection variables) never reaches tests.
36
+ - Fixture-specific values are supplied explicitly through runner configuration (`environment.overrides` in `.ace/test/runner.yml`); required values that are missing produce a setup error naming the key. The invoking environment is never mutated.
37
+
38
+ See the [usage guide](docs/usage.md#hermetic-environment-contract) for the full contract and configuration examples.
39
+
29
40
  ## Use Cases
30
41
 
31
42
  **Run package tests from anywhere in the monorepo** - execute [`ace-test [package]`](docs/usage.md) by name without changing directories, keeping outputs consistent across local and CI environments.
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module TestRunner
5
+ module Atoms
6
+ # Pure allowlist filter from a parent environment snapshot to the
7
+ # deterministic child base environment.
8
+ #
9
+ # Copies only policy-preserved keys into a fresh hash. Fixture-home
10
+ # replacements and fixture overrides are applied later by
11
+ # Molecules::FixtureEnvironment. Never mutates ENV or the input hash.
12
+ module EnvironmentSanitizer
13
+ module_function
14
+
15
+ # @param parent_env [Hash] snapshot of the parent environment (not modified)
16
+ # @param policy [Models::EnvironmentPolicy] hermetic environment contract
17
+ # @return [Hash] new child base environment containing only preserved keys
18
+ def sanitize(parent_env, policy)
19
+ parent_env.each_with_object({}) do |(key, value), child|
20
+ next if value.nil?
21
+
22
+ child[key] = value if policy.preserved_key?(key)
23
+ end
24
+ end
25
+
26
+ # Names of parent keys dropped by the policy (diagnostics only).
27
+ def dropped_keys(parent_env, policy)
28
+ parent_env.keys.reject { |key| policy.preserved_key?(key) }
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -147,6 +147,11 @@ module Ace
147
147
  raise Ace::Support::Cli::Error.new(e.message)
148
148
  end
149
149
 
150
+ # Label the execution mode: package tests are always deterministic
151
+ # (hermetic fixture environment). Live integration runs only through
152
+ # the E2E entrypoints with explicit target configuration.
153
+ display_deterministic_mode(test_options)
154
+
150
155
  # Run tests with special exit! handling for Minitest compatibility
151
156
  run_tests_with_exit_handling(test_options)
152
157
  rescue Ace::TestRunner::Error => e
@@ -311,6 +316,12 @@ module Ace
311
316
  exit_code
312
317
  end
313
318
 
319
+ def display_deterministic_mode(options)
320
+ return if options[:quiet]
321
+
322
+ puts "Test mode: deterministic (hermetic environment)"
323
+ end
324
+
314
325
  def display_config_summary(options)
315
326
  return if options[:quiet]
316
327
 
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ace
6
+ module TestRunner
7
+ # Deterministic setup failure: names the missing or invalid key, never its value.
8
+ class EnvironmentSetupError < Error; end
9
+
10
+ module Models
11
+ # Immutable declaration of the hermetic subprocess environment contract.
12
+ #
13
+ # Deterministic test children start from an empty environment and receive only:
14
+ # - preserved toolchain/process keys (PATH, locale, temp directories)
15
+ # - fixture-owned HOME/XDG replacements (applied by Molecules::FixtureEnvironment)
16
+ # - explicit fixture overrides supplied through runner configuration
17
+ #
18
+ # Everything else - ambient LAB_* and ACE_* runtime/provider configuration,
19
+ # credentials, sockets, proxy and project-selection variables - never reaches
20
+ # a deterministic test child.
21
+ class EnvironmentPolicy
22
+ # Runner-internal channel for ace-test-suite to propagate its suite-level
23
+ # fixture configuration into package workers. Consumed by the runner when
24
+ # building the package policy; never passed to test children (the blocked
25
+ # ACE_ prefix keeps it out of every sanitized child environment).
26
+ SUITE_CHANNEL_KEY = "ACE_TEST_FIXTURE_ENVIRONMENT"
27
+
28
+ # Toolchain/process keys preserved from the parent environment.
29
+ PRESERVED_KEYS = %w[PATH LANG LC_ALL TMPDIR TMP TEMP].freeze
30
+
31
+ # Key prefixes never allowed in deterministic children or preserve lists.
32
+ BLOCKED_PREFIXES = %w[
33
+ ACE_ ANTHROPIC_ AWS_ AZURE_ BUNDLE_ COHERE_ DEEPSEEK_ FIREWORKS_ GEM_
34
+ GEMINI_ GLM_ GOOGLE_ GROQ_ HF_ HUGGINGFACE_ LAB_ MISTRAL_ MOONSHOT_
35
+ OPENAI_ OPENROUTER_ TOGETHER_ XAI_ ZAI_ ZHIPU_
36
+ ].freeze
37
+
38
+ # Exact keys never allowed in deterministic children or preserve lists.
39
+ BLOCKED_KEYS = %w[
40
+ ALL_PROXY BASH_ENV DATABASE_URL DOCKER_HOST ENV GITHUB_TOKEN
41
+ GITLAB_TOKEN HTTP_PROXY HTTPS_PROXY NO_PROXY PYTHONPATH
42
+ PYTHONSTARTUP RUBYLIB RUBYOPT SSH_AUTH_SOCK
43
+ all_proxy http_proxy https_proxy no_proxy
44
+ ].freeze
45
+
46
+ # Keys replaced with fixture-owned directories instead of parent values.
47
+ FIXTURE_HOME_KEYS = %w[HOME XDG_CONFIG_HOME XDG_CACHE_HOME XDG_DATA_HOME].freeze
48
+
49
+ attr_reader :preserved_keys, :blocked_prefixes, :blocked_keys,
50
+ :fixture_home_keys, :overrides, :required_keys
51
+
52
+ def initialize(preserved_keys: PRESERVED_KEYS, blocked_prefixes: BLOCKED_PREFIXES,
53
+ blocked_keys: BLOCKED_KEYS, fixture_home_keys: FIXTURE_HOME_KEYS,
54
+ overrides: {}, required_keys: [])
55
+ @preserved_keys = preserved_keys.map(&:to_s).freeze
56
+ @blocked_prefixes = blocked_prefixes.map(&:to_s).freeze
57
+ @blocked_keys = blocked_keys.map(&:to_s).freeze
58
+ @fixture_home_keys = fixture_home_keys.map(&:to_s).freeze
59
+ @overrides = overrides.each_with_object({}) { |(key, value), hash| hash[key.to_s] = value.to_s }.freeze
60
+ @required_keys = required_keys.map(&:to_s).freeze
61
+ validate_contract!
62
+ end
63
+
64
+ # Build a policy from runner or suite configuration:
65
+ #
66
+ # environment:
67
+ # preserve: [EXTRA_TOOLCHAIN_KEY]
68
+ # overrides:
69
+ # MY_ENDPOINT: "http://127.0.0.1:1"
70
+ # require: [MY_ENDPOINT]
71
+ def self.from_config(config)
72
+ normalized = (config || {}).transform_keys(&:to_sym)
73
+ new(
74
+ preserved_keys: PRESERVED_KEYS + Array(normalized[:preserve]),
75
+ overrides: normalized[:overrides] || {},
76
+ required_keys: Array(normalized[:require])
77
+ )
78
+ end
79
+
80
+ # Build a policy from the suite propagation channel payload.
81
+ def self.from_channel(raw)
82
+ from_config(JSON.parse(raw, symbolize_names: true))
83
+ rescue JSON::ParserError => e
84
+ raise EnvironmentSetupError, "Invalid #{SUITE_CHANNEL_KEY} payload: #{e.message}"
85
+ end
86
+
87
+ # Combine this (broader, e.g. suite-level) policy with a more specific one
88
+ # (e.g. package runner configuration). Specific overrides win; preserve and
89
+ # require lists union.
90
+ def merge_with(more_specific)
91
+ self.class.new(
92
+ preserved_keys: (preserved_keys + more_specific.preserved_keys).uniq,
93
+ overrides: overrides.merge(more_specific.overrides),
94
+ required_keys: (required_keys + more_specific.required_keys).uniq
95
+ )
96
+ end
97
+
98
+ # Channel payload for propagating this policy's configuration extras.
99
+ def channel_payload
100
+ JSON.generate(
101
+ overrides: overrides,
102
+ require: required_keys,
103
+ preserve: preserved_keys - PRESERVED_KEYS
104
+ )
105
+ end
106
+
107
+ def preserved_key?(key)
108
+ @preserved_keys.include?(key)
109
+ end
110
+
111
+ def blocked_key?(key)
112
+ @blocked_keys.include?(key) || @blocked_prefixes.any? { |prefix| key.start_with?(prefix) }
113
+ end
114
+
115
+ def credential_like?(key)
116
+ key.match?(/token|secret|password|passwd|credential|api_?key|_key\z|private_?key|_sock(?:et)?\z|_url\z|_endpoint\z/i)
117
+ end
118
+
119
+ # Diagnostic-safe value for a key; credential-like values are never exposed.
120
+ def redacted_value(key, value)
121
+ credential_like?(key) ? "[redacted]" : value
122
+ end
123
+
124
+ private
125
+
126
+ def validate_contract!
127
+ @preserved_keys.each do |key|
128
+ next unless blocked_key?(key)
129
+
130
+ raise EnvironmentSetupError,
131
+ "Environment policy: cannot preserve blocked ambient key '#{key}'"
132
+ end
133
+
134
+ @required_keys.each do |key|
135
+ next unless key.empty?
136
+
137
+ raise EnvironmentSetupError, "Environment policy: required key names must not be empty"
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -9,7 +9,7 @@ module Ace
9
9
  :verbose, :filter, :fix_deprecations, :patterns,
10
10
  :timeout, :parallel, :color, :per_file, :targets,
11
11
  :target, :config_path, :failure_limits, :profile,
12
- :execution, :files, :run_in_single_batch
12
+ :execution, :files, :run_in_single_batch, :environment
13
13
 
14
14
  def initialize(attributes = {})
15
15
  @format = attributes[:format] || "progress" # Default to per-test progress
@@ -32,6 +32,7 @@ module Ace
32
32
  @execution = attributes[:execution] || {}
33
33
  @files = attributes[:files] # Specific files to test (overrides target/patterns)
34
34
  @run_in_single_batch = attributes[:run_in_single_batch] || false
35
+ @environment = attributes[:environment] || {} # Hermetic fixture environment configuration
35
36
  end
36
37
 
37
38
  def valid_format?
@@ -94,7 +95,8 @@ module Ace
94
95
  profile: profile,
95
96
  execution: execution,
96
97
  files: files,
97
- run_in_single_batch: run_in_single_batch
98
+ run_in_single_batch: run_in_single_batch,
99
+ environment: environment
98
100
  }
99
101
  end
100
102
 
@@ -149,6 +149,7 @@ module Ace
149
149
  config[:defaults] ||= {}
150
150
  config[:failure_limits] ||= {}
151
151
  config[:execution] ||= {}
152
+ config[:environment] ||= {}
152
153
 
153
154
  config
154
155
  end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tmpdir"
5
+
6
+ module Ace
7
+ module TestRunner
8
+ module Molecules
9
+ # Builds a launch-ready hermetic environment for deterministic test children.
10
+ #
11
+ # The fixture environment:
12
+ # - starts from the sanitized allowlisted base (Atoms::EnvironmentSanitizer)
13
+ # - replaces HOME and XDG locations with test-owned directories
14
+ # - applies explicit fixture overrides after sanitization
15
+ # - validates required explicit configuration, naming missing keys not values
16
+ #
17
+ # Cleanup owns only the temporary root created here; the parent home,
18
+ # project directories, and system temp locations are never touched.
19
+ class FixtureEnvironment
20
+ attr_reader :policy, :root, :env
21
+
22
+ def initialize(policy:, parent_env:, root_factory: Dir.method(:mktmpdir))
23
+ @policy = policy
24
+ @parent_env = parent_env
25
+ @root_factory = root_factory
26
+ @root = nil
27
+ @env = nil
28
+ end
29
+
30
+ # Build the fixture environment. Returns self for chaining.
31
+ # A failed build cleans up the temporary root it created before raising.
32
+ def build
33
+ @root = @root_factory.call("ace-test-fixture-")
34
+ @env = build_environment
35
+ self
36
+ rescue StandardError
37
+ cleanup
38
+ raise
39
+ end
40
+
41
+ # Remove the test-owned fixture root. Safe to call multiple times.
42
+ def cleanup
43
+ return unless @root
44
+
45
+ FileUtils.rm_rf(@root)
46
+ @root = nil
47
+ @env = nil
48
+ end
49
+
50
+ private
51
+
52
+ def build_environment
53
+ env = Atoms::EnvironmentSanitizer.sanitize(@parent_env, @policy)
54
+ env["MT_NO_AUTORUN"] = "1"
55
+ apply_fixture_homes(env)
56
+ @policy.overrides.each { |key, value| env[key] = value }
57
+ validate_required_keys!(env)
58
+ env
59
+ end
60
+
61
+ def apply_fixture_homes(env)
62
+ home = File.join(@root, "home")
63
+ config_home = File.join(home, ".config")
64
+ cache_home = File.join(home, ".cache")
65
+ data_home = File.join(home, ".local", "share")
66
+
67
+ [home, config_home, cache_home, data_home].each { |dir| FileUtils.mkdir_p(dir) }
68
+
69
+ @policy.fixture_home_keys.each do |key|
70
+ env[key] = case key
71
+ when "HOME" then home
72
+ when "XDG_CONFIG_HOME" then config_home
73
+ when "XDG_CACHE_HOME" then cache_home
74
+ when "XDG_DATA_HOME" then data_home
75
+ end
76
+ end
77
+ end
78
+
79
+ def validate_required_keys!(env)
80
+ missing = @policy.required_keys.select { |key| env[key].to_s.empty? }
81
+ return if missing.empty?
82
+
83
+ raise EnvironmentSetupError,
84
+ "Missing required fixture environment for deterministic test mode: #{missing.join(", ")}." \
85
+ " Supply the listed keys via runner configuration environment.overrides."
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -10,8 +10,10 @@ module Ace
10
10
  # Runs tests directly in the current Ruby process without spawning subprocesses
11
11
  # This provides significantly faster execution for unit tests that don't need isolation
12
12
  class InProcessRunner
13
- def initialize(timeout: nil)
13
+ def initialize(timeout: nil, launch_env:)
14
14
  @timeout = timeout
15
+ @launch_env = launch_env # Hermetic fixture environment applied for the test run
16
+ @saved_env = nil
15
17
  end
16
18
 
17
19
  def execute_tests(files, options = {})
@@ -27,15 +29,16 @@ module Ace
27
29
 
28
30
  # Store original verbose setting
29
31
  original_verbose = $VERBOSE
30
- original_mt_no_autorun = ENV["MT_NO_AUTORUN"]
31
32
 
32
33
  begin
33
34
  $stdout = stdout_io
34
35
  $stderr = stderr_io
35
36
  $VERBOSE = nil if options[:suppress_warnings]
36
37
 
37
- # Prevent Minitest from auto-running
38
- ENV["MT_NO_AUTORUN"] = "1"
38
+ # Apply the hermetic fixture environment for the duration of the run:
39
+ # test code reads sanitized, fixture-owned environment state, and any
40
+ # subprocess the tests spawn inherits the same hermetic environment.
41
+ apply_launch_env
39
42
 
40
43
  # Add test directory to load path if not already there
41
44
  test_dir = File.expand_path("test")
@@ -145,12 +148,9 @@ module Ace
145
148
  $stderr = original_stderr
146
149
  $VERBOSE = original_verbose
147
150
 
148
- # Restore original MT_NO_AUTORUN value
149
- if original_mt_no_autorun
150
- ENV["MT_NO_AUTORUN"] = original_mt_no_autorun
151
- else
152
- ENV.delete("MT_NO_AUTORUN")
153
- end
151
+ # Restore the exact parent environment, including keys removed or
152
+ # added by test code during the run
153
+ restore_launch_env
154
154
  end
155
155
 
156
156
  end_time = Time.now
@@ -195,6 +195,23 @@ module Ace
195
195
 
196
196
  private
197
197
 
198
+ # Swap the process environment to the hermetic fixture environment.
199
+ # Keys absent from the fixture environment (ambient configuration,
200
+ # credentials) are removed for the duration of the run.
201
+ def apply_launch_env
202
+ @saved_env = ENV.to_h
203
+ @launch_env.each { |key, value| ENV[key] = value }
204
+ (@saved_env.keys - @launch_env.keys).each { |key| ENV.delete(key) }
205
+ end
206
+
207
+ def restore_launch_env
208
+ return unless @saved_env
209
+
210
+ ENV.clear
211
+ @saved_env.each { |key, value| ENV[key] = value }
212
+ @saved_env = nil
213
+ end
214
+
198
215
  def empty_result
199
216
  {
200
217
  stdout: "",
@@ -9,9 +9,9 @@ module Ace
9
9
  module Molecules
10
10
  # Intelligently chooses between subprocess and in-process execution based on test type
11
11
  class SmartTestExecutor
12
- def initialize(command_builder: nil, timeout: nil, force_mode: nil)
13
- @subprocess_executor = TestExecutor.new(command_builder: command_builder, timeout: timeout)
14
- @in_process_runner = InProcessRunner.new(timeout: timeout)
12
+ def initialize(command_builder: nil, timeout: nil, force_mode: nil, launch_env:)
13
+ @subprocess_executor = TestExecutor.new(command_builder: command_builder, timeout: timeout, launch_env: launch_env)
14
+ @in_process_runner = InProcessRunner.new(timeout: timeout, launch_env: launch_env)
15
15
  @test_type_detector = Atoms::TestTypeDetector.new
16
16
  @force_mode = force_mode # :subprocess, :direct, or nil for auto
17
17
  end
@@ -9,9 +9,10 @@ module Ace
9
9
  module Molecules
10
10
  # Executes test commands and captures output
11
11
  class TestExecutor
12
- def initialize(command_builder: nil, timeout: nil)
12
+ def initialize(command_builder: nil, timeout: nil, launch_env:)
13
13
  @command_builder = command_builder || Atoms::CommandBuilder.new
14
14
  @timeout = timeout # In seconds, nil = no timeout
15
+ @launch_env = launch_env # Hermetic fixture environment for every child
15
16
  end
16
17
 
17
18
  def execute_tests(files, options = {})
@@ -32,25 +33,21 @@ module Ace
32
33
  stderr = ""
33
34
  status = nil
34
35
 
35
- # Set environment to prevent Minitest autorun at_exit hook
36
- # Also strip assignment context vars to prevent tests from resolving to wrong assignments
37
- # Inherit parent environment and override specific vars (nil unsets at exec time)
38
- env = ENV.to_h.merge({
39
- "MT_NO_AUTORUN" => "1",
40
- "ACE_ASSIGN_ID" => nil,
41
- "ACE_ASSIGN_FORK_ROOT" => nil
42
- })
43
-
44
- # Remove MT_NO_AUTORUN=1 from command if it's there
36
+ # Launch from the hermetic fixture environment: ambient LAB_*, ACE_*,
37
+ # provider and user configuration never reaches the test child.
38
+ # unsetenv_others makes the fixture environment the entire child
39
+ # environment (Process.spawn merges onto the parent by default).
40
+ # MT_NO_AUTORUN is part of the fixture environment.
41
+ env = @launch_env
45
42
  command = command.sub(/^MT_NO_AUTORUN=1\s+/, "")
46
43
 
47
44
  begin
48
45
  if @timeout
49
46
  Timeout.timeout(@timeout) do
50
- stdout, stderr, status = Open3.capture3(env, command)
47
+ stdout, stderr, status = Open3.capture3(env, command, unsetenv_others: true)
51
48
  end
52
49
  else
53
- stdout, stderr, status = Open3.capture3(env, command)
50
+ stdout, stderr, status = Open3.capture3(env, command, unsetenv_others: true)
54
51
  end
55
52
  rescue Timeout::Error
56
53
  stderr = "Test execution timed out after #{@timeout} seconds"
@@ -51,6 +51,9 @@ module Ace
51
51
  ensure
52
52
  # Restore original directory if we changed it
53
53
  Dir.chdir(@original_dir) if @package_dir && Dir.pwd != @original_dir
54
+
55
+ # The fixture environment (and its temp root) belongs to this run only
56
+ @fixture_environment&.cleanup
54
57
  end
55
58
 
56
59
  def run_with_package_context
@@ -63,6 +66,8 @@ module Ace
63
66
  puts "Running tests in #{File.basename(@package_dir)}..."
64
67
  end
65
68
 
69
+ report_fixture_environment if @configuration.verbose
70
+
66
71
  # Check if sequential target execution should be used
67
72
  if should_execute_sequentially?
68
73
  # Use default "fast" target if no target specified in by-target mode
@@ -179,6 +184,17 @@ module Ace
179
184
  @pattern_resolver = Molecules::PatternResolver.new(@configuration)
180
185
  @test_detector = Atoms::TestDetector.new(patterns: @configuration.patterns)
181
186
 
187
+ # Hermetic fixture environment: every test child (subprocess or in-process)
188
+ # is launched from the sanitized allowlisted environment with fixture-owned
189
+ # HOME/XDG directories. Ambient LAB_*/ACE_*/provider/user configuration
190
+ # never reaches deterministic tests. Suite-level fixture configuration is
191
+ # merged under this package's own runner configuration (specific wins).
192
+ @environment_policy = build_environment_policy
193
+ @fixture_environment = Molecules::FixtureEnvironment.new(
194
+ policy: @environment_policy,
195
+ parent_env: ENV.to_h
196
+ ).build
197
+
182
198
  # Use SmartTestExecutor for intelligent subprocess/direct execution choice
183
199
  require_relative "../molecules/smart_test_executor"
184
200
  force_mode = if @options[:direct]
@@ -188,7 +204,8 @@ module Ace
188
204
  end
189
205
  @test_executor = Molecules::SmartTestExecutor.new(
190
206
  timeout: @configuration.timeout,
191
- force_mode: force_mode
207
+ force_mode: force_mode,
208
+ launch_env: @fixture_environment.env
192
209
  )
193
210
  @result_parser = Atoms::ResultParser.new
194
211
  @failure_analyzer = Molecules::FailureAnalyzer.new
@@ -367,10 +384,29 @@ module Ace
367
384
  profile: options[:profile],
368
385
  execution: config_with_options.execution || {},
369
386
  files: options[:files],
370
- run_in_single_batch: options[:run_in_single_batch]
387
+ run_in_single_batch: options[:run_in_single_batch],
388
+ environment: config_with_options.environment
371
389
  )
372
390
  end
373
391
 
392
+ # Verbose diagnostics on stderr: fixture root and override key names only.
393
+ # Override values (potentially credential-like) are never printed.
394
+ def report_fixture_environment
395
+ warn "fixture home: #{@fixture_environment.root}"
396
+ override_names = @environment_policy.overrides.keys.join(", ")
397
+ warn "fixture overrides: #{override_names.empty? ? "none" : override_names}"
398
+ end
399
+
400
+ # Package policy from runner configuration, merged with suite-level
401
+ # fixture configuration propagated by ace-test-suite (specific wins).
402
+ def build_environment_policy
403
+ policy = Models::EnvironmentPolicy.from_config(@configuration.environment)
404
+ channel = ENV[Models::EnvironmentPolicy::SUITE_CHANNEL_KEY]
405
+ return policy unless channel
406
+
407
+ Models::EnvironmentPolicy.from_channel(channel).merge_with(policy)
408
+ end
409
+
374
410
  def validate_configuration!
375
411
  @configuration.validate!
376
412
  rescue ArgumentError => e
@@ -3,6 +3,11 @@
3
3
  require "rake"
4
4
  require "rake/tasklib"
5
5
 
6
+ # Rakefiles load this file directly in a fresh process; load the runner
7
+ # namespace explicitly (models, molecules, atoms, error classes) instead of
8
+ # relying on another entrypoint having required it first.
9
+ require_relative "../test_runner"
10
+
6
11
  module Ace
7
12
  module TestRunner
8
13
  # Custom Rake task that uses ace-test instead of standard Minitest runner
@@ -40,17 +45,22 @@ module Ace
40
45
  # Build ace-test command
41
46
  command = build_command
42
47
 
43
- # Execute ace-test with sanitized environment
44
- # Strip assignment context vars to prevent tests from resolving to wrong assignments
45
48
  puts command if verbose
46
- env = ENV.to_h.merge({
47
- "ACE_ASSIGN_ID" => nil,
48
- "ACE_ASSIGN_FORK_ROOT" => nil
49
- })
50
- success = system(env, command)
49
+
50
+ # Launch ace-test from a hermetic fixture environment: ambient LAB_*,
51
+ # ACE_*/provider and user configuration never reaches the test run.
52
+ fixture_environment = Molecules::FixtureEnvironment.new(
53
+ policy: Models::EnvironmentPolicy.new,
54
+ parent_env: ENV.to_h
55
+ ).build
56
+ # unsetenv_others makes the fixture environment the entire child
57
+ # environment (system merges onto the parent by default).
58
+ success = system(fixture_environment.env, command, unsetenv_others: true)
51
59
 
52
60
  # Exit with proper code for CI/CD
53
61
  exit(1) unless success
62
+ ensure
63
+ fixture_environment&.cleanup
54
64
  end
55
65
 
56
66
  def build_command
@@ -28,6 +28,8 @@ module Ace
28
28
 
29
29
  def run
30
30
  validate_packages!
31
+ puts "Test mode: deterministic (hermetic environment)"
32
+
31
33
  test_options = (@config.dig("test_suite", "test_options") || {}).dup
32
34
  report_root = normalize_report_root(test_options["report_dir"])
33
35
  test_options["report_dir"] = report_root if report_root
@@ -35,7 +37,8 @@ module Ace
35
37
  display_manager = create_display_manager
36
38
  process_monitor = ProcessMonitor.new(
37
39
  @config.dig("test_suite", "max_parallel") || 10,
38
- package_timeout: @config.dig("test_suite", "timeout")
40
+ package_timeout: @config.dig("test_suite", "timeout"),
41
+ environment_policy: environment_policy
39
42
  )
40
43
 
41
44
  # Enrich packages with historical duration data for scheduling
@@ -89,6 +92,12 @@ module Ace
89
92
 
90
93
  private
91
94
 
95
+ # Hermetic environment policy from suite configuration
96
+ # (test_suite.environment: preserve/overrides/require).
97
+ def environment_policy
98
+ Models::EnvironmentPolicy.from_config(@config.dig("test_suite", "environment"))
99
+ end
100
+
92
101
  def validate_packages!
93
102
  @packages.each do |package|
94
103
  unless Dir.exist?(package["path"])
@@ -10,10 +10,12 @@ module Ace
10
10
  class ProcessMonitor
11
11
  attr_reader :processes, :max_parallel
12
12
 
13
- def initialize(max_parallel = 10, package_timeout: nil, termination_grace_period: 1.0, clock: nil)
13
+ def initialize(max_parallel = 10, package_timeout: nil, termination_grace_period: 1.0, clock: nil,
14
+ environment_policy: Models::EnvironmentPolicy.new)
14
15
  @max_parallel = max_parallel
15
16
  @package_timeout = package_timeout
16
17
  @termination_grace_period = termination_grace_period
18
+ @environment_policy = environment_policy
17
19
  @clock = clock || -> { Time.now }
18
20
  @processes = {}
19
21
  @queue = []
@@ -31,14 +33,22 @@ module Ace
31
33
  # Build command
32
34
  cmd = build_command(package, test_options)
33
35
 
34
- # Start the process
35
- # Strip assignment context vars to prevent tests from resolving to wrong assignments
36
- env = ENV.to_h.merge({
37
- "ACE_ASSIGN_ID" => nil,
38
- "ACE_ASSIGN_FORK_ROOT" => nil
39
- })
36
+ # Launch each package from its own hermetic fixture environment so
37
+ # parallel workers never share (or leak) ambient Lab/ACE/user state.
38
+ # Suite-level fixture configuration propagates to the package worker
39
+ # through the runner-internal channel; the worker merges it under its
40
+ # own runner configuration and the channel never reaches test children.
41
+ fixture_environment = Molecules::FixtureEnvironment.new(
42
+ policy: @environment_policy,
43
+ parent_env: ENV.to_h
44
+ ).build
45
+ fixture_environment.env[Models::EnvironmentPolicy::SUITE_CHANNEL_KEY] =
46
+ @environment_policy.channel_payload
47
+
40
48
  start_time = now
41
- stdin, stdout, stderr, thread = Open3.popen3(env, *cmd, chdir: package["path"], pgroup: true)
49
+ stdin, stdout, stderr, thread = Open3.popen3(
50
+ fixture_environment.env, *cmd, chdir: package["path"], pgroup: true, unsetenv_others: true
51
+ )
42
52
 
43
53
  @processes[package["name"]] = {
44
54
  package: package,
@@ -48,6 +58,7 @@ module Ace
48
58
  stdin: stdin,
49
59
  start_time: start_time,
50
60
  callback: callback,
61
+ fixture_environment: fixture_environment,
51
62
  output: +"",
52
63
  stderr_output: +"",
53
64
  report_root: test_options["report_dir"],
@@ -99,6 +110,7 @@ module Ace
99
110
  collect_remaining_output(process_info)
100
111
  results = build_results(process_info, elapsed, exit_status)
101
112
  close_streams(process_info)
113
+ process_info[:fixture_environment]&.cleanup
102
114
 
103
115
  # Final callback
104
116
  if callback
@@ -164,6 +176,7 @@ module Ace
164
176
  end
165
177
 
166
178
  close_streams(process_info)
179
+ process_info[:fixture_environment]&.cleanup
167
180
  end
168
181
 
169
182
  @processes.clear
@@ -180,7 +193,15 @@ module Ace
180
193
  private
181
194
 
182
195
  def build_command(package, options)
183
- cmd_parts = ["ace-test"]
196
+ # Run the child with this suite's own Ruby binary and a bundler
197
+ # bootstrap: the hermetic fixture env blocks RUBYOPT/BUNDLE_/GEM_ and
198
+ # redirects HOME/XDG, so a PATH-resolved interpreter can fail through
199
+ # manager shims and children have no dependency surface beyond the
200
+ # Ruby install's default gem dir. -rbundler/setup makes bundler
201
+ # discover the checkout Gemfile by walking up from the package cwd,
202
+ # giving every child the same source-of-truth dependency set without
203
+ # ambient env or globally installed gems.
204
+ cmd_parts = [RbConfig.ruby, "-rbundler/setup", ace_test_executable]
184
205
 
185
206
  # Suite package execution intentionally bypasses grouped mode so each
186
207
  # package runs its full target scope as one batch under suite orchestration.
@@ -207,6 +228,10 @@ module Ace
207
228
  cmd_parts
208
229
  end
209
230
 
231
+ def ace_test_executable
232
+ File.expand_path("../../../../exe/ace-test", __dir__)
233
+ end
234
+
210
235
  def now
211
236
  @clock.call
212
237
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module TestRunner
5
- VERSION = "0.26.0"
5
+ VERSION = "0.27.1"
6
6
  end
7
7
  end
@@ -15,6 +15,12 @@ require "time"
15
15
 
16
16
  require_relative "test_runner/version"
17
17
 
18
+ module Ace
19
+ module TestRunner
20
+ class Error < StandardError; end
21
+ end
22
+ end
23
+
18
24
  # CLI and commands
19
25
  require_relative "test_runner/cli"
20
26
 
@@ -23,6 +29,7 @@ require_relative "test_runner/models/test_result"
23
29
  require_relative "test_runner/models/test_failure"
24
30
  require_relative "test_runner/models/test_configuration"
25
31
  require_relative "test_runner/models/test_report"
32
+ require_relative "test_runner/models/environment_policy"
26
33
 
27
34
  # Atoms - Basic utilities (always needed)
28
35
  require_relative "test_runner/atoms/test_detector"
@@ -32,8 +39,10 @@ require_relative "test_runner/atoms/timestamp_generator"
32
39
  require_relative "test_runner/atoms/lazy_loader"
33
40
  require_relative "test_runner/atoms/report_path_resolver"
34
41
  require_relative "test_runner/atoms/report_directory_resolver"
42
+ require_relative "test_runner/atoms/environment_sanitizer"
35
43
 
36
44
  # Molecules - Core operations (always needed for basic test running)
45
+ require_relative "test_runner/molecules/fixture_environment"
37
46
  require_relative "test_runner/molecules/test_executor"
38
47
  require_relative "test_runner/molecules/failure_analyzer"
39
48
  require_relative "test_runner/molecules/report_storage"
@@ -54,8 +63,6 @@ require_relative "test_runner/organisms/report_generator"
54
63
 
55
64
  module Ace
56
65
  module TestRunner
57
- class Error < StandardError; end
58
-
59
66
  # Define module namespaces
60
67
  module Commands; end
61
68
 
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-test-runner
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.26.0
4
+ version: 0.27.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-09-27 00:00:00.000000000 Z
10
+ date: 2026-09-29 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-cli
@@ -171,6 +171,7 @@ files:
171
171
  - exe/ace-test-suite
172
172
  - lib/ace/test_runner.rb
173
173
  - lib/ace/test_runner/atoms/command_builder.rb
174
+ - lib/ace/test_runner/atoms/environment_sanitizer.rb
174
175
  - lib/ace/test_runner/atoms/lazy_loader.rb
175
176
  - lib/ace/test_runner/atoms/line_number_resolver.rb
176
177
  - lib/ace/test_runner/atoms/report_directory_resolver.rb
@@ -187,6 +188,7 @@ files:
187
188
  - lib/ace/test_runner/formatters/markdown_formatter.rb
188
189
  - lib/ace/test_runner/formatters/progress_file_formatter.rb
189
190
  - lib/ace/test_runner/formatters/progress_formatter.rb
191
+ - lib/ace/test_runner/models/environment_policy.rb
190
192
  - lib/ace/test_runner/models/test_configuration.rb
191
193
  - lib/ace/test_runner/models/test_failure.rb
192
194
  - lib/ace/test_runner/models/test_report.rb
@@ -197,6 +199,7 @@ files:
197
199
  - lib/ace/test_runner/molecules/deprecation_fixer.rb
198
200
  - lib/ace/test_runner/molecules/failed_package_reporter.rb
199
201
  - lib/ace/test_runner/molecules/failure_analyzer.rb
202
+ - lib/ace/test_runner/molecules/fixture_environment.rb
200
203
  - lib/ace/test_runner/molecules/in_process_runner.rb
201
204
  - lib/ace/test_runner/molecules/package_resolver.rb
202
205
  - lib/ace/test_runner/molecules/pattern_resolver.rb