selective-ruby-core 0.2.9 → 0.2.10
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 +4 -4
- data/LICENSE +0 -0
- data/Rakefile +20 -0
- data/ext/selective_tracer/extconf.rb +41 -0
- data/ext/selective_tracer/selective_tracer.c +336 -0
- data/lib/selective/ruby/core/controller.rb +73 -6
- data/lib/selective/ruby/core/file_correlator.rb +0 -0
- data/lib/selective/ruby/core/helper.rb +0 -0
- data/lib/selective/ruby/core/named_pipe.rb +0 -0
- data/lib/selective/ruby/core/test_map/recorder.rb +61 -0
- data/lib/selective/ruby/core/test_map/tracer.rb +120 -0
- data/lib/selective/ruby/core/test_map/tree.rb +125 -0
- data/lib/selective/ruby/core/test_map/uploader.rb +108 -0
- data/lib/selective/ruby/core/test_map.rb +89 -0
- data/lib/selective/ruby/core/version.rb +1 -1
- data/lib/selective-ruby-core.rb +0 -0
- data/selective-ruby-core.gemspec +53 -0
- metadata +15 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9f6dbb5b565e9daa33dd9734e130efafcbb431372ee74eafc3b1e61356b94f2a
|
|
4
|
+
data.tar.gz: 9a4252a640d2a396d6d8b6cde0817566c9940ff569bceeb049260a062f6b9e73
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5db64c1685e6c52cf3bca4d08c27b5cc3461f2162b8e9d587fd87872c7f73012d96a14c2260d7b2e6650661e6a07163a0ed379aa6019459beb91591e4ea79dcb
|
|
7
|
+
data.tar.gz: 4f0755272db64264a15727ce35a1d8a896953a0f192617d9b9eff0afbe46b3d8f7f1211e11de45cc4b9987e91d6b5092a6636024e6fefe1a240e585e63da2984
|
data/LICENSE
CHANGED
|
File without changes
|
data/Rakefile
CHANGED
|
@@ -5,6 +5,26 @@ require "rspec/core/rake_task"
|
|
|
5
5
|
|
|
6
6
|
RSpec::Core::RakeTask.new(:spec)
|
|
7
7
|
|
|
8
|
+
# Builds the optional test map tracer into lib/ for development and CI.
|
|
9
|
+
# Installed gems build it through `spec.extensions` instead.
|
|
10
|
+
desc "Compile the native test map tracer"
|
|
11
|
+
task :compile do
|
|
12
|
+
require "fileutils"
|
|
13
|
+
require "rbconfig"
|
|
14
|
+
ext_dir = File.expand_path("ext/selective_tracer", __dir__)
|
|
15
|
+
build_dir = File.expand_path("tmp/selective_tracer", __dir__)
|
|
16
|
+
FileUtils.mkdir_p(build_dir)
|
|
17
|
+
Dir.chdir(build_dir) do
|
|
18
|
+
# extconf must not inherit this process's bundle; it only needs mkmf.
|
|
19
|
+
Bundler.with_unbundled_env do
|
|
20
|
+
sh RbConfig.ruby, File.join(ext_dir, "extconf.rb")
|
|
21
|
+
sh "make"
|
|
22
|
+
end
|
|
23
|
+
built = Dir["selective_tracer.{#{RbConfig::CONFIG["DLEXT"]},bundle,so}"].first
|
|
24
|
+
FileUtils.cp(built, File.expand_path("lib", __dir__)) if built
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
8
28
|
require "standard/rake"
|
|
9
29
|
|
|
10
30
|
task default: %i[spec standard]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The tracer is optional. A customer without a C toolchain (or on an
|
|
4
|
+
# unsupported Ruby) must still be able to install selective-ruby-core and run
|
|
5
|
+
# their suite exactly as before, so any failed check writes a no-op Makefile
|
|
6
|
+
# instead of failing `gem install`. That includes compiling the tracer itself
|
|
7
|
+
# here first: an error in `make` would fail the install, so a Ruby whose
|
|
8
|
+
# headers the source doesn't build against must be caught before it. At
|
|
9
|
+
# runtime the missing library simply means "this runner can't record a test
|
|
10
|
+
# map".
|
|
11
|
+
|
|
12
|
+
require "mkmf"
|
|
13
|
+
|
|
14
|
+
def write_noop_makefile(reason)
|
|
15
|
+
warn "selective_tracer: skipping native build (#{reason}); test map recording will be unavailable"
|
|
16
|
+
File.write("Makefile", <<~MAKE)
|
|
17
|
+
all:
|
|
18
|
+
\t@true
|
|
19
|
+
install:
|
|
20
|
+
\t@true
|
|
21
|
+
clean:
|
|
22
|
+
\t@true
|
|
23
|
+
MAKE
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
begin
|
|
27
|
+
if RUBY_ENGINE != "ruby"
|
|
28
|
+
write_noop_makefile("#{RUBY_ENGINE} is not supported")
|
|
29
|
+
elsif !have_header("ruby/debug.h") || !have_func("rb_profile_frames", "ruby/debug.h")
|
|
30
|
+
write_noop_makefile("required VM hook APIs are missing")
|
|
31
|
+
else
|
|
32
|
+
$CFLAGS << " -O2 -std=c99 -Wall -Wno-unused-parameter" # standard:disable Style/GlobalVars -- mkmf's interface
|
|
33
|
+
if try_compile(File.read(File.expand_path("selective_tracer.c", __dir__)))
|
|
34
|
+
create_makefile("selective_tracer")
|
|
35
|
+
else
|
|
36
|
+
write_noop_makefile("the tracer does not compile on Ruby #{RUBY_VERSION}; see mkmf.log")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
rescue => e
|
|
40
|
+
write_noop_makefile(e.message)
|
|
41
|
+
end
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// selective_tracer — records which source files a single test executes.
|
|
2
|
+
//
|
|
3
|
+
// A test map is built from one question asked of every test: "which files
|
|
4
|
+
// did you run?". This extension answers it with two raw VM event hooks that
|
|
5
|
+
// never call back into Ruby while the test runs:
|
|
6
|
+
//
|
|
7
|
+
// * RUBY_EVENT_LINE | CALL | B_CALL, registered globally so work done on
|
|
8
|
+
// *any* thread is
|
|
9
|
+
// attributed to the running test. Capybara drives a Puma server thread;
|
|
10
|
+
// a hook scoped to the test's own thread (what rotoscope does) would map
|
|
11
|
+
// every system test to nothing and let selection skip them wrongly.
|
|
12
|
+
// Selective runs one test at a time per process, so "everything that ran
|
|
13
|
+
// between start and stop" is the right attribution. Stray background
|
|
14
|
+
// threads can only add files, which makes selection more conservative,
|
|
15
|
+
// never less. CALL and B_CALL are not redundant with LINE: an endless
|
|
16
|
+
// method (`def total = price * qty`) and a one-line block executed from
|
|
17
|
+
// another file emit no line event of their own, so a line-only hook never
|
|
18
|
+
// ties a test to the file they live in.
|
|
19
|
+
//
|
|
20
|
+
// * RUBY_INTERNAL_EVENT_NEWOBJ (optional), which records the class of every
|
|
21
|
+
// plain object allocated. A class whose file only ran at boot — an
|
|
22
|
+
// ActiveRecord model with no methods of its own — executes no lines
|
|
23
|
+
// during a test, so line events alone would never tie the test to it.
|
|
24
|
+
// The classes are resolved to source files in Ruby after the test stops.
|
|
25
|
+
//
|
|
26
|
+
// Why not Ruby's Coverage module: it is process-global, so it cannot run next
|
|
27
|
+
// to SimpleCov, and while it is enabled Bootsnap cannot use its compile cache.
|
|
28
|
+
// Why not TracePoint from Ruby: every event re-enters the interpreter. The
|
|
29
|
+
// design follows what Datadog learned building datadog-ci-rb (line hook with
|
|
30
|
+
// pointer caching, allocation tracking for code-less classes), written for
|
|
31
|
+
// Selective's needs: several project roots, several ignored prefixes, and no
|
|
32
|
+
// Ruby API use inside the allocation hook.
|
|
33
|
+
|
|
34
|
+
#include <ruby.h>
|
|
35
|
+
#include <ruby/debug.h>
|
|
36
|
+
#include <ruby/st.h>
|
|
37
|
+
|
|
38
|
+
#include <stdbool.h>
|
|
39
|
+
#include <string.h>
|
|
40
|
+
|
|
41
|
+
#define PATH_CACHE_SIZE 1024 // power of two
|
|
42
|
+
#define KLASS_CACHE_SIZE 4096 // power of two
|
|
43
|
+
#define MAX_PREFIXES 32
|
|
44
|
+
#define EXEC_EVENTS (RUBY_EVENT_LINE | RUBY_EVENT_CALL | RUBY_EVENT_B_CALL)
|
|
45
|
+
|
|
46
|
+
struct prefix_list {
|
|
47
|
+
int count;
|
|
48
|
+
char *values[MAX_PREFIXES];
|
|
49
|
+
long lengths[MAX_PREFIXES];
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
struct tracer {
|
|
53
|
+
// { path(String) => true } for the test being recorded.
|
|
54
|
+
VALUE files;
|
|
55
|
+
|
|
56
|
+
// Absolute paths are kept only under one of `roots` and outside every
|
|
57
|
+
// `ignored` prefix (bundled gems, Selective's own gems, tmp dirs).
|
|
58
|
+
struct prefix_list roots;
|
|
59
|
+
struct prefix_list ignored;
|
|
60
|
+
|
|
61
|
+
// Consecutive line events almost always come from the same file, and the
|
|
62
|
+
// path pointer of an iseq is stable, so a pointer comparison settles most
|
|
63
|
+
// events. The strings are marked so a freed path's address can't be reused
|
|
64
|
+
// for a different file and wrongly treated as "already seen".
|
|
65
|
+
VALUE last_path;
|
|
66
|
+
VALUE path_cache[PATH_CACHE_SIZE];
|
|
67
|
+
|
|
68
|
+
bool allocations;
|
|
69
|
+
bool running;
|
|
70
|
+
|
|
71
|
+
// Classes allocated during the test. Keys are class VALUEs, pinned by mark.
|
|
72
|
+
st_table *klasses;
|
|
73
|
+
VALUE last_klass;
|
|
74
|
+
VALUE klass_cache[KLASS_CACHE_SIZE];
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
static int mark_klass_i(st_data_t key, st_data_t _value, st_data_t _arg) {
|
|
78
|
+
rb_gc_mark((VALUE)key);
|
|
79
|
+
return ST_CONTINUE;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
static void tracer_mark(void *ptr) {
|
|
83
|
+
struct tracer *t = ptr;
|
|
84
|
+
rb_gc_mark(t->files);
|
|
85
|
+
rb_gc_mark(t->last_path);
|
|
86
|
+
for (int i = 0; i < PATH_CACHE_SIZE; i++) rb_gc_mark(t->path_cache[i]);
|
|
87
|
+
rb_gc_mark(t->last_klass);
|
|
88
|
+
for (int i = 0; i < KLASS_CACHE_SIZE; i++) rb_gc_mark(t->klass_cache[i]);
|
|
89
|
+
if (t->klasses) st_foreach(t->klasses, mark_klass_i, 0);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
static void free_prefixes(struct prefix_list *list) {
|
|
93
|
+
for (int i = 0; i < list->count; i++) xfree(list->values[i]);
|
|
94
|
+
list->count = 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
static void tracer_free(void *ptr) {
|
|
98
|
+
struct tracer *t = ptr;
|
|
99
|
+
// No hook cleanup needed: the VM marks every registered hook's data, so a
|
|
100
|
+
// tracer that is still running is reachable and can't be freed.
|
|
101
|
+
free_prefixes(&t->roots);
|
|
102
|
+
free_prefixes(&t->ignored);
|
|
103
|
+
if (t->klasses) st_free_table(t->klasses);
|
|
104
|
+
xfree(t);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
static size_t tracer_memsize(const void *ptr) {
|
|
108
|
+
return sizeof(struct tracer);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
static const rb_data_type_t tracer_type = {
|
|
112
|
+
.wrap_struct_name = "selective_tracer",
|
|
113
|
+
.function = {.dmark = tracer_mark, .dfree = tracer_free, .dsize = tracer_memsize},
|
|
114
|
+
.flags = RUBY_TYPED_FREE_IMMEDIATELY};
|
|
115
|
+
|
|
116
|
+
static void reset_caches(struct tracer *t) {
|
|
117
|
+
t->last_path = Qnil;
|
|
118
|
+
for (int i = 0; i < PATH_CACHE_SIZE; i++) t->path_cache[i] = Qnil;
|
|
119
|
+
t->last_klass = Qnil;
|
|
120
|
+
for (int i = 0; i < KLASS_CACHE_SIZE; i++) t->klass_cache[i] = Qnil;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
static VALUE tracer_alloc(VALUE klass) {
|
|
124
|
+
struct tracer *t;
|
|
125
|
+
VALUE self = TypedData_Make_Struct(klass, struct tracer, &tracer_type, t);
|
|
126
|
+
t->files = Qnil;
|
|
127
|
+
t->roots.count = 0;
|
|
128
|
+
t->ignored.count = 0;
|
|
129
|
+
t->allocations = false;
|
|
130
|
+
t->running = false;
|
|
131
|
+
t->klasses = NULL;
|
|
132
|
+
reset_caches(t);
|
|
133
|
+
t->files = rb_hash_new();
|
|
134
|
+
t->klasses = st_init_numtable();
|
|
135
|
+
return self;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
static struct tracer *get_tracer(VALUE self) {
|
|
139
|
+
struct tracer *t;
|
|
140
|
+
TypedData_Get_Struct(self, struct tracer, &tracer_type, t);
|
|
141
|
+
return t;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
static bool has_dir_prefix(const char *path, long path_len, const char *prefix, long prefix_len) {
|
|
145
|
+
if (prefix_len > path_len || memcmp(prefix, path, prefix_len) != 0) return false;
|
|
146
|
+
// "/app" must not match "/application"; a prefix given with a trailing
|
|
147
|
+
// slash already carries the boundary.
|
|
148
|
+
return prefix_len == path_len || prefix[prefix_len - 1] == '/' || path[prefix_len] == '/';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
static bool path_included(struct tracer *t, const char *path, long len) {
|
|
152
|
+
if (len == 0) return false;
|
|
153
|
+
|
|
154
|
+
if (path[0] != '/') {
|
|
155
|
+
// Relative paths ("./spec/x_spec.rb" from `load`) are resolved and
|
|
156
|
+
// re-checked in Ruby. Pseudo-paths like "(eval at ...)" and
|
|
157
|
+
// "<internal:kernel>" are never project files.
|
|
158
|
+
return path[0] != '(' && path[0] != '<';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
bool in_root = false;
|
|
162
|
+
for (int i = 0; i < t->roots.count; i++) {
|
|
163
|
+
if (has_dir_prefix(path, len, t->roots.values[i], t->roots.lengths[i])) {
|
|
164
|
+
in_root = true;
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (!in_root) return false;
|
|
169
|
+
|
|
170
|
+
for (int i = 0; i < t->ignored.count; i++) {
|
|
171
|
+
if (has_dir_prefix(path, len, t->ignored.values[i], t->ignored.lengths[i])) return false;
|
|
172
|
+
}
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
static inline size_t cache_slot(uintptr_t ptr, size_t size) {
|
|
177
|
+
return ((ptr >> 4) ^ (ptr >> 12)) & (size - 1);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
static void on_exec(rb_event_flag_t event, VALUE data, VALUE self, ID id, VALUE klass) {
|
|
181
|
+
struct tracer *t = RTYPEDDATA_DATA(data);
|
|
182
|
+
|
|
183
|
+
const char *current = rb_sourcefile();
|
|
184
|
+
if (current == NULL) return;
|
|
185
|
+
|
|
186
|
+
if (t->last_path != Qnil && RSTRING_PTR(t->last_path) == current) return;
|
|
187
|
+
|
|
188
|
+
size_t slot = cache_slot((uintptr_t)current, PATH_CACHE_SIZE);
|
|
189
|
+
VALUE cached = t->path_cache[slot];
|
|
190
|
+
if (cached != Qnil && RSTRING_PTR(cached) == current) {
|
|
191
|
+
t->last_path = cached;
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// First time this test sees this file: fetch the frame's path String so
|
|
196
|
+
// the pointer we cache is owned by a marked object.
|
|
197
|
+
VALUE frame;
|
|
198
|
+
if (rb_profile_frames(0, 1, &frame, NULL) != 1) return;
|
|
199
|
+
|
|
200
|
+
VALUE path = rb_profile_frame_path(frame);
|
|
201
|
+
if (NIL_P(path) || !RB_TYPE_P(path, T_STRING)) return;
|
|
202
|
+
|
|
203
|
+
t->last_path = path;
|
|
204
|
+
t->path_cache[slot] = path;
|
|
205
|
+
|
|
206
|
+
if (path_included(t, RSTRING_PTR(path), RSTRING_LEN(path))) {
|
|
207
|
+
rb_hash_aset(t->files, path, Qtrue);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Runs inside object allocation: no Ruby method calls and no Ruby object
|
|
212
|
+
// allocation are allowed here. rb_obj_class, rb_mod_name and st_* do neither.
|
|
213
|
+
static void on_newobj(VALUE data, rb_trace_arg_t *targ) {
|
|
214
|
+
VALUE obj = rb_tracearg_object(targ);
|
|
215
|
+
enum ruby_value_type type = rb_type(obj);
|
|
216
|
+
if (type != RUBY_T_OBJECT && type != RUBY_T_STRUCT) return;
|
|
217
|
+
|
|
218
|
+
VALUE klass = rb_obj_class(obj);
|
|
219
|
+
if (klass == 0 || NIL_P(klass)) return;
|
|
220
|
+
|
|
221
|
+
struct tracer *t = RTYPEDDATA_DATA(data);
|
|
222
|
+
if (t->last_klass == klass) return;
|
|
223
|
+
|
|
224
|
+
size_t slot = cache_slot((uintptr_t)klass, KLASS_CACHE_SIZE);
|
|
225
|
+
if (t->klass_cache[slot] == klass) {
|
|
226
|
+
t->last_klass = klass;
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
t->last_klass = klass;
|
|
231
|
+
t->klass_cache[slot] = klass;
|
|
232
|
+
|
|
233
|
+
if (st_is_member(t->klasses, (st_data_t)klass)) return;
|
|
234
|
+
if (NIL_P(rb_mod_name(klass))) return; // anonymous class: no file to resolve
|
|
235
|
+
|
|
236
|
+
st_insert(t->klasses, (st_data_t)klass, 1);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
static void load_prefixes(struct prefix_list *list, VALUE ary, const char *name) {
|
|
240
|
+
if (NIL_P(ary)) return;
|
|
241
|
+
Check_Type(ary, T_ARRAY);
|
|
242
|
+
long n = RARRAY_LEN(ary);
|
|
243
|
+
if (n > MAX_PREFIXES) rb_raise(rb_eArgError, "at most %d %s are supported", MAX_PREFIXES, name);
|
|
244
|
+
|
|
245
|
+
for (long i = 0; i < n; i++) {
|
|
246
|
+
VALUE s = rb_ary_entry(ary, i);
|
|
247
|
+
Check_Type(s, T_STRING);
|
|
248
|
+
long len = RSTRING_LEN(s);
|
|
249
|
+
if (len == 0) continue;
|
|
250
|
+
char *copy = ALLOC_N(char, len + 1);
|
|
251
|
+
memcpy(copy, RSTRING_PTR(s), len);
|
|
252
|
+
copy[len] = '\0';
|
|
253
|
+
list->values[list->count] = copy;
|
|
254
|
+
list->lengths[list->count] = len;
|
|
255
|
+
list->count++;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// NativeTracer.new(roots: [...], ignored: [...], allocations: true/false)
|
|
260
|
+
static VALUE tracer_initialize(int argc, VALUE *argv, VALUE self) {
|
|
261
|
+
VALUE opts;
|
|
262
|
+
rb_scan_args(argc, argv, "1", &opts);
|
|
263
|
+
Check_Type(opts, T_HASH);
|
|
264
|
+
|
|
265
|
+
struct tracer *t = get_tracer(self);
|
|
266
|
+
free_prefixes(&t->roots);
|
|
267
|
+
free_prefixes(&t->ignored);
|
|
268
|
+
|
|
269
|
+
load_prefixes(&t->roots, rb_hash_aref(opts, ID2SYM(rb_intern("roots"))), "roots");
|
|
270
|
+
load_prefixes(&t->ignored, rb_hash_aref(opts, ID2SYM(rb_intern("ignored"))), "ignored prefixes");
|
|
271
|
+
if (t->roots.count == 0) rb_raise(rb_eArgError, "roots must contain at least one path");
|
|
272
|
+
|
|
273
|
+
t->allocations = RTEST(rb_hash_aref(opts, ID2SYM(rb_intern("allocations"))));
|
|
274
|
+
return self;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
static VALUE tracer_start(VALUE self) {
|
|
278
|
+
struct tracer *t = get_tracer(self);
|
|
279
|
+
if (t->running) rb_raise(rb_eRuntimeError, "tracer is already running");
|
|
280
|
+
|
|
281
|
+
rb_hash_clear(t->files);
|
|
282
|
+
st_clear(t->klasses);
|
|
283
|
+
reset_caches(t);
|
|
284
|
+
|
|
285
|
+
rb_add_event_hook2((rb_event_hook_func_t)on_exec, EXEC_EVENTS, self, RUBY_EVENT_HOOK_FLAG_SAFE);
|
|
286
|
+
if (t->allocations) {
|
|
287
|
+
rb_add_event_hook2((rb_event_hook_func_t)on_newobj, RUBY_INTERNAL_EVENT_NEWOBJ, self,
|
|
288
|
+
RUBY_EVENT_HOOK_FLAG_SAFE | RUBY_EVENT_HOOK_FLAG_RAW_ARG);
|
|
289
|
+
}
|
|
290
|
+
t->running = true;
|
|
291
|
+
return self;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
static int collect_klass_i(st_data_t key, st_data_t _value, st_data_t ary) {
|
|
295
|
+
rb_ary_push((VALUE)ary, (VALUE)key);
|
|
296
|
+
return ST_CONTINUE;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Returns [files, classes]: the Hash of executed paths and the Array of named
|
|
300
|
+
// classes allocated during the test. Both are fresh objects owned by Ruby.
|
|
301
|
+
static VALUE tracer_stop(VALUE self) {
|
|
302
|
+
struct tracer *t = get_tracer(self);
|
|
303
|
+
if (!t->running) rb_raise(rb_eRuntimeError, "tracer is not running");
|
|
304
|
+
|
|
305
|
+
rb_remove_event_hook_with_data((rb_event_hook_func_t)on_exec, self);
|
|
306
|
+
if (t->allocations) rb_remove_event_hook_with_data((rb_event_hook_func_t)on_newobj, self);
|
|
307
|
+
t->running = false;
|
|
308
|
+
|
|
309
|
+
VALUE files = t->files;
|
|
310
|
+
t->files = rb_hash_new();
|
|
311
|
+
|
|
312
|
+
VALUE klasses = rb_ary_new_capa((long)t->klasses->num_entries);
|
|
313
|
+
st_foreach(t->klasses, collect_klass_i, (st_data_t)klasses);
|
|
314
|
+
st_clear(t->klasses);
|
|
315
|
+
reset_caches(t);
|
|
316
|
+
|
|
317
|
+
return rb_assoc_new(files, klasses);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
static VALUE tracer_running_p(VALUE self) {
|
|
321
|
+
return get_tracer(self)->running ? Qtrue : Qfalse;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
void Init_selective_tracer(void) {
|
|
325
|
+
VALUE mSelective = rb_define_module("Selective");
|
|
326
|
+
VALUE mRuby = rb_define_module_under(mSelective, "Ruby");
|
|
327
|
+
VALUE mCore = rb_define_module_under(mRuby, "Core");
|
|
328
|
+
VALUE mTestMap = rb_define_module_under(mCore, "TestMap");
|
|
329
|
+
VALUE cTracer = rb_define_class_under(mTestMap, "NativeTracer", rb_cObject);
|
|
330
|
+
|
|
331
|
+
rb_define_alloc_func(cTracer, tracer_alloc);
|
|
332
|
+
rb_define_method(cTracer, "initialize", tracer_initialize, -1);
|
|
333
|
+
rb_define_method(cTracer, "start", tracer_start, 0);
|
|
334
|
+
rb_define_method(cTracer, "stop", tracer_stop, 0);
|
|
335
|
+
rb_define_method(cTracer, "running?", tracer_running_p, 0);
|
|
336
|
+
}
|
|
@@ -24,10 +24,9 @@ module Selective
|
|
|
24
24
|
|
|
25
25
|
def initialize(runner_class, runner_args, debug: false, log: false)
|
|
26
26
|
@debug = debug
|
|
27
|
+
@log = log
|
|
27
28
|
@runner = runner_class.new(runner_args, method(:test_case_callback))
|
|
28
29
|
@retries = 0
|
|
29
|
-
@runner_id = safe_filename(get_runner_id)
|
|
30
|
-
@logger = init_logger(log)
|
|
31
30
|
end
|
|
32
31
|
|
|
33
32
|
def start(reconnect: false)
|
|
@@ -67,7 +66,19 @@ module Selective
|
|
|
67
66
|
|
|
68
67
|
private
|
|
69
68
|
|
|
70
|
-
attr_reader :runner, :pipe, :transport_pid, :retries, :
|
|
69
|
+
attr_reader :runner, :pipe, :transport_pid, :retries, :diff
|
|
70
|
+
|
|
71
|
+
# Resolved on first use rather than in the constructor: `selective
|
|
72
|
+
# exec` (the plain framework run behind the manifest dry-run) never
|
|
73
|
+
# connects, so it must not require the CI environment that naming a
|
|
74
|
+
# runner validates.
|
|
75
|
+
def runner_id
|
|
76
|
+
@runner_id ||= safe_filename(get_runner_id)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def logger
|
|
80
|
+
@logger ||= init_logger(@log)
|
|
81
|
+
end
|
|
71
82
|
|
|
72
83
|
def get_runner_id
|
|
73
84
|
runner_id = build_env["runner_id"]
|
|
@@ -90,7 +101,7 @@ module Selective
|
|
|
90
101
|
message = pipe.read
|
|
91
102
|
response = JSON.parse(message, symbolize_names: true)
|
|
92
103
|
|
|
93
|
-
|
|
104
|
+
logger.info("Received Command: #{response}")
|
|
94
105
|
break if handle_command(response) == :break
|
|
95
106
|
end
|
|
96
107
|
end
|
|
@@ -140,6 +151,13 @@ module Selective
|
|
|
140
151
|
"framework" => runner.framework,
|
|
141
152
|
"framework_version" => runner.framework_version,
|
|
142
153
|
"framework_wrapper_version" => runner.wrapper_version,
|
|
154
|
+
"runtime_version" => RUBY_VERSION,
|
|
155
|
+
# "record", "select" or "0": whether this runner can record a
|
|
156
|
+
# test map, only run selected subsets, or neither.
|
|
157
|
+
"test_map" => TestMap.capability,
|
|
158
|
+
# Forces this run to record (e.g. a nightly full run), however
|
|
159
|
+
# fresh the current map is.
|
|
160
|
+
"test_map_record" => ENV["SELECTIVE_TEST_MAP_RECORD"].to_s
|
|
143
161
|
}.merge(metadata: metadata.to_json)
|
|
144
162
|
end
|
|
145
163
|
end
|
|
@@ -271,8 +289,11 @@ module Selective
|
|
|
271
289
|
|
|
272
290
|
def handle_test_manifest(_data)
|
|
273
291
|
self.class.restore_reporting!
|
|
274
|
-
|
|
292
|
+
logger.info("Sending Response: test_manifest")
|
|
275
293
|
data = {test_cases: runner.manifest["examples"] || runner.manifest["test_cases"]}
|
|
294
|
+
if TestMap.tree_requested? && (tree = test_map_tree)
|
|
295
|
+
data[:test_map_tree] = tree
|
|
296
|
+
end
|
|
276
297
|
num_commits = build_env["num_commits"] || 1000
|
|
277
298
|
if (diff = get_diff(num_commits))
|
|
278
299
|
data[:modified_test_files] = modified_test_files(diff)
|
|
@@ -281,6 +302,22 @@ module Selective
|
|
|
281
302
|
write({type: "test_manifest", data: data})
|
|
282
303
|
end
|
|
283
304
|
|
|
305
|
+
# Sent by servers that support test maps, before any tests are
|
|
306
|
+
# assigned. `tree: true` asks for a file-hash snapshot with the
|
|
307
|
+
# manifest; `record: true` turns on tracing for every test this runner
|
|
308
|
+
# executes.
|
|
309
|
+
def handle_configure_test_map(data)
|
|
310
|
+
recorder = TestMap.configure(
|
|
311
|
+
record: data[:record] == true,
|
|
312
|
+
tree: data[:tree] == true,
|
|
313
|
+
allocations: data[:allocations] != false
|
|
314
|
+
)
|
|
315
|
+
logger.info("Test map configured: record=#{!recorder.nil?} tree=#{TestMap.tree_requested?}")
|
|
316
|
+
if data[:record] == true && recorder.nil?
|
|
317
|
+
print_warning("Selective asked this runner to record a test map, but the native tracer isn't available. Tests will run normally.")
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
284
321
|
def handle_run_test_cases(data)
|
|
285
322
|
runner.run_test_cases(data[:test_case_ids])
|
|
286
323
|
end
|
|
@@ -307,6 +344,8 @@ module Selective
|
|
|
307
344
|
runner.finish unless exit_status.is_a?(Integer)
|
|
308
345
|
end
|
|
309
346
|
|
|
347
|
+
upload_test_map
|
|
348
|
+
|
|
310
349
|
kill_transport
|
|
311
350
|
pipe.delete_pipes
|
|
312
351
|
exit(exit_status || runner.exit_status)
|
|
@@ -315,12 +354,40 @@ module Selective
|
|
|
315
354
|
:break
|
|
316
355
|
end
|
|
317
356
|
|
|
357
|
+
def test_map_tree
|
|
358
|
+
TestMap::Tree.snapshot
|
|
359
|
+
rescue => e
|
|
360
|
+
logger.warn("Could not snapshot the repository for test selection: #{e.message}")
|
|
361
|
+
nil
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def upload_test_map
|
|
365
|
+
recorder = TestMap.recorder
|
|
366
|
+
return if recorder.nil? || recorder.empty?
|
|
367
|
+
|
|
368
|
+
uploader = TestMap::Uploader.new(
|
|
369
|
+
host: build_env["host"],
|
|
370
|
+
api_key: build_env["api_key"],
|
|
371
|
+
run_id: build_env["run_id"],
|
|
372
|
+
run_attempt: build_env["run_attempt"],
|
|
373
|
+
runner_id: runner_id,
|
|
374
|
+
logger: logger
|
|
375
|
+
)
|
|
376
|
+
if uploader.upload(recorder.to_h)
|
|
377
|
+
logger.info("Uploaded test map fragment: #{recorder.test_count} tests")
|
|
378
|
+
else
|
|
379
|
+
print_warning("Selective could not upload this runner's test map (#{uploader.last_error&.message}). Its tests will run on the next build rather than be skipped.")
|
|
380
|
+
end
|
|
381
|
+
rescue => e
|
|
382
|
+
logger.warn("Test map upload failed: #{e.class}: #{e.message}")
|
|
383
|
+
end
|
|
384
|
+
|
|
318
385
|
def correlated_files(diff, num_commits)
|
|
319
386
|
Selective::Ruby::Core::FileCorrelator.new(diff, num_commits, build_env["target_branch"]).correlate
|
|
320
387
|
end
|
|
321
388
|
|
|
322
389
|
def test_case_callback(test_case)
|
|
323
|
-
|
|
390
|
+
logger.info("Sending Response: test_case_result: #{test_case[:id]}")
|
|
324
391
|
write({type: "test_case_result", data: test_case})
|
|
325
392
|
end
|
|
326
393
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Selective
|
|
6
|
+
module Ruby
|
|
7
|
+
module Core
|
|
8
|
+
module TestMap
|
|
9
|
+
# Accumulates "test id => files" for every test this runner executes and
|
|
10
|
+
# serialises it as a fragment. Paths are interned into a table so a
|
|
11
|
+
# fragment carries each path once, not once per test that touched it.
|
|
12
|
+
class Recorder
|
|
13
|
+
FORMAT_VERSION = 1
|
|
14
|
+
|
|
15
|
+
attr_reader :tracer
|
|
16
|
+
|
|
17
|
+
def initialize(tracer:)
|
|
18
|
+
@tracer = tracer
|
|
19
|
+
@paths = []
|
|
20
|
+
@path_index = {}
|
|
21
|
+
@tests = {}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Retries and reruns can run a test twice in one process. Keep the
|
|
25
|
+
# union: a dependency seen on any attempt is a real dependency.
|
|
26
|
+
def record(test_id)
|
|
27
|
+
result, files = tracer.trace { yield }
|
|
28
|
+
indexes = (@tests[test_id] ||= Set.new)
|
|
29
|
+
files.each { |path| indexes << intern(path) }
|
|
30
|
+
result
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def test_count
|
|
34
|
+
@tests.size
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def empty?
|
|
38
|
+
@tests.empty?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def to_h
|
|
42
|
+
{
|
|
43
|
+
version: FORMAT_VERSION,
|
|
44
|
+
files: @paths,
|
|
45
|
+
tests: @tests.map { |id, indexes| {id: id, deps: indexes.to_a.sort} }
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def intern(path)
|
|
52
|
+
@path_index[path] ||= begin
|
|
53
|
+
@paths << path
|
|
54
|
+
@paths.size - 1
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Selective
|
|
6
|
+
module Ruby
|
|
7
|
+
module Core
|
|
8
|
+
module TestMap
|
|
9
|
+
# Ruby face of the native tracer: decides which paths count as the
|
|
10
|
+
# project's own code, and turns what the tracer saw into sorted,
|
|
11
|
+
# repo-relative paths that line up with `git ls-tree`.
|
|
12
|
+
class Tracer
|
|
13
|
+
attr_reader :repo_root
|
|
14
|
+
|
|
15
|
+
def initialize(allocations: true, repo_root: Tree.repo_root, pwd: Dir.pwd)
|
|
16
|
+
@repo_root = repo_root || File.realpath(pwd)
|
|
17
|
+
@roots = unique_dirs([@repo_root, pwd])
|
|
18
|
+
# Only directories *inside* a root need ignoring (everything else is
|
|
19
|
+
# outside the roots already). An ancestor would ignore the whole
|
|
20
|
+
# project, e.g. GEM_HOME=/work with the repo at /work/app.
|
|
21
|
+
@ignored = unique_dirs(ignored_dirs).select do |dir|
|
|
22
|
+
@roots.any? { |root| dir.start_with?("#{root}/") }
|
|
23
|
+
end
|
|
24
|
+
@native = NativeTracer.new(roots: @roots, ignored: @ignored, allocations: allocations)
|
|
25
|
+
@relative_paths = {}
|
|
26
|
+
@class_files = {}.compare_by_identity
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Runs the block with tracing on and returns [result, files], where
|
|
30
|
+
# files is a Set of repo-relative paths.
|
|
31
|
+
def trace
|
|
32
|
+
@native.start
|
|
33
|
+
begin
|
|
34
|
+
result = yield
|
|
35
|
+
ensure
|
|
36
|
+
files, klasses = @native.stop
|
|
37
|
+
end
|
|
38
|
+
[result, collect(files, klasses)]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def collect(files, klasses)
|
|
44
|
+
paths = Set.new
|
|
45
|
+
files.each_key do |path|
|
|
46
|
+
rel = relative(path)
|
|
47
|
+
paths << rel if rel
|
|
48
|
+
end
|
|
49
|
+
klasses.each do |klass|
|
|
50
|
+
files_for_class(klass).each { |rel| paths << rel }
|
|
51
|
+
end
|
|
52
|
+
paths
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A class's own file, plus those of its project-defined ancestors: a
|
|
56
|
+
# test that builds a `User` depends on `ApplicationRecord` too.
|
|
57
|
+
def files_for_class(klass)
|
|
58
|
+
@class_files[klass] ||= klass.ancestors.filter_map do |mod|
|
|
59
|
+
name = mod.name
|
|
60
|
+
next if name.nil?
|
|
61
|
+
|
|
62
|
+
location = Object.const_source_location(name)&.first
|
|
63
|
+
location && relative(location)
|
|
64
|
+
rescue NameError, ArgumentError, TypeError
|
|
65
|
+
nil
|
|
66
|
+
end.uniq.freeze
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def relative(path)
|
|
70
|
+
return @relative_paths[path] if @relative_paths.key?(path)
|
|
71
|
+
|
|
72
|
+
@relative_paths[path] = compute_relative(path)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def compute_relative(path)
|
|
76
|
+
abs = File.expand_path(path)
|
|
77
|
+
return nil if ignored?(abs)
|
|
78
|
+
|
|
79
|
+
rel = strip_root(abs)
|
|
80
|
+
if rel.nil? && File.exist?(abs)
|
|
81
|
+
real = File.realpath(abs)
|
|
82
|
+
rel = strip_root(real) unless ignored?(real)
|
|
83
|
+
end
|
|
84
|
+
rel
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def strip_root(abs)
|
|
88
|
+
prefix = "#{@repo_root}/"
|
|
89
|
+
abs.start_with?(prefix) ? abs.delete_prefix(prefix) : nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def ignored?(abs)
|
|
93
|
+
@ignored.any? { |dir| abs == dir || abs.start_with?("#{dir}/") }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Code the project runs but doesn't own: installed gems (including a
|
|
97
|
+
# vendored bundle inside the repo) and Selective's own gems, which
|
|
98
|
+
# live inside the repo when developed through path/symlinks.
|
|
99
|
+
def ignored_dirs
|
|
100
|
+
dirs = [Gem.dir, *Gem.path]
|
|
101
|
+
dirs << Bundler.bundle_path.to_s if defined?(Bundler) && Bundler.respond_to?(:bundle_path)
|
|
102
|
+
Gem.loaded_specs.each do |name, spec|
|
|
103
|
+
dirs << spec.full_gem_path if name.start_with?("selective-ruby-")
|
|
104
|
+
end
|
|
105
|
+
extra = ENV["SELECTIVE_TEST_MAP_IGNORE"].to_s.split(",").map(&:strip).reject(&:empty?)
|
|
106
|
+
dirs.concat(extra.map { |d| File.expand_path(d) })
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def unique_dirs(dirs)
|
|
110
|
+
dirs.compact.flat_map do |dir|
|
|
111
|
+
expanded = File.expand_path(dir.to_s)
|
|
112
|
+
real = File.exist?(expanded) ? File.realpath(expanded) : expanded
|
|
113
|
+
[expanded, real]
|
|
114
|
+
end.map { |d| d.chomp("/") }.reject(&:empty?).uniq
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Selective
|
|
6
|
+
module Ruby
|
|
7
|
+
module Core
|
|
8
|
+
module TestMap
|
|
9
|
+
# A content-hash snapshot of the repository: every file's git blob id.
|
|
10
|
+
#
|
|
11
|
+
# This is what makes selection independent of diffs. The server
|
|
12
|
+
# compares these hashes with the ones stored alongside the map, so there
|
|
13
|
+
# is no base commit to pick, no merge-base to compute and no fetch depth
|
|
14
|
+
# to get right. It works in a depth-1 clone, and for uncommitted edits
|
|
15
|
+
# (a developer or an agent running locally) it hashes what is actually
|
|
16
|
+
# on disk rather than what HEAD says.
|
|
17
|
+
module Tree
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def repo_root
|
|
21
|
+
out, status = Open3.capture2e("git", "rev-parse", "--show-toplevel")
|
|
22
|
+
status.success? ? out.strip : nil
|
|
23
|
+
rescue SystemCallError
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# => {sha:, prefix:, files: {path => blob}, dirty: [paths]} or nil
|
|
28
|
+
# when the working directory isn't a git checkout.
|
|
29
|
+
def snapshot(pwd: Dir.pwd)
|
|
30
|
+
root = repo_root
|
|
31
|
+
return nil if root.nil?
|
|
32
|
+
|
|
33
|
+
files = committed_files(root)
|
|
34
|
+
return nil if files.nil?
|
|
35
|
+
|
|
36
|
+
dirty = apply_working_tree_changes(root, files)
|
|
37
|
+
{
|
|
38
|
+
sha: head_sha(root),
|
|
39
|
+
prefix: prefix_for(root, pwd),
|
|
40
|
+
files: files,
|
|
41
|
+
dirty: dirty
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Where the test command runs, relative to the repository root, e.g.
|
|
46
|
+
# "services/api/" in a monorepo. The server uses it to line manifest
|
|
47
|
+
# paths (relative to the command) up with tree paths (relative to the
|
|
48
|
+
# root).
|
|
49
|
+
def prefix_for(root, pwd)
|
|
50
|
+
real = File.realpath(pwd)
|
|
51
|
+
return "" if real == root
|
|
52
|
+
|
|
53
|
+
real.start_with?("#{root}/") ? "#{real.delete_prefix("#{root}/")}/" : ""
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def head_sha(root)
|
|
57
|
+
git(root, "rev-parse", "HEAD")&.strip
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def committed_files(root)
|
|
61
|
+
out = git(root, "ls-tree", "-r", "-z", "--full-tree", "HEAD")
|
|
62
|
+
return nil if out.nil?
|
|
63
|
+
|
|
64
|
+
out.split("\0").each_with_object({}) do |entry, files|
|
|
65
|
+
meta, path = entry.split("\t", 2)
|
|
66
|
+
next if path.nil?
|
|
67
|
+
|
|
68
|
+
_mode, _type, blob = meta.split(" ")
|
|
69
|
+
files[path] = blob
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Folds uncommitted edits to tracked files into the snapshot. Returns
|
|
74
|
+
# the dirty paths so the server can report that the run used a
|
|
75
|
+
# working tree.
|
|
76
|
+
#
|
|
77
|
+
# Untracked files are deliberately left out. They add nothing — a new
|
|
78
|
+
# test file is "new" to the map through the manifest, and a new app
|
|
79
|
+
# file can't be a recorded dependency — and they churn: runner logs,
|
|
80
|
+
# tmp/, coverage output. Hashing them made a local log file look like
|
|
81
|
+
# a changed file no test uses, which runs the whole suite.
|
|
82
|
+
def apply_working_tree_changes(root, files)
|
|
83
|
+
out = git(root, "status", "--porcelain=v1", "-z", "--untracked-files=no", "--no-renames")
|
|
84
|
+
return [] if out.nil? || out.empty?
|
|
85
|
+
|
|
86
|
+
changed = []
|
|
87
|
+
deleted = []
|
|
88
|
+
out.split("\0").each do |entry|
|
|
89
|
+
next if entry.length < 4
|
|
90
|
+
|
|
91
|
+
status = entry[0, 2]
|
|
92
|
+
path = entry[3..]
|
|
93
|
+
if status.include?("D") && !File.exist?(File.join(root, path))
|
|
94
|
+
files.delete(path)
|
|
95
|
+
deleted << path
|
|
96
|
+
else
|
|
97
|
+
changed << path
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
hash_objects(root, changed).each { |path, blob| files[path] = blob }
|
|
102
|
+
changed + deleted
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def hash_objects(root, paths)
|
|
106
|
+
existing = paths.select { |p| File.file?(File.join(root, p)) }
|
|
107
|
+
return {} if existing.empty?
|
|
108
|
+
|
|
109
|
+
out, status = Open3.capture2("git", "-C", root, "hash-object", "--stdin-paths", stdin_data: existing.join("\n") + "\n")
|
|
110
|
+
return {} unless status.success?
|
|
111
|
+
|
|
112
|
+
existing.zip(out.split("\n")).to_h
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def git(root, *args)
|
|
116
|
+
out, status = Open3.capture2("git", "-C", root, *args)
|
|
117
|
+
status.success? ? out : nil
|
|
118
|
+
rescue SystemCallError
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "open3"
|
|
6
|
+
require "rbconfig"
|
|
7
|
+
require "tempfile"
|
|
8
|
+
require "uri"
|
|
9
|
+
require "zlib"
|
|
10
|
+
|
|
11
|
+
module Selective
|
|
12
|
+
module Ruby
|
|
13
|
+
module Core
|
|
14
|
+
module TestMap
|
|
15
|
+
# Sends this runner's recorded fragment to the server over HTTPS.
|
|
16
|
+
#
|
|
17
|
+
# A map is far larger than anything else a runner sends (tests × files),
|
|
18
|
+
# so it doesn't go over the WebSocket. Upload failure never fails the
|
|
19
|
+
# build: the worst outcome is a map missing this runner's tests, and a
|
|
20
|
+
# test missing from the map is always run.
|
|
21
|
+
#
|
|
22
|
+
# The POST runs in a child Ruby process. The upload happens inside the
|
|
23
|
+
# test process, where suites commonly load WebMock or VCR, and those
|
|
24
|
+
# intercept every Net::HTTP request, this one included. A child started
|
|
25
|
+
# without Bundler's environment loads neither.
|
|
26
|
+
class Uploader
|
|
27
|
+
PATH = "/test_maps/fragments"
|
|
28
|
+
ATTEMPTS = 3
|
|
29
|
+
|
|
30
|
+
def initialize(host:, api_key:, run_id:, run_attempt:, runner_id:, logger: nil)
|
|
31
|
+
@uri = build_uri(host, run_id: run_id, run_attempt: run_attempt, runner_id: runner_id)
|
|
32
|
+
@api_key = api_key
|
|
33
|
+
@logger = logger
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Returns true on success.
|
|
37
|
+
def upload(fragment)
|
|
38
|
+
body = Zlib.gzip(JSON.generate(fragment))
|
|
39
|
+
attempt = 0
|
|
40
|
+
begin
|
|
41
|
+
attempt += 1
|
|
42
|
+
true if post(body)
|
|
43
|
+
rescue => e
|
|
44
|
+
@logger&.warn("Test map upload attempt #{attempt} failed: #{e.message}")
|
|
45
|
+
if attempt < ATTEMPTS
|
|
46
|
+
sleep(attempt)
|
|
47
|
+
retry
|
|
48
|
+
end
|
|
49
|
+
@last_error = e
|
|
50
|
+
false
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
attr_reader :last_error, :uri
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# The request, as a standalone script for the child process. The API key
|
|
59
|
+
# arrives in the environment, never on the command line.
|
|
60
|
+
CHILD_SCRIPT = <<~RUBY
|
|
61
|
+
require "net/http"
|
|
62
|
+
require "uri"
|
|
63
|
+
uri = URI(ARGV.fetch(0))
|
|
64
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
65
|
+
http.use_ssl = uri.scheme == "https"
|
|
66
|
+
http.open_timeout = 10
|
|
67
|
+
http.read_timeout = 120
|
|
68
|
+
request = Net::HTTP::Post.new(uri.request_uri)
|
|
69
|
+
request["authorization"] = ENV.fetch("SELECTIVE_TEST_MAP_UPLOAD_KEY")
|
|
70
|
+
request["content-type"] = "application/gzip"
|
|
71
|
+
request.body = File.binread(ARGV.fetch(1))
|
|
72
|
+
response = http.request(request)
|
|
73
|
+
puts response.code
|
|
74
|
+
warn response.body.to_s[0, 200] unless response.is_a?(Net::HTTPSuccess)
|
|
75
|
+
exit(response.is_a?(Net::HTTPSuccess) ? 0 : 1)
|
|
76
|
+
RUBY
|
|
77
|
+
|
|
78
|
+
# Keeps Bundler (and whatever it would require) out of the child.
|
|
79
|
+
CLEAN_ENV = %w[RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_BIN_PATH BUNDLER_SETUP BUNDLER_VERSION GEM_HOME
|
|
80
|
+
GEM_PATH].to_h do |k|
|
|
81
|
+
[k, nil]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Raises unless the server accepted the upload.
|
|
85
|
+
def post(body)
|
|
86
|
+
Tempfile.create(["selective-test-map", ".json.gz"], binmode: true) do |file|
|
|
87
|
+
file.write(body)
|
|
88
|
+
file.flush
|
|
89
|
+
|
|
90
|
+
env = CLEAN_ENV.merge("SELECTIVE_TEST_MAP_UPLOAD_KEY" => @api_key.to_s)
|
|
91
|
+
out, err, status = Open3.capture3(env, RbConfig.ruby, "-e", CHILD_SCRIPT, @uri.to_s, file.path)
|
|
92
|
+
raise "HTTP #{out.strip}: #{err.strip}" unless status.success?
|
|
93
|
+
|
|
94
|
+
true
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The runner is configured with the WebSocket host (ws:// or wss://);
|
|
99
|
+
# the upload goes to the same server over HTTP(S).
|
|
100
|
+
def build_uri(host, **params)
|
|
101
|
+
base = host.sub(%r{\Aws(s?)://}) { "http#{::Regexp.last_match(1)}://" }.chomp("/")
|
|
102
|
+
URI("#{base}#{PATH}?#{URI.encode_www_form(params)}")
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Selective
|
|
4
|
+
module Ruby
|
|
5
|
+
module Core
|
|
6
|
+
# Test maps let Selective skip tests a change can't affect.
|
|
7
|
+
#
|
|
8
|
+
# A *recording* run traces every test with the native tracer and uploads
|
|
9
|
+
# "test id => repo files it executed" when the runner finishes. A later
|
|
10
|
+
# run sends a snapshot of the repository's file hashes with its manifest,
|
|
11
|
+
# and the server skips a test only when every file it depends on is
|
|
12
|
+
# byte-for-byte what it was when the map was recorded.
|
|
13
|
+
#
|
|
14
|
+
# Everything here is inert unless the server asks for it
|
|
15
|
+
# (`configure_test_map`), and recording additionally needs the optional
|
|
16
|
+
# native extension. Without either, the suite runs exactly as before.
|
|
17
|
+
module TestMap
|
|
18
|
+
class << self
|
|
19
|
+
# True when the native tracer compiled and loads on this Ruby.
|
|
20
|
+
def available?
|
|
21
|
+
return @available if defined?(@available)
|
|
22
|
+
|
|
23
|
+
@available = if disabled?
|
|
24
|
+
false
|
|
25
|
+
else
|
|
26
|
+
begin
|
|
27
|
+
require "selective_tracer"
|
|
28
|
+
true
|
|
29
|
+
rescue LoadError
|
|
30
|
+
false
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# SELECTIVE_TEST_MAP_DISABLE=1 is the escape hatch: the runner then
|
|
36
|
+
# never records and never takes part in selection.
|
|
37
|
+
def disabled?
|
|
38
|
+
ENV["SELECTIVE_TEST_MAP_DISABLE"].to_s.match?(/\A(1|true|yes)\z/i)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# What this runner can do, as advertised to the server. Selection
|
|
42
|
+
# needs only a git snapshot, so a runner without the native tracer
|
|
43
|
+
# (unsupported Ruby, failed compile) still runs selected subsets
|
|
44
|
+
# from maps recorded elsewhere; it just can't record one.
|
|
45
|
+
def capability
|
|
46
|
+
return "0" if disabled?
|
|
47
|
+
|
|
48
|
+
available? ? "record" : "select"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Recording is one-way for the life of the process: a reconnect can
|
|
52
|
+
# make the server re-send its configuration, and dropping a recorder
|
|
53
|
+
# would throw away every test traced so far.
|
|
54
|
+
def configure(record: false, tree: false, allocations: true)
|
|
55
|
+
@tree_requested = tree
|
|
56
|
+
if record && available?
|
|
57
|
+
@recorder ||= Recorder.new(tracer: Tracer.new(allocations: allocations))
|
|
58
|
+
end
|
|
59
|
+
@recorder
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def recording?
|
|
63
|
+
!@recorder.nil?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def tree_requested?
|
|
67
|
+
@tree_requested == true
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Wraps one test. Adapters call this around the framework's own
|
|
71
|
+
# "run this test" so setup, the test body and teardown are all traced.
|
|
72
|
+
def around(test_id)
|
|
73
|
+
recorder = @recorder
|
|
74
|
+
return yield if recorder.nil?
|
|
75
|
+
|
|
76
|
+
recorder.record(test_id) { yield }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
attr_reader :recorder
|
|
80
|
+
|
|
81
|
+
def reset!
|
|
82
|
+
@recorder = nil
|
|
83
|
+
@tree_requested = false
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
data/lib/selective-ruby-core.rb
CHANGED
|
File without changes
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/selective/ruby/core/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "selective-ruby-core"
|
|
7
|
+
spec.version = Selective::Ruby::Core::VERSION
|
|
8
|
+
spec.authors = ["Benjamin Wood", "Nate Vick"]
|
|
9
|
+
spec.email = ["ben@hint.io", "nate@hint.io"]
|
|
10
|
+
spec.license = "MIT"
|
|
11
|
+
|
|
12
|
+
# Currently supported release platforms are:
|
|
13
|
+
# Linux: x86_64-linux, aarch64-linux
|
|
14
|
+
# MacOS: x86_64-darwin, arm64-darwin
|
|
15
|
+
if (platform = ENV["RELEASE_PLATFORM"])
|
|
16
|
+
spec.platform = platform
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
spec.summary = "Selective Ruby Client Core"
|
|
20
|
+
spec.description = "Selective is an intelligent test runner for your current CI provider. Get real-time test results, intelligent ordering based on code changes, shorter run times, automatic flake detection, the ability to re-enqueue failed tests, and more."
|
|
21
|
+
spec.homepage = "https://www.selective.ci"
|
|
22
|
+
spec.required_ruby_version = ">= 2.6.0"
|
|
23
|
+
|
|
24
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
25
|
+
spec.metadata["source_code_uri"] = "http://github.com/selectiveci/selective-ruby-core"
|
|
26
|
+
spec.metadata["changelog_uri"] = "https://github.com/selectiveci/selective-ruby-core/blob/main/CHANGELOG.md"
|
|
27
|
+
|
|
28
|
+
# Specify which files should be added to the gem when it is released.
|
|
29
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
|
30
|
+
spec.files = Dir.chdir(__dir__) do
|
|
31
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
|
32
|
+
(File.expand_path(f) == __FILE__) || f.start_with?(*%w[bin/ test/ spec/ features/ log/ .git .circleci appveyor .rspec .ruby-version .standard.yml README.md CHANGELOG.md CODE_OF_CONDUCT.md Gemfile])
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Add the transport binary
|
|
37
|
+
unless ENV["RELEASE_PLATFORM"].nil?
|
|
38
|
+
spec.files << "lib/bin/transport"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The optional test map tracer. extconf.rb writes a no-op Makefile when it
|
|
42
|
+
# can't build (no compiler, unsupported Ruby), so installation never fails
|
|
43
|
+
# because of it; the runner then reports it can't record.
|
|
44
|
+
spec.extensions = ["ext/selective_tracer/extconf.rb"]
|
|
45
|
+
|
|
46
|
+
spec.bindir = "exe"
|
|
47
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
48
|
+
spec.require_paths = ["lib"]
|
|
49
|
+
|
|
50
|
+
# Uncomment to register a new dependency of your gem
|
|
51
|
+
spec.add_dependency("logger", "~> 1.6")
|
|
52
|
+
spec.add_dependency("zeitwerk", "~> 2.6.12")
|
|
53
|
+
end
|
metadata
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: selective-ruby-core
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.10
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Benjamin Wood
|
|
8
8
|
- Nate Vick
|
|
9
|
-
autorequire:
|
|
9
|
+
autorequire:
|
|
10
10
|
bindir: exe
|
|
11
11
|
cert_chain: []
|
|
12
|
-
date: 2026-
|
|
12
|
+
date: 2026-09-29 00:00:00.000000000 Z
|
|
13
13
|
dependencies:
|
|
14
14
|
- !ruby/object:Gem::Dependency
|
|
15
15
|
name: logger
|
|
@@ -48,12 +48,15 @@ email:
|
|
|
48
48
|
- nate@hint.io
|
|
49
49
|
executables:
|
|
50
50
|
- selective
|
|
51
|
-
extensions:
|
|
51
|
+
extensions:
|
|
52
|
+
- ext/selective_tracer/extconf.rb
|
|
52
53
|
extra_rdoc_files: []
|
|
53
54
|
files:
|
|
54
55
|
- LICENSE
|
|
55
56
|
- Rakefile
|
|
56
57
|
- exe/selective
|
|
58
|
+
- ext/selective_tracer/extconf.rb
|
|
59
|
+
- ext/selective_tracer/selective_tracer.c
|
|
57
60
|
- lib/bin/build_env.sh
|
|
58
61
|
- lib/bin/file_correlation_collector.sh
|
|
59
62
|
- lib/selective-ruby-core.rb
|
|
@@ -61,7 +64,13 @@ files:
|
|
|
61
64
|
- lib/selective/ruby/core/file_correlator.rb
|
|
62
65
|
- lib/selective/ruby/core/helper.rb
|
|
63
66
|
- lib/selective/ruby/core/named_pipe.rb
|
|
67
|
+
- lib/selective/ruby/core/test_map.rb
|
|
68
|
+
- lib/selective/ruby/core/test_map/recorder.rb
|
|
69
|
+
- lib/selective/ruby/core/test_map/tracer.rb
|
|
70
|
+
- lib/selective/ruby/core/test_map/tree.rb
|
|
71
|
+
- lib/selective/ruby/core/test_map/uploader.rb
|
|
64
72
|
- lib/selective/ruby/core/version.rb
|
|
73
|
+
- selective-ruby-core.gemspec
|
|
65
74
|
homepage: https://www.selective.ci
|
|
66
75
|
licenses:
|
|
67
76
|
- MIT
|
|
@@ -69,7 +78,7 @@ metadata:
|
|
|
69
78
|
homepage_uri: https://www.selective.ci
|
|
70
79
|
source_code_uri: http://github.com/selectiveci/selective-ruby-core
|
|
71
80
|
changelog_uri: https://github.com/selectiveci/selective-ruby-core/blob/main/CHANGELOG.md
|
|
72
|
-
post_install_message:
|
|
81
|
+
post_install_message:
|
|
73
82
|
rdoc_options: []
|
|
74
83
|
require_paths:
|
|
75
84
|
- lib
|
|
@@ -85,7 +94,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
85
94
|
version: '0'
|
|
86
95
|
requirements: []
|
|
87
96
|
rubygems_version: 3.5.22
|
|
88
|
-
signing_key:
|
|
97
|
+
signing_key:
|
|
89
98
|
specification_version: 4
|
|
90
99
|
summary: Selective Ruby Client Core
|
|
91
100
|
test_files: []
|