diff_test 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. checksums.yaml +7 -0
  2. data/.standard.yml +3 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +208 -0
  5. data/Rakefile +10 -0
  6. data/lib/diff_test/api_client.rb +38 -0
  7. data/lib/diff_test/configuration.rb +178 -0
  8. data/lib/diff_test/file_hash_computer.rb +21 -0
  9. data/lib/diff_test/helper.rb +37 -0
  10. data/lib/diff_test/impacted_file_tracker.rb +34 -0
  11. data/lib/diff_test/integrations/integration.rb +29 -0
  12. data/lib/diff_test/integrations/minitest/integration.rb +26 -0
  13. data/lib/diff_test/integrations/minitest/lifecycle.rb +77 -0
  14. data/lib/diff_test/integrations/rails_js/annotator.rb +43 -0
  15. data/lib/diff_test/integrations/rails_js/body_processor.rb +69 -0
  16. data/lib/diff_test/integrations/rails_js/integration.rb +25 -0
  17. data/lib/diff_test/integrations/rails_js/js_files.rake +48 -0
  18. data/lib/diff_test/integrations/rails_js/middleware.rb +43 -0
  19. data/lib/diff_test/integrations/rails_js/processing_skip_analyzer.rb +31 -0
  20. data/lib/diff_test/integrations/rails_js/railtie.rb +17 -0
  21. data/lib/diff_test/js_version_hash_computer.rb +24 -0
  22. data/lib/diff_test/project_version_hash_computer.rb +26 -0
  23. data/lib/diff_test/should_run_decider.rb +44 -0
  24. data/lib/diff_test/test_execution.rb +92 -0
  25. data/lib/diff_test/test_suite_execution.rb +114 -0
  26. data/lib/diff_test/trackers/base.rb +23 -0
  27. data/lib/diff_test/trackers/js_file.rb +7 -0
  28. data/lib/diff_test/trackers/ruby_file.rb +50 -0
  29. data/lib/diff_test/trackers/singleton_base.rb +43 -0
  30. data/lib/diff_test/version.rb +5 -0
  31. data/lib/diff_test.rb +32 -0
  32. data/sig/diff_test.rbs +4 -0
  33. metadata +107 -0
@@ -0,0 +1,43 @@
1
+ module DiffTest
2
+ module Integrations
3
+ module RailsJs
4
+ class Annotator
5
+ def self.annotate_file(file_path)
6
+ expanded_file_path = DiffTest::Helper.expand_path(file_path)
7
+ content = File.read(expanded_file_path)
8
+ content = annotate(content, file_path)
9
+ File.write(expanded_file_path, content)
10
+ end
11
+
12
+ def self.unannotate_file(file_path)
13
+ expanded_file_path = DiffTest::Helper.expand_path(file_path)
14
+ content = File.read(expanded_file_path)
15
+ content = unannotate(content)
16
+ File.write(expanded_file_path, content)
17
+ end
18
+
19
+ def self.annotate(content, file_path)
20
+ return content if annotated?(content)
21
+
22
+ coffee = file_path.end_with?('.coffee')
23
+ comment = coffee ? '#' : '//'
24
+ safe_method_call = coffee ? '?' : '?.'
25
+
26
+ annotation = "#{comment} Automatically added by DiffTest\n(globalThis || window)?.diffTestTrackJsFile#{safe_method_call}(\"#{file_path}\");\n#{comment} Automatically added by DiffTest\n"
27
+
28
+ annotation + content
29
+ end
30
+
31
+ UNANNOTATE_REGEX = /(\/\/|#) Automatically added by DiffTest.*?(\/\/|#) Automatically added by DiffTest\n/m
32
+ def self.unannotate(content)
33
+ content.gsub!(UNANNOTATE_REGEX, '')
34
+ content
35
+ end
36
+
37
+ def self.annotated?(content)
38
+ content.include?('diffTestTrackJsFile')
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,69 @@
1
+ module DiffTest
2
+ module Integrations
3
+ module RailsJs
4
+ class BodyProcessor
5
+ HEAD_TAG_REGEX = /<head( [^<]+)?>/
6
+
7
+ attr_reader :content_length, :new_body
8
+
9
+ def initialize(body, options)
10
+ @body = body
11
+ @options = options
12
+ end
13
+
14
+ def process!(env)
15
+ @env = env
16
+ @body.close if @body.respond_to?(:close)
17
+
18
+ @new_body = []
19
+ @body.each { |line| @new_body << line.to_s }
20
+
21
+ @content_length = 0
22
+ @script_added = false
23
+
24
+ @new_body = @new_body.map do |line|
25
+ if !@script_added && line['<head']
26
+ line = line.gsub(HEAD_TAG_REGEX) { |match| %(#{match}#{script}) }
27
+
28
+ @script_added = true
29
+ end
30
+
31
+ @content_length += line.bytesize
32
+ line
33
+ end
34
+ end
35
+
36
+ def script
37
+ <<~HTML
38
+ <script>
39
+ var owner = globalThis || window
40
+ owner.diffTestTrackedJsFiles = owner.diffTestTrackedJsFiles || [];
41
+ owner.diffTestTrackJsFile = function(file_path) {
42
+ const path = "/__diff_test_js_beacon";
43
+ const payload = JSON.stringify({ test_id: #{DiffTest::TestExecution.current.id.to_json}, file_path: file_path });
44
+
45
+ owner.diffTestTrackedJsFiles.push(file_path);
46
+ if (owner.navigator && owner.navigator.sendBeacon) {
47
+ // Use sendBeacon in capable browsers/environments
48
+ window.navigator.sendBeacon(path, payload);
49
+ } else {
50
+ // Fallback: Use fetch from Service Worker or other Worker
51
+ fetch(path, {
52
+ method: 'POST',
53
+ headers: { 'Content-Type': 'application/json' },
54
+ body: payload
55
+ }).catch((err) => {
56
+ // Handle error logging or silent fail
57
+ console.error('diff_test_js_beacon_failure', err);
58
+ });
59
+ }
60
+ }
61
+
62
+ owner.diffTestTrackJsFileAuto = owner.diffTestTrackJsFile;
63
+ </script>
64
+ HTML
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,25 @@
1
+ require_relative '../integration'
2
+
3
+ module DiffTest
4
+ module Integrations
5
+ module RailsJs
6
+ class Integration < DiffTest::Integrations::Integration
7
+ def gem_name
8
+ 'rails'
9
+ end
10
+
11
+ def minimum_compatible_version
12
+ '4.0.0'
13
+ end
14
+
15
+ def loaded?
16
+ supported? && defined?(::Rails)
17
+ end
18
+
19
+ def integrate
20
+ require_relative 'railtie'
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,48 @@
1
+ require_relative 'annotator'
2
+
3
+ namespace :diff_test do
4
+ desc 'Annotate your js files with autoTrackJsFile call'
5
+ task annotate: :environment do |_, args|
6
+ DiffTest.configuration.tracked_js_files.each do |file_path|
7
+ DiffTest::Integrations::RailsJs::Annotator.annotate_file(file_path)
8
+ end
9
+ end
10
+
11
+ task unannotate: :environment do |_, args|
12
+ DiffTest.configuration.tracked_js_files.each do |file_path|
13
+ DiffTest::Integrations::RailsJs::Annotator.unannotate_file(file_path)
14
+ end
15
+ end
16
+
17
+ task __auto_annotate_before_precompile: :environment do |_, args|
18
+ next unless DiffTest.configuration.auto_annotate_js_files?
19
+ next unless Rails.env.test?
20
+
21
+ Rake::Task['diff_test:annotate'].invoke
22
+ end
23
+
24
+ task __auto_unannotate_before_clobber: :environment do |_, args|
25
+ next unless DiffTest.configuration.auto_annotate_js_files?
26
+ next unless Rails.env.test?
27
+
28
+ Rake::Task['diff_test:unannotate'].invoke
29
+ end
30
+
31
+ task __auto_annotate_before_test_prepare: :environment do |_, args|
32
+ next unless DiffTest.configuration.auto_annotate_js_files?
33
+ next unless Rails.env.test?
34
+
35
+ Rake::Task['diff_test:annotate'].invoke
36
+ end
37
+ end
38
+
39
+ Rake::Task['assets:precompile'].enhance(['diff_test:__auto_annotate_before_precompile']) if Rake::Task.task_defined?('assets:precompile')
40
+ Rake::Task['assets:clobber'].enhance(['diff_test:__auto_unannotate_before_clobber']) if Rake::Task.task_defined?('assets:clobber')
41
+
42
+ if Rake::Task.task_defined?("test:prepare")
43
+ Rake::Task["test:prepare"].enhance(["diff_test:__auto_annotate_before_test_prepare"])
44
+ elsif Rake::Task.task_defined?("spec:prepare")
45
+ Rake::Task["spec:prepare"].enhance(["diff_test:__auto_annotate_before_test_prepare"])
46
+ elsif Rake::Task.task_defined?("db:test:prepare")
47
+ Rake::Task["db:test:prepare"].enhance(["diff_test:__auto_annotate_before_test_prepare"])
48
+ end
@@ -0,0 +1,43 @@
1
+ require_relative 'processing_skip_analyzer'
2
+ require_relative 'body_processor'
3
+
4
+ module DiffTest
5
+ module Integrations
6
+ module RailsJs
7
+ class Middleware
8
+ def initialize(app)
9
+ @app = app
10
+ end
11
+
12
+ def call(env)
13
+ dup._call(env)
14
+ end
15
+
16
+ def _call(env)
17
+ path = env['PATH_INFO'] || ''
18
+
19
+ if path.include?('__diff_test_js_beacon')
20
+ body = env['rack.input'].read
21
+ json = JSON.parse(body)
22
+ DiffTest::Trackers::JsFile.record_by_test_id(json['test_id'], json['file_path'])
23
+ [204, {}, []]
24
+ else
25
+ env.delete('HTTP_IF_NONE_MATCH')
26
+ env.delete('HTTP_IF_MODIFIED_SINCE')
27
+
28
+ status, headers, body = result = @app.call(env)
29
+
30
+ return result if ProcessingSkipAnalyzer.skip_processing?(result, env, @options)
31
+
32
+ processor = BodyProcessor.new(body, @options)
33
+ processor.process!(env)
34
+
35
+ headers['content-length'] = processor.content_length.to_s
36
+
37
+ [status, headers, processor.new_body]
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,31 @@
1
+ module DiffTest
2
+ module Integrations
3
+ module RailsJs
4
+ class ProcessingSkipAnalyzer
5
+ def self.skip_processing?(result, env, options)
6
+ new(result, env, options).skip_processing?
7
+ end
8
+
9
+ def initialize(result, env, options)
10
+ @env = env
11
+ @options = options
12
+
13
+ @status, @headers, @body = result
14
+ end
15
+
16
+ def skip_processing?
17
+ !html? || chunked?
18
+ end
19
+
20
+ def chunked?
21
+ @headers['Transfer-Encoding'] == 'chunked'
22
+ end
23
+
24
+ def html?
25
+ content_type = @headers['Content-Type'] || @headers['content-type']
26
+ content_type&.include?('text/html')
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,17 @@
1
+ require_relative 'middleware'
2
+ module DiffTest
3
+ module Integrations
4
+ module RailsJs
5
+ class Railtie < Rails::Railtie
6
+ initializer 'diff_test.middleware' do |app|
7
+ app.middleware.insert_before 0, DiffTest::Integrations::RailsJs::Middleware if Rails.env.test?
8
+ end
9
+
10
+ rake_tasks do
11
+ path = File.expand_path(__dir__)
12
+ load "#{path}/js_files.rake"
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,24 @@
1
+ require 'json'
2
+ require_relative 'integrations/rails_js/annotator'
3
+
4
+ module DiffTest
5
+ class JsVersionHashComputer
6
+ def self.compute
7
+ new.compute
8
+ end
9
+
10
+ def compute
11
+ string = file_paths_to_use.map do |file_path|
12
+ [file_path, FileHashComputer.compute_relative(file_path)]
13
+ end.sort_by(&:first).to_json
14
+
15
+ Digest::SHA256.hexdigest(string)
16
+ end
17
+
18
+ private
19
+
20
+ def file_paths_to_use
21
+ DiffTest.configuration.tracked_js_files
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,26 @@
1
+ require 'json'
2
+
3
+ module DiffTest
4
+ class ProjectVersionHashComputer
5
+ def self.compute
6
+ new.compute
7
+ end
8
+
9
+ def compute
10
+ string = file_paths_to_use.map do |file_path|
11
+ [file_path, XXhash.xxh64_file(Helper.expand_path(file_path))]
12
+ end.sort_by(&:first).to_json
13
+
14
+ Digest::SHA256.hexdigest(string)
15
+ end
16
+
17
+ private
18
+
19
+ def file_paths_to_use
20
+ DiffTest.configuration.project_files -
21
+ DiffTest.configuration.ignored_files -
22
+ DiffTest.configuration.tracked_files -
23
+ DiffTest.configuration.tracked_js_files
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,44 @@
1
+ module DiffTest
2
+ class ShouldRunDecider
3
+ def initialize
4
+ previous_compatible_test_executions = DiffTest::TestSuiteExecution.current.previous_compatible_test_executions
5
+ @impacted_file_path_by_id = previous_compatible_test_executions['impacted_file_path_by_id']
6
+ @test_suite_executions_by_id = previous_compatible_test_executions['test_suite_executions_by_id']
7
+ @test_executions_by_test = previous_compatible_test_executions['test_executions_by_test']
8
+ end
9
+
10
+ def should_run?(test_id, system_test:)
11
+ previous_test_executions = @test_executions_by_test[test_id] || []
12
+
13
+ # If there are any previous test executions run whose impacted files are the same as now
14
+ # Assume its safe to skip
15
+ has_previous_execution_with_no_impacted_changes = previous_test_executions.any? do |previous_test_execution|
16
+ hashes_match = previous_test_execution['test_execution_impacted_files'].all? do |test_execution_impacted_file|
17
+ impacted_file_id = test_execution_impacted_file['impacted_file_id'].to_s
18
+ file_hash = test_execution_impacted_file['file_hash']
19
+ impacted_file_path = @impacted_file_path_by_id[impacted_file_id]
20
+
21
+ DiffTest::FileHashComputer.compute_relative(impacted_file_path) == file_hash
22
+ end
23
+ next false unless hashes_match
24
+ next true unless system_test
25
+
26
+ test_suite_execution = @test_suite_executions_by_id[previous_test_execution['test_suite_execution_id'].to_s]
27
+ next true if test_suite_execution['all_js_files_annotated']
28
+ next true if test_suite_execution['js_version_hash'] == DiffTest::TestSuiteExecution.current.js_version_hash
29
+
30
+ false
31
+ end
32
+
33
+ !has_previous_execution_with_no_impacted_changes
34
+ end
35
+
36
+ def self.current
37
+ @current ||= DiffTest::ShouldRunDecider.new
38
+ end
39
+
40
+ def self.should_run?(test_id, system_test:)
41
+ current.should_run?(test_id, system_test: system_test)
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,92 @@
1
+ require_relative 'should_run_decider'
2
+
3
+ module DiffTest
4
+ class TestExecution
5
+ attr_reader :impacted_file_tracker
6
+
7
+ def initialize(test_file_path:, test_name:)
8
+ @test_file_path = test_file_path
9
+ @test_name = test_name
10
+ @result = :queued
11
+ @started_at = Time.now
12
+ @stopped_at = Time.now
13
+ @impacted_file_tracker = DiffTest::ImpactedFileTracker.new
14
+ self.class.set(id, self)
15
+ end
16
+
17
+ def should_run?
18
+ return @should_run unless @should_run.nil?
19
+ @should_run = DiffTest::ShouldRunDecider.should_run?(id, system_test: system_test?)
20
+ end
21
+
22
+ def save_payload
23
+ {
24
+ test: id,
25
+ result: @result,
26
+ runtime_ms: runtime_ms,
27
+ impacted_files: @impacted_file_tracker.save_payload,
28
+ }
29
+ end
30
+
31
+ def id
32
+ @id ||= DiffTest::Helper.test_id(@test_file_path, @test_name)
33
+ end
34
+
35
+ def start
36
+ @impacted_file_tracker.start
37
+ @started_at = Time.now
38
+ end
39
+
40
+ def stop
41
+ @impacted_file_tracker.stop
42
+ @stopped_at = Time.now
43
+ finish
44
+ end
45
+
46
+ def finish
47
+ DiffTest::TestSuiteExecution.current.record_test_execution(self)
48
+ end
49
+
50
+ def not_run!
51
+ @result = :not_run
52
+ end
53
+
54
+ def skipped!
55
+ @result = :skipped
56
+ end
57
+
58
+ def passed!
59
+ @result = :passed
60
+ end
61
+
62
+ def failed!
63
+ @result = :failed
64
+ end
65
+
66
+ def runtime_ms
67
+ return 0 if @stopped_at.nil? || @started_at.nil?
68
+ (@stopped_at - @started_at) * 1000
69
+ end
70
+
71
+ def system_test?
72
+ DiffTest.configuration.system_test_paths.include?(@test_file_path)
73
+ end
74
+
75
+ def self.set(test_id, instance)
76
+ @by_id ||= {}
77
+ @by_id[test_id] = instance
78
+ end
79
+
80
+ def self.get(test_id)
81
+ @by_id[test_id]
82
+ end
83
+
84
+ def self.current
85
+ @current
86
+ end
87
+
88
+ def self.current=(instance)
89
+ @current = instance
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,114 @@
1
+ require_relative 'project_version_hash_computer'
2
+ require_relative 'js_version_hash_computer'
3
+
4
+ module DiffTest
5
+ class TestSuiteExecution
6
+ def initialize(tests:)
7
+ @tests = tests.compact
8
+ @test_executions = []
9
+
10
+ find_or_create_on_server
11
+ end
12
+
13
+ def previous_compatible_test_executions
14
+ DiffTest::ApiClient.post("/test_suite_executions/#{id}/previous_compatible_test_executions", body: { tests: @tests })
15
+ end
16
+
17
+ def ensure_application_eager_loaded!
18
+ return if @eager_loaded
19
+ @eager_loaded = true
20
+ ::Rails.application.eager_load! if defined?(::Rails)
21
+ end
22
+
23
+
24
+ def record_test_execution(test_execution)
25
+ @test_executions << test_execution
26
+ end
27
+
28
+ def run?(test_file_path, test_name)
29
+ should_run_decider.run?(test_file_path, test_name)
30
+ end
31
+
32
+ def save
33
+ save_test_execution_results_on_server
34
+ end
35
+
36
+ def js_version_hash
37
+ @js_version_hash ||= DiffTest::JsVersionHashComputer.compute
38
+ end
39
+
40
+ def self.current
41
+ @current
42
+ end
43
+
44
+ def self.current=(value)
45
+ @current = value
46
+ end
47
+
48
+ def self.find_or_create(tests)
49
+ self.current ||= new(tests:)
50
+ end
51
+
52
+ private
53
+
54
+ def id
55
+ @id || raise('Not created')
56
+ end
57
+
58
+ def find_or_create_on_server
59
+ response = DiffTest::ApiClient.post("/test_suite_executions", body: payload)
60
+ raise "Failed to create test suite execution: #{response.inspect}" unless response.success?
61
+ @id = response['id']
62
+ end
63
+
64
+ def save_test_execution_results_on_server
65
+ raise "Expected to have at least one test execution" if @test_executions.empty?
66
+ response = DiffTest::ApiClient.post("/test_suite_executions/#{id}/test_executions/bulk_create", body: { test_executions: @test_executions.map(&:save_payload) })
67
+ end
68
+
69
+ def payload
70
+ {
71
+ key: key,
72
+ project_version_hash: project_version_hash,
73
+ js_version_hash: js_version_hash,
74
+ all_js_files_annotated: all_js_files_annotated?,
75
+ rev_sha: rev_sha,
76
+ commit_title: commit_title,
77
+ branch_name: branch_name,
78
+ ci_platform: DiffTest.configuration.ci_platform,
79
+ meta: meta,
80
+ }
81
+ end
82
+
83
+ def key
84
+ ENV['CIRCLE_WORKFLOW_ID'] || SecureRandom.hex(48) || rev_sha
85
+ end
86
+
87
+ def project_version_hash
88
+ @project_version_hash ||= DiffTest::ProjectVersionHashComputer.compute
89
+ end
90
+
91
+ def all_js_files_annotated?
92
+ @all_js_files_annotated ||= DiffTest.configuration.tracked_js_files.all? do |file_path|
93
+ file_path = DiffTest::Helper.expand_path(file_path)
94
+ DiffTest::Integrations::RailsJs::Annotator.annotated?(File.read(file_path))
95
+ end
96
+ end
97
+
98
+ def rev_sha
99
+ @rev_sha ||= `git rev-parse HEAD`.strip
100
+ end
101
+
102
+ def commit_title
103
+ @commit_title ||= `git log -1 --pretty=%B`.strip
104
+ end
105
+
106
+ def branch_name
107
+ @branch_name ||= `git rev-parse --abbrev-ref HEAD`.strip
108
+ end
109
+
110
+ def meta
111
+ {}
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,23 @@
1
+ module DiffTest
2
+ module Trackers
3
+ class Base
4
+ attr_reader :impacted_files
5
+
6
+ def initialize
7
+ @impacted_files = Set.new
8
+ end
9
+
10
+ def enable
11
+ raise NotImplementedError
12
+ end
13
+
14
+ def disable
15
+ raise NotImplementedError
16
+ end
17
+
18
+ def record(path)
19
+ @impacted_files << path
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,7 @@
1
+ require_relative 'singleton_base'
2
+ module DiffTest
3
+ module Trackers
4
+ class JsFile < DiffTest::Trackers::SingletonBase
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,50 @@
1
+ require_relative 'base'
2
+ module DiffTest
3
+ module Trackers
4
+ class RubyFile < DiffTest::Trackers::Base
5
+ @@location_by_constant = {}
6
+
7
+ def enable
8
+ @seen_constants = Set.new
9
+ @tracepoint = TracePoint.new(:call, :c_call, :b_call) do |event|
10
+ if DiffTest::Helper.file_in_project?(event.path)
11
+ record(DiffTest::Helper.relative_path_from_project_root(event.path))
12
+ end
13
+
14
+ constant = event.defined_class
15
+ constant = event.self if constant == Class && event.method_id == :new
16
+
17
+ unless @seen_constants.include?(constant)
18
+ @seen_constants.add(constant)
19
+ constant = constant.attached_object if constant&.singleton_class?
20
+
21
+ path = @@location_by_constant[constant]
22
+
23
+ if path.nil?
24
+ if constant
25
+ path = begin
26
+ DiffTest::Helper.const_source_path(constant.name)
27
+ rescue StandardError
28
+ nil
29
+ end
30
+ end
31
+ path = false if !path || !DiffTest::Helper.file_in_project?(path)
32
+ path = DiffTest::Helper.relative_path_from_project_root(path) if path
33
+ @@location_by_constant[constant] = path
34
+ end
35
+
36
+ record(path) if path
37
+ end
38
+ end
39
+
40
+ @tracepoint.enable
41
+ end
42
+
43
+ def disable
44
+ @tracepoint.disable
45
+ @tracepoint = nil
46
+ @seen_constants = nil
47
+ end
48
+ end
49
+ end
50
+ end