padded_cell 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +22 -0
- data/Cargo.lock +518 -0
- data/Cargo.toml +41 -0
- data/LICENSE.txt +21 -0
- data/README.md +184 -0
- data/exe/padded_cell +6 -0
- data/ext/padded_cell_native/core/Cargo.toml +20 -0
- data/ext/padded_cell_native/core/README.md +50 -0
- data/ext/padded_cell_native/core/src/discover/tests.rs +107 -0
- data/ext/padded_cell_native/core/src/discover.rs +454 -0
- data/ext/padded_cell_native/core/src/hash/tests.rs +13 -0
- data/ext/padded_cell_native/core/src/hash.rs +13 -0
- data/ext/padded_cell_native/core/src/lib.rs +30 -0
- data/ext/padded_cell_native/core/src/plan/tests.rs +61 -0
- data/ext/padded_cell_native/core/src/plan.rs +109 -0
- data/ext/padded_cell_native/core/src/rng/tests.rs +17 -0
- data/ext/padded_cell_native/core/src/rng.rs +28 -0
- data/ext/padded_cell_native/extconf.rb +46 -0
- data/ext/padded_cell_native/ffi/Cargo.toml +19 -0
- data/ext/padded_cell_native/ffi/src/lib.rs +120 -0
- data/lib/padded_cell/active_support_hooks.rb +30 -0
- data/lib/padded_cell/cell.rb +87 -0
- data/lib/padded_cell/cli.rb +97 -0
- data/lib/padded_cell/discovery.rb +24 -0
- data/lib/padded_cell/hooks.rb +33 -0
- data/lib/padded_cell/loader.rb +82 -0
- data/lib/padded_cell/native.rb +23 -0
- data/lib/padded_cell/pool.rb +202 -0
- data/lib/padded_cell/railtie.rb +18 -0
- data/lib/padded_cell/reporters.rb +106 -0
- data/lib/padded_cell/results.rb +108 -0
- data/lib/padded_cell/runner.rb +131 -0
- data/lib/padded_cell/scheduler.rb +21 -0
- data/lib/padded_cell/selection.rb +78 -0
- data/lib/padded_cell/timings.rb +30 -0
- data/lib/padded_cell/version.rb +5 -0
- data/lib/padded_cell.rb +29 -0
- metadata +113 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
|
|
3
|
+
#[test]
|
|
4
|
+
fn matches_the_reference_sequence() {
|
|
5
|
+
// From Vigna's reference implementation, seed 1234567.
|
|
6
|
+
let mut rng = SplitMix64::new(1_234_567);
|
|
7
|
+
let expected = [
|
|
8
|
+
6_457_827_717_110_365_317,
|
|
9
|
+
3_203_168_211_198_807_973,
|
|
10
|
+
9_817_491_932_198_370_423,
|
|
11
|
+
4_593_380_528_125_082_431,
|
|
12
|
+
16_408_922_859_458_223_821,
|
|
13
|
+
];
|
|
14
|
+
for value in expected {
|
|
15
|
+
assert_eq!(rng.next_u64(), value);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
//! Vigna's `SplitMix64`: std has no seedable generator, and a fixed algorithm
|
|
2
|
+
//! gives a seed the same order on every platform and in every release.
|
|
3
|
+
|
|
4
|
+
#[cfg(test)]
|
|
5
|
+
mod tests;
|
|
6
|
+
|
|
7
|
+
/// A `SplitMix64` generator.
|
|
8
|
+
#[derive(Debug, Clone)]
|
|
9
|
+
pub struct SplitMix64 {
|
|
10
|
+
state: u64,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
impl SplitMix64 {
|
|
14
|
+
/// A generator starting from `seed`.
|
|
15
|
+
#[must_use]
|
|
16
|
+
pub const fn new(seed: u64) -> Self {
|
|
17
|
+
Self { state: seed }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/// The next 64 random bits.
|
|
21
|
+
pub const fn next_u64(&mut self) -> u64 {
|
|
22
|
+
self.state = self.state.wrapping_add(0x9e37_79b9_7f4a_7c15);
|
|
23
|
+
let mut z = self.state;
|
|
24
|
+
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
|
|
25
|
+
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
|
|
26
|
+
z ^ (z >> 31)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# padded_cell's discovery and scheduling are Rust: without the extension
|
|
4
|
+
# the gem cannot work, so a missing toolchain stops the install with the reason.
|
|
5
|
+
def fail_native(reason)
|
|
6
|
+
abort "padded_cell: #{reason}. padded_cell needs its native extension; precompiled gems exist for " \
|
|
7
|
+
'Linux (glibc 2.34+, musl), macOS 13+ and FreeBSD 15.1.'
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def command?(command) = system(command, out: File::NULL, err: File::NULL)
|
|
11
|
+
|
|
12
|
+
# rb_sys writes a GNU Makefile; RubyGems runs the system make (BSD make here).
|
|
13
|
+
BSD = RUBY_PLATFORM.include?('bsd')
|
|
14
|
+
|
|
15
|
+
fail_native("#{RUBY_ENGINE} is not supported (CRuby only)") unless RUBY_ENGINE == 'ruby'
|
|
16
|
+
fail_native('Cargo not found (install Rust 1.99+ from https://rustup.rs)') unless command?('cargo --version')
|
|
17
|
+
fail_native('GNU make not found (pkg install gmake)') if BSD && !command?('gmake --version')
|
|
18
|
+
|
|
19
|
+
require 'mkmf'
|
|
20
|
+
require 'rb_sys/mkmf'
|
|
21
|
+
|
|
22
|
+
create_rust_makefile('padded_cell/padded_cell_native') do |r|
|
|
23
|
+
r.ext_dir = 'ffi'
|
|
24
|
+
r.profile = ENV.fetch('RB_SYS_CARGO_PROFILE', :release).to_sym
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# BSD make reads BSDmakefile first (GNU make ignores it): delegate to gmake.
|
|
28
|
+
if BSD
|
|
29
|
+
File.write('BSDmakefile', <<~MAKE)
|
|
30
|
+
GMAKE_VARS =
|
|
31
|
+
.for var in DESTDIR sitearchdir sitelibdir
|
|
32
|
+
.if defined(${var})
|
|
33
|
+
GMAKE_VARS += ${var}="${${var}}"
|
|
34
|
+
.endif
|
|
35
|
+
.endfor
|
|
36
|
+
|
|
37
|
+
# BSD make exports its own job flags (-J) in MAKEFLAGS; gmake rejects them.
|
|
38
|
+
GMAKE = env -u MAKEFLAGS -u MFLAGS gmake -f Makefile
|
|
39
|
+
|
|
40
|
+
all:
|
|
41
|
+
\t${GMAKE} ${GMAKE_VARS}
|
|
42
|
+
|
|
43
|
+
.DEFAULT:
|
|
44
|
+
\t${GMAKE} ${GMAKE_VARS} $@
|
|
45
|
+
MAKE
|
|
46
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "padded_cell_native"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Ruby bindings for the padded_cell crate"
|
|
5
|
+
publish = false
|
|
6
|
+
edition.workspace = true
|
|
7
|
+
rust-version.workspace = true
|
|
8
|
+
authors.workspace = true
|
|
9
|
+
license.workspace = true
|
|
10
|
+
|
|
11
|
+
[lib]
|
|
12
|
+
crate-type = ["cdylib"]
|
|
13
|
+
|
|
14
|
+
[lints]
|
|
15
|
+
workspace = true
|
|
16
|
+
|
|
17
|
+
[dependencies]
|
|
18
|
+
magnus.workspace = true
|
|
19
|
+
padded_cell.workspace = true
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
//! Ruby bindings for the `padded_cell` crate. Only primitives cross: each
|
|
2
|
+
//! function mirrors a `PaddedCell::Engine` method; the runner is the only
|
|
3
|
+
//! caller, so arguments are trusted.
|
|
4
|
+
//!
|
|
5
|
+
//! Nothing is copied twice: Ruby strings are read in place (see
|
|
6
|
+
//! `with_bytes`), files are read by Rust, and results are written straight
|
|
7
|
+
//! into Ruby objects.
|
|
8
|
+
#![expect(
|
|
9
|
+
clippy::needless_pass_by_value,
|
|
10
|
+
reason = "magnus converts Ruby arguments into owned values; every function takes them"
|
|
11
|
+
)]
|
|
12
|
+
|
|
13
|
+
use std::ffi::OsStr;
|
|
14
|
+
use std::os::unix::ffi::OsStrExt;
|
|
15
|
+
use std::path::Path;
|
|
16
|
+
|
|
17
|
+
use magnus::prelude::*;
|
|
18
|
+
use magnus::{Error, RArray, RString, Ruby, function};
|
|
19
|
+
use padded_cell::{Discovery, FileError};
|
|
20
|
+
|
|
21
|
+
/// `Errno::*` for the path, as Ruby's own file reading raises.
|
|
22
|
+
fn file_error(ruby: &Ruby, error: &FileError) -> Error {
|
|
23
|
+
let path = error.path.display().to_string();
|
|
24
|
+
let Some(errno) = error.source.raw_os_error() else {
|
|
25
|
+
return Error::new(ruby.exception_io_error(), error.to_string());
|
|
26
|
+
};
|
|
27
|
+
match ruby.exception_system_call_error().new_instance((path, errno)) {
|
|
28
|
+
Ok(exception) => exception.into(),
|
|
29
|
+
Err(error) => error,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/// The strings of a Ruby array. They stay reachable for the GC through the
|
|
34
|
+
/// array, a Ruby argument.
|
|
35
|
+
fn strings(array: RArray) -> Result<Vec<RString>, Error> {
|
|
36
|
+
(0..array.len()).map(|index| array.entry(index.cast_signed())).collect()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// Lends `f` the bytes of Ruby strings, without copying them.
|
|
40
|
+
///
|
|
41
|
+
/// Only call it with closures that run pure Rust (the `padded_cell` crate):
|
|
42
|
+
/// see the SAFETY comment.
|
|
43
|
+
#[expect(unsafe_code, reason = "reads Ruby strings in place instead of copying them")]
|
|
44
|
+
fn with_bytes<R>(strings: &[RString], f: impl FnOnce(&[&[u8]]) -> R) -> R {
|
|
45
|
+
// SAFETY: `RString::as_slice` requires that Ruby neither frees, moves nor
|
|
46
|
+
// modifies the strings while the slices live. The slices only live
|
|
47
|
+
// during `f`, which runs Rust code that never calls into Ruby: it
|
|
48
|
+
// allocates no Ruby object (so no GC can run) and this thread keeps the
|
|
49
|
+
// GVL (so no other Ruby thread can mutate a string). The strings stay
|
|
50
|
+
// referenced by the caller's Ruby arguments.
|
|
51
|
+
let bytes: Vec<&[u8]> = strings.iter().map(|string| unsafe { string.as_slice() }).collect();
|
|
52
|
+
f(&bytes)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// `[[class_name, method_name, start_line, end_line], ...]`. A class name is
|
|
56
|
+
/// one frozen Ruby string, shared by the rows of its tests.
|
|
57
|
+
fn rows(ruby: &Ruby, discovery: &Discovery) -> Result<RArray, Error> {
|
|
58
|
+
// A Ruby array keeps the class names reachable for the GC while the rows
|
|
59
|
+
// are built.
|
|
60
|
+
let classes = ruby.ary_new_capa(discovery.classes().len());
|
|
61
|
+
for class in discovery.classes() {
|
|
62
|
+
let class = ruby.str_new(class);
|
|
63
|
+
class.freeze();
|
|
64
|
+
classes.push(class)?;
|
|
65
|
+
}
|
|
66
|
+
let rows = ruby.ary_new_capa(discovery.len());
|
|
67
|
+
for test in discovery {
|
|
68
|
+
let class: RString = classes.entry(test.class.cast_signed())?;
|
|
69
|
+
rows.push((class, ruby.str_new(test.name), test.start_line, test.end_line))?;
|
|
70
|
+
}
|
|
71
|
+
Ok(rows)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// Checked against `PaddedCell::VERSION` before the extension is used.
|
|
75
|
+
const fn version() -> &'static str {
|
|
76
|
+
env!("CARGO_PKG_VERSION")
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
fn discover(ruby: &Ruby, source: RString) -> Result<RArray, Error> {
|
|
80
|
+
let discovery = with_bytes(&[source], |bytes| padded_cell::discover(bytes[0]));
|
|
81
|
+
rows(ruby, &discovery)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/// Reads and parses on scoped threads: none is left running when Ruby forks
|
|
85
|
+
/// next.
|
|
86
|
+
fn discover_files(ruby: &Ruby, paths: RArray) -> Result<RArray, Error> {
|
|
87
|
+
let found = with_bytes(&strings(paths)?, |paths| {
|
|
88
|
+
let paths: Vec<&Path> =
|
|
89
|
+
paths.iter().map(|path| Path::new(OsStr::from_bytes(path))).collect();
|
|
90
|
+
padded_cell::discover_files(&paths)
|
|
91
|
+
})
|
|
92
|
+
.map_err(|error| file_error(ruby, &error))?;
|
|
93
|
+
let files = ruby.ary_new_capa(found.len());
|
|
94
|
+
for discovery in &found {
|
|
95
|
+
files.push(rows(ruby, discovery)?)?;
|
|
96
|
+
}
|
|
97
|
+
Ok(files)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
fn plan(
|
|
101
|
+
classes: RArray,
|
|
102
|
+
durations: Vec<f64>,
|
|
103
|
+
seed: u64,
|
|
104
|
+
shard_index: u64,
|
|
105
|
+
shard_count: u64,
|
|
106
|
+
) -> Result<Vec<Vec<usize>>, Error> {
|
|
107
|
+
Ok(with_bytes(&strings(classes)?, |classes| {
|
|
108
|
+
padded_cell::plan(classes, &durations, seed, shard_index, shard_count)
|
|
109
|
+
}))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
#[magnus::init]
|
|
113
|
+
fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
114
|
+
let module = ruby.define_module("PaddedCellNative")?;
|
|
115
|
+
module.define_module_function("version", function!(version, 0))?;
|
|
116
|
+
module.define_module_function("discover", function!(discover, 1))?;
|
|
117
|
+
module.define_module_function("discover_files", function!(discover_files, 1))?;
|
|
118
|
+
module.define_module_function("plan", function!(plan, 5))?;
|
|
119
|
+
Ok(())
|
|
120
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/testing/parallelization'
|
|
4
|
+
|
|
5
|
+
module PaddedCell
|
|
6
|
+
# Runs ActiveSupport's parallelization hooks around padded_cell's workers,
|
|
7
|
+
# as Rails runs them around its own: parallelize_before_fork,
|
|
8
|
+
# parallelize_setup, parallelize_teardown, and Active Record's per-worker
|
|
9
|
+
# test databases (app_test-0, app_test-1...).
|
|
10
|
+
#
|
|
11
|
+
# The runner installs this after loading the tests when ActiveSupport::TestCase
|
|
12
|
+
# is defined. Any parallelize(workers:) call in test_helper.rb stays inert:
|
|
13
|
+
# padded_cell is the executor.
|
|
14
|
+
module ActiveSupportHooks
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def install
|
|
18
|
+
return if @installed
|
|
19
|
+
|
|
20
|
+
@installed = true
|
|
21
|
+
parallelization = ActiveSupport::Testing::Parallelization
|
|
22
|
+
PaddedCell.before_fork { parallelization.before_fork_hooks.each(&:call) }
|
|
23
|
+
PaddedCell.after_fork do |worker|
|
|
24
|
+
ActiveSupport::TestCase.parallel_worker_id = worker if ActiveSupport::TestCase.respond_to?(:parallel_worker_id=)
|
|
25
|
+
parallelization.after_fork_hooks.each { it.call(worker) }
|
|
26
|
+
end
|
|
27
|
+
PaddedCell.cleanup { |worker| parallelization.run_cleanup_hooks.each { it.call(worker) } }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaddedCell
|
|
4
|
+
# One test in its own forked process. Whatever the test does to its process
|
|
5
|
+
# (crash, exit, hang, monkey patches, leaked threads) stays in the cell.
|
|
6
|
+
module Cell
|
|
7
|
+
CHUNK = 65_536
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Runs klass#name in a fork and returns its Minitest::Result, or an error
|
|
12
|
+
# result when the cell died or ran past timeout seconds.
|
|
13
|
+
#
|
|
14
|
+
# close: descriptors the cell must not keep open (the worker's socket)
|
|
15
|
+
def run(klass, name, seed:, timeout: nil, close: [])
|
|
16
|
+
# Binary: the result is Marshal data, and with Encoding.default_internal
|
|
17
|
+
# set (Rails sets it) a text-mode pipe transcodes what is written to it.
|
|
18
|
+
reader, writer = IO.pipe
|
|
19
|
+
[reader, writer].each(&:binmode)
|
|
20
|
+
started = Minitest.clock_time
|
|
21
|
+
pid = fork_cell(klass, name, seed, reader, writer, close)
|
|
22
|
+
writer.close
|
|
23
|
+
collect(pid, reader, klass, name, started, timeout)
|
|
24
|
+
ensure
|
|
25
|
+
reader&.close
|
|
26
|
+
writer&.close unless writer&.closed?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Runs klass#name in this process. A test calling exit is reported as an
|
|
30
|
+
# error instead of ending the process.
|
|
31
|
+
def execute(klass, name)
|
|
32
|
+
klass.new(name).run
|
|
33
|
+
rescue SystemExit => e
|
|
34
|
+
Results.crashed(klass, name, "exit(#{e.status}) called during the test")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def fork_cell(klass, name, seed, reader, writer, close)
|
|
38
|
+
$stdout.flush
|
|
39
|
+
$stderr.flush
|
|
40
|
+
Process.fork do
|
|
41
|
+
status = 1
|
|
42
|
+
begin
|
|
43
|
+
reader.close
|
|
44
|
+
close.each(&:close)
|
|
45
|
+
srand(seed)
|
|
46
|
+
writer.write(Results.dump(execute(klass, name)))
|
|
47
|
+
status = 0
|
|
48
|
+
rescue Exception => e # anything: report it, then exit!
|
|
49
|
+
warn "padded_cell: cell #{klass}##{name} failed to report: #{e.class}: #{e.message}"
|
|
50
|
+
ensure
|
|
51
|
+
# exit! skips at_exit handlers: the cell is not the test run.
|
|
52
|
+
exit!(status)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def collect(pid, reader, klass, name, started, timeout)
|
|
58
|
+
data = read(reader, timeout && (started + timeout))
|
|
59
|
+
elapsed = Minitest.clock_time - started
|
|
60
|
+
if data.nil?
|
|
61
|
+
Process.kill(:KILL, pid)
|
|
62
|
+
Process.wait(pid)
|
|
63
|
+
return Results.crashed(klass, name, "timed out after #{timeout}s", time: elapsed)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
_, status = Process.wait2(pid)
|
|
67
|
+
return Results.load(data, klass, name) unless data.empty?
|
|
68
|
+
|
|
69
|
+
Results.crashed(klass, name, Results.describe(status, 'cell'), time: elapsed)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Everything until EOF, or nil when the deadline passes first.
|
|
73
|
+
def read(io, deadline)
|
|
74
|
+
data = String.new(encoding: Encoding::BINARY)
|
|
75
|
+
loop do
|
|
76
|
+
remaining = deadline && (deadline - Minitest.clock_time)
|
|
77
|
+
return if remaining&.<= 0
|
|
78
|
+
next unless io.wait_readable(remaining)
|
|
79
|
+
|
|
80
|
+
chunk = io.read_nonblock(CHUNK, exception: false)
|
|
81
|
+
return data if chunk.nil?
|
|
82
|
+
|
|
83
|
+
data << chunk if chunk.is_a?(String)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'optparse'
|
|
4
|
+
require_relative 'runner'
|
|
5
|
+
|
|
6
|
+
module PaddedCell
|
|
7
|
+
# The padded_cell command.
|
|
8
|
+
module CLI
|
|
9
|
+
BANNER = <<~TEXT
|
|
10
|
+
Usage: padded_cell [options] [paths...]
|
|
11
|
+
|
|
12
|
+
Runs a Minitest (or Rails) test suite, every test in its own forked cell.
|
|
13
|
+
|
|
14
|
+
Paths are files, directories, globs or file:line[-line][:line...].
|
|
15
|
+
Without paths: test/**/*_test.rb, minus test/{system,dummy,fixtures}.
|
|
16
|
+
|
|
17
|
+
TEXT
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# Exit status: 0 passed, 1 failed, 2 usage or load error, 130 interrupted.
|
|
22
|
+
def start(argv, io: $stdout)
|
|
23
|
+
options = { io: }
|
|
24
|
+
parser(options).parse!(argv)
|
|
25
|
+
options[:paths] = argv
|
|
26
|
+
Runner.new(**options).run
|
|
27
|
+
rescue OptionParser::ParseError, ArgumentError, LoadFailure, Unsupported, SystemCallError => e
|
|
28
|
+
warn "padded_cell: #{e.message}"
|
|
29
|
+
2
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def parser(options)
|
|
33
|
+
OptionParser.new do |opts|
|
|
34
|
+
opts.banner = BANNER
|
|
35
|
+
opts.version = VERSION
|
|
36
|
+
runner_options(opts, options)
|
|
37
|
+
output_options(opts, options)
|
|
38
|
+
opts.on('--version', 'Print the version') do
|
|
39
|
+
options[:io].puts "padded_cell #{VERSION}"
|
|
40
|
+
exit
|
|
41
|
+
end
|
|
42
|
+
opts.on('-h', '--help', 'Show this help') do
|
|
43
|
+
options[:io].puts opts
|
|
44
|
+
exit
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def runner_options(opts, options)
|
|
50
|
+
opts.on('-j', '--jobs N', Integer, "Workers (default: #{Etc.nprocessors})") do
|
|
51
|
+
options[:jobs] = positive(it)
|
|
52
|
+
end
|
|
53
|
+
opts.on('-s', '--seed SEED', Integer, 'Order seed (default: random, or $SEED)') { options[:seed] = it }
|
|
54
|
+
opts.on('-n', '-i', '--name', '--include PATTERN', 'Only tests matching (/regexp/ or exact name)') do
|
|
55
|
+
options[:include] = it
|
|
56
|
+
end
|
|
57
|
+
opts.on('-e', '-x', '--exclude PATTERN', 'Skip tests matching (/regexp/ or exact name)') do
|
|
58
|
+
options[:exclude] = it
|
|
59
|
+
end
|
|
60
|
+
opts.on('--shard I/N', 'Run shard I of N (1-based), split by class') { options[:shard] = shard(it) }
|
|
61
|
+
opts.on('--isolation MODE', %w[test worker], 'test: a fork per test; worker: tests share their worker ' \
|
|
62
|
+
'(default: worker with Active Record fixtures, else test)') do
|
|
63
|
+
options[:isolation] = it.to_sym
|
|
64
|
+
end
|
|
65
|
+
opts.on('--timeout SECONDS', Float, 'Kill and fail a test running longer') do
|
|
66
|
+
options[:timeout] = positive(it)
|
|
67
|
+
end
|
|
68
|
+
opts.on('--fail-fast', 'Stop dispatching after the first failure') { options[:fail_fast] = true }
|
|
69
|
+
opts.on('--list', 'List the tests that would run (static, loads nothing)') { options[:list] = true }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def output_options(opts, options)
|
|
73
|
+
opts.on('-v', '--verbose', 'One line per test, with timings') { options[:verbose] = true }
|
|
74
|
+
opts.on('--show-skips', 'Show skipped tests at the end') { options[:show_skips] = true }
|
|
75
|
+
opts.on('--junit PATH', 'Also write JUnit XML to PATH') { options[:junit] = it }
|
|
76
|
+
opts.on('--json PATH', 'Also write JSON lines to PATH') { options[:json] = it }
|
|
77
|
+
opts.on('--timings PATH', 'Timings file (default: tmp/padded_cell/timings.tsv)') { options[:timings] = it }
|
|
78
|
+
opts.on('--no-timings', 'Neither read nor write timings') { options[:timings] = nil }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# OptionParser names the switch in the message.
|
|
82
|
+
def positive(value)
|
|
83
|
+
raise OptionParser::InvalidArgument, "#{value} (must be positive)" unless value.positive?
|
|
84
|
+
|
|
85
|
+
value
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def shard(value)
|
|
89
|
+
index, count = value.split('/', 2).map { Integer(it, 10, exception: false) }
|
|
90
|
+
unless index && count&.positive? && index.between?(1, count)
|
|
91
|
+
raise OptionParser::InvalidArgument, "--shard #{value} (expected I/N, 1 <= I <= N)"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
[index - 1, count]
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaddedCell
|
|
4
|
+
# A statically discovered test.
|
|
5
|
+
TestRef = Data.define(:file, :class_name, :name, :start_line, :end_line) do
|
|
6
|
+
def id = "#{class_name}##{name}"
|
|
7
|
+
|
|
8
|
+
def lines = start_line..end_line
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Static discovery, without loading anything.
|
|
12
|
+
module Discovery
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# TestRefs for files, in file order, then source order. The extension
|
|
16
|
+
# reads and parses the files itself, on every core.
|
|
17
|
+
def call(files) = PaddedCellNative.discover_files(files).zip(files).flat_map { |rows, file| refs(rows, file) }
|
|
18
|
+
|
|
19
|
+
# TestRefs for one source string.
|
|
20
|
+
def source(source, file: '(source)') = refs(PaddedCellNative.discover(source), file)
|
|
21
|
+
|
|
22
|
+
def refs(rows, file) = rows.map { TestRef.new(file, *it) }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaddedCell
|
|
4
|
+
# Process lifecycle hooks, for anything that must not be shared across
|
|
5
|
+
# forks (database connections, sockets, per-worker resources):
|
|
6
|
+
#
|
|
7
|
+
# PaddedCell.before_fork { Database.disconnect }
|
|
8
|
+
# PaddedCell.after_fork { |worker| Database.connect("app_test_#{worker}") }
|
|
9
|
+
# PaddedCell.cleanup { |worker| Database.drop("app_test_#{worker}") }
|
|
10
|
+
#
|
|
11
|
+
# before_fork runs in the runner before workers start, after_fork in each
|
|
12
|
+
# worker (worker index 0...jobs) and cleanup when a worker finishes.
|
|
13
|
+
# Cells (one fork per test) run no hooks: they inherit their worker.
|
|
14
|
+
module Hooks
|
|
15
|
+
@before_fork = []
|
|
16
|
+
@after_fork = []
|
|
17
|
+
@cleanup = []
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def before_fork(&block) = @before_fork << block
|
|
21
|
+
|
|
22
|
+
def after_fork(&block) = @after_fork << block
|
|
23
|
+
|
|
24
|
+
def cleanup(&block) = @cleanup << block
|
|
25
|
+
|
|
26
|
+
def run_before_fork = @before_fork.each(&:call)
|
|
27
|
+
|
|
28
|
+
def run_after_fork(worker) = @after_fork.each { it.call(worker) }
|
|
29
|
+
|
|
30
|
+
def run_cleanup(worker) = @cleanup.each { it.call(worker) }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaddedCell
|
|
4
|
+
# A test file that would not load.
|
|
5
|
+
class LoadFailure < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Loads test files once, in the runner, so every worker and cell starts
|
|
8
|
+
# from the same preloaded (copy-on-write) process.
|
|
9
|
+
module Loader
|
|
10
|
+
# Test helpers require minitest/autorun (or Rails' equivalent): its
|
|
11
|
+
# at_exit would run the whole suite again, unisolated.
|
|
12
|
+
module NoAutorun
|
|
13
|
+
def autorun = nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def call(files, seed:)
|
|
19
|
+
Minitest.singleton_class.prepend(NoAutorun)
|
|
20
|
+
Minitest.seed = seed
|
|
21
|
+
# What `rake test` and `rails test` put on the load path.
|
|
22
|
+
%w[test lib].each do |dir|
|
|
23
|
+
path = File.expand_path(dir)
|
|
24
|
+
$LOAD_PATH.unshift(path) if File.directory?(path) && !$LOAD_PATH.include?(path)
|
|
25
|
+
end
|
|
26
|
+
files.each { load_file(it) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def load_file(file)
|
|
30
|
+
require file
|
|
31
|
+
rescue ScriptError, StandardError => e
|
|
32
|
+
raise LoadFailure, "#{file}: #{e.class}: #{e.message}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# [klass, method_name] for every loaded test, the way Minitest finds them.
|
|
36
|
+
def tests
|
|
37
|
+
Minitest::Runnable.runnables.flat_map do |klass|
|
|
38
|
+
klass.runnable_methods.map { [klass, it] }
|
|
39
|
+
rescue NotImplementedError
|
|
40
|
+
[]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Which loaded tests run: Minitest's --include/--exclude, plus file:line.
|
|
46
|
+
class Filter
|
|
47
|
+
def initialize(selection, refs, include: nil, exclude: nil)
|
|
48
|
+
@selection = selection
|
|
49
|
+
@include = pattern(include)
|
|
50
|
+
@exclude = pattern(exclude)
|
|
51
|
+
@ends = refs.to_h { [[it.file, it.start_line], it.end_line] }
|
|
52
|
+
@selected = selection.files.to_h { [it, true] }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def call(klass, name)
|
|
56
|
+
id = "#{klass}##{name}"
|
|
57
|
+
return false if @include && !matches?(@include, name, id)
|
|
58
|
+
return false if @exclude && matches?(@exclude, name, id)
|
|
59
|
+
|
|
60
|
+
!@selection.line_filtered? || line_selected?(klass, name)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# "/re/" is a Regexp, anything else an exact name, as in Minitest.
|
|
66
|
+
def pattern(value)
|
|
67
|
+
match = value.is_a?(String) && %r{\A/(.*)/\z}.match(value)
|
|
68
|
+
match ? Regexp.new(match[1]) : value
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def matches?(pattern, name, id) = [name, id].any? { pattern === it }
|
|
72
|
+
|
|
73
|
+
# With file:line arguments, a test runs when its file was named and its
|
|
74
|
+
# definition overlaps a requested line, or its file was named whole.
|
|
75
|
+
def line_selected?(klass, name)
|
|
76
|
+
file, line = klass.instance_method(name).source_location
|
|
77
|
+
return false unless file && @selected[file]
|
|
78
|
+
|
|
79
|
+
@selection.covers?(file, line..@ends.fetch([file, line], line))
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Loads the Rust extension, which does discovery and scheduling. There is no
|
|
4
|
+
# fallback: platform gems ship it precompiled under the Ruby ABI version, the
|
|
5
|
+
# source gem compiles it at install, and a checkout builds it with
|
|
6
|
+
# `rake compile`.
|
|
7
|
+
|
|
8
|
+
require 'rbconfig'
|
|
9
|
+
|
|
10
|
+
shipped = File.join(__dir__, RUBY_VERSION[/\A\d+\.\d+/], "padded_cell_native.#{RbConfig::CONFIG['DLEXT']}")
|
|
11
|
+
begin
|
|
12
|
+
File.exist?(shipped) ? require(shipped) : require('padded_cell/padded_cell_native')
|
|
13
|
+
rescue LoadError => e
|
|
14
|
+
raise LoadError, "padded_cell: native extension not loaded (#{e.message}). Precompiled gems need " \
|
|
15
|
+
'glibc 2.34+ (Linux), macOS 13+ or FreeBSD 15.1; elsewhere the gem builds it with Rust ' \
|
|
16
|
+
'at install. In a checkout, run `rake compile`.'
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# A stale build may implement other semantics.
|
|
20
|
+
unless PaddedCellNative.version == PaddedCell::VERSION
|
|
21
|
+
raise LoadError, "padded_cell: native extension #{PaddedCellNative.version} does not match padded_cell " \
|
|
22
|
+
"#{PaddedCell::VERSION}: rebuild it (`rake compile` in a checkout, or reinstall the gem)"
|
|
23
|
+
end
|