jobpayload 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 +52 -0
- data/LICENSE +21 -0
- data/README.md +622 -0
- data/exe/jobpayload +6 -0
- data/lib/jobpayload/boot.rb +49 -0
- data/lib/jobpayload/canonical.rb +87 -0
- data/lib/jobpayload/case_registry.rb +86 -0
- data/lib/jobpayload/checker.rb +187 -0
- data/lib/jobpayload/cli.rb +221 -0
- data/lib/jobpayload/errors.rb +16 -0
- data/lib/jobpayload/exception_classifier.rb +88 -0
- data/lib/jobpayload/finding.rb +82 -0
- data/lib/jobpayload/fixture.rb +94 -0
- data/lib/jobpayload/fixture_loader.rb +36 -0
- data/lib/jobpayload/formatter/json.rb +27 -0
- data/lib/jobpayload/formatter/text.rb +82 -0
- data/lib/jobpayload/formatter.rb +19 -0
- data/lib/jobpayload/result.rb +60 -0
- data/lib/jobpayload/snapshotter.rb +122 -0
- data/lib/jobpayload/version.rb +5 -0
- data/lib/jobpayload.rb +61 -0
- metadata +89 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobPayload
|
|
4
|
+
# One problem found while checking a fixture.
|
|
5
|
+
#
|
|
6
|
+
# Finding codes are a stable API: their meaning does not change within 0.1.x.
|
|
7
|
+
Finding = Struct.new(
|
|
8
|
+
:code,
|
|
9
|
+
:fixture,
|
|
10
|
+
:job_class,
|
|
11
|
+
:argument_path,
|
|
12
|
+
:message,
|
|
13
|
+
:exception_class,
|
|
14
|
+
:exception_message,
|
|
15
|
+
:cause_chain, # Array of {"class" => String, "message" => String}, outermost first
|
|
16
|
+
:backtraces, # Array of Array<String>, parallel to cause_chain; never serialized to JSON
|
|
17
|
+
keyword_init: true
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
class Finding
|
|
21
|
+
# code => [name, kind]. kind is :breaking, :inconclusive or :fatal.
|
|
22
|
+
CODES = {
|
|
23
|
+
"AJP001" => ["fixture_invalid", :fatal],
|
|
24
|
+
"AJP101" => ["unknown_job_class", :breaking],
|
|
25
|
+
"AJP102" => ["job_deserialize_failed", :breaking],
|
|
26
|
+
"AJP201" => ["argument_deserialize_failed", :breaking],
|
|
27
|
+
"AJP202" => ["globalid_record_missing", :inconclusive],
|
|
28
|
+
"AJP900" => ["environment_error", :fatal]
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
# Severity strings used in JSON output.
|
|
32
|
+
SEVERITIES = { breaking: "error", inconclusive: "warning", fatal: "fatal" }.freeze
|
|
33
|
+
|
|
34
|
+
def self.build(code:, fixture:, message:, job_class: nil, argument_path: nil, exception: nil)
|
|
35
|
+
raise ArgumentError, "unknown finding code #{code}" unless CODES.key?(code)
|
|
36
|
+
|
|
37
|
+
chain = exception ? ExceptionClassifier.cause_chain(exception) : []
|
|
38
|
+
new(
|
|
39
|
+
code: code,
|
|
40
|
+
fixture: fixture,
|
|
41
|
+
job_class: job_class,
|
|
42
|
+
argument_path: argument_path,
|
|
43
|
+
message: message,
|
|
44
|
+
exception_class: exception && ExceptionClassifier.class_name(exception),
|
|
45
|
+
exception_message: exception && ExceptionClassifier.safe_message(exception),
|
|
46
|
+
cause_chain: chain.map { |e| { "class" => ExceptionClassifier.class_name(e), "message" => ExceptionClassifier.safe_message(e) } },
|
|
47
|
+
backtraces: chain.map { |e| Array(e.backtrace) }
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def name
|
|
52
|
+
CODES.fetch(code).first
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def kind
|
|
56
|
+
CODES.fetch(code).last
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def severity
|
|
60
|
+
SEVERITIES.fetch(kind)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def sort_key
|
|
64
|
+
[fixture.to_s, code, argument_path.to_s, message.to_s]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def to_json_hash
|
|
68
|
+
{
|
|
69
|
+
"code" => code,
|
|
70
|
+
"name" => name,
|
|
71
|
+
"severity" => severity,
|
|
72
|
+
"fixture" => fixture,
|
|
73
|
+
"job_class" => job_class,
|
|
74
|
+
"argument_path" => argument_path,
|
|
75
|
+
"message" => message,
|
|
76
|
+
"exception_class" => exception_class,
|
|
77
|
+
"exception_message" => exception_message,
|
|
78
|
+
"cause_chain" => cause_chain
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module JobPayload
|
|
6
|
+
# A parsed fixture file (schema v1).
|
|
7
|
+
#
|
|
8
|
+
# Fixture.parse never raises for bad content: it returns an Invalid value
|
|
9
|
+
# carrying the reason, which the checker reports as AJP001.
|
|
10
|
+
class Fixture
|
|
11
|
+
Invalid = Struct.new(:name, :path, :reason)
|
|
12
|
+
|
|
13
|
+
attr_reader :name, :path, :job, :source
|
|
14
|
+
|
|
15
|
+
def initialize(name:, path:, job:, source:)
|
|
16
|
+
@name = name
|
|
17
|
+
@path = path
|
|
18
|
+
@job = job
|
|
19
|
+
@source = source
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def job_class
|
|
23
|
+
job["job_class"]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.parse(path)
|
|
27
|
+
basename = File.basename(path, ".json")
|
|
28
|
+
reason = catch(:invalid) do
|
|
29
|
+
data = parse_json(path)
|
|
30
|
+
validate!(data, basename)
|
|
31
|
+
return new(name: data["name"], path: path, job: data["job"], source: data["source"])
|
|
32
|
+
end
|
|
33
|
+
# The file name and OS error messages may carry bytes that are not
|
|
34
|
+
# UTF-8; Invalid values end up in JSON output.
|
|
35
|
+
Invalid.new(JobPayload.scrub_utf8(basename), path, JobPayload.scrub_utf8(reason))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.parse_json(path)
|
|
39
|
+
content = File.binread(path).force_encoding(Encoding::UTF_8)
|
|
40
|
+
invalid!("file is not valid UTF-8") unless content.valid_encoding?
|
|
41
|
+
data = JSON.parse(content, max_nesting: false)
|
|
42
|
+
# Some json versions decode a lone surrogate escape ("\udc80") into
|
|
43
|
+
# bytes that are not valid UTF-8.
|
|
44
|
+
invalid!("file contains a string escape that is not valid UTF-8") unless valid_strings?(data)
|
|
45
|
+
data
|
|
46
|
+
rescue JSON::ParserError => e
|
|
47
|
+
invalid!("invalid JSON: #{e.message.lines.first.to_s.strip}")
|
|
48
|
+
rescue SystemCallError => e
|
|
49
|
+
invalid!("cannot read file: #{e.message}")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Iterative, so that deeply nested fixtures cannot exhaust the stack here.
|
|
53
|
+
def self.valid_strings?(data)
|
|
54
|
+
pending = [data]
|
|
55
|
+
until pending.empty?
|
|
56
|
+
value = pending.pop
|
|
57
|
+
case value
|
|
58
|
+
when String then return false unless value.valid_encoding?
|
|
59
|
+
when Array then pending.concat(value)
|
|
60
|
+
when Hash
|
|
61
|
+
pending.concat(value.keys)
|
|
62
|
+
pending.concat(value.values)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
true
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.validate!(data, basename)
|
|
69
|
+
invalid!("top-level JSON value must be an object") unless data.is_a?(Hash)
|
|
70
|
+
invalid!("missing jobpayload_schema") unless data.key?("jobpayload_schema")
|
|
71
|
+
schema = data["jobpayload_schema"]
|
|
72
|
+
unless schema == FIXTURE_SCHEMA && schema.is_a?(Integer)
|
|
73
|
+
invalid!("unsupported jobpayload_schema #{schema.inspect} (expected #{FIXTURE_SCHEMA})")
|
|
74
|
+
end
|
|
75
|
+
invalid!("invalid file name #{basename.inspect}.json") unless JobPayload.valid_name?(basename)
|
|
76
|
+
|
|
77
|
+
name = data["name"]
|
|
78
|
+
invalid!("name must be a string") unless name.is_a?(String)
|
|
79
|
+
invalid!("invalid name #{name.inspect}") unless JobPayload.valid_name?(name)
|
|
80
|
+
invalid!("name #{name.inspect} does not match file name #{basename}.json") unless name == basename
|
|
81
|
+
invalid!("source must be an object") if data.key?("source") && !data["source"].is_a?(Hash)
|
|
82
|
+
|
|
83
|
+
job = data["job"]
|
|
84
|
+
invalid!("job must be an object") unless job.is_a?(Hash)
|
|
85
|
+
invalid!("job.job_class must be a non-empty string") unless job["job_class"].is_a?(String) && !job["job_class"].empty?
|
|
86
|
+
invalid!("job.arguments must be an array") unless job["arguments"].is_a?(Array)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.invalid!(reason)
|
|
90
|
+
throw :invalid, reason
|
|
91
|
+
end
|
|
92
|
+
private_class_method :parse_json, :valid_strings?, :validate!, :invalid!
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobPayload
|
|
4
|
+
# Resolves --fixtures (a directory or a single file) into fixture files, in a
|
|
5
|
+
# stable order that does not depend on directory enumeration order.
|
|
6
|
+
#
|
|
7
|
+
# Listing (.files) only touches the file system, so it runs before the host
|
|
8
|
+
# application boots; parsing (.parse) needs the json library and runs after
|
|
9
|
+
# boot, so the application's own json version is the one that gets loaded.
|
|
10
|
+
module FixtureLoader
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def files(path)
|
|
14
|
+
files =
|
|
15
|
+
if File.directory?(path)
|
|
16
|
+
Dir.children(path).select { |entry| entry.end_with?(".json") }.sort.map { |entry| File.join(path, entry) }
|
|
17
|
+
.select { |file| File.file?(file) }
|
|
18
|
+
elsif File.file?(path)
|
|
19
|
+
[path]
|
|
20
|
+
else
|
|
21
|
+
raise ConfigurationError, "fixture path not found: #{path}"
|
|
22
|
+
end
|
|
23
|
+
raise ConfigurationError, "no fixture files (*.json) found in #{path}" if files.empty?
|
|
24
|
+
|
|
25
|
+
files
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def parse(files)
|
|
29
|
+
files.map { |file| Fixture.parse(file) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def call(path)
|
|
33
|
+
parse(files(path))
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module JobPayload
|
|
6
|
+
module Formatter
|
|
7
|
+
# Machine-readable output, schema v1. Field names and meanings are stable
|
|
8
|
+
# for 0.1.x; see README "JSON output".
|
|
9
|
+
module JSON
|
|
10
|
+
SCHEMA_VERSION = 1
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def call(result, fail_on_inconclusive: false, debug: false) # rubocop:disable Lint/UnusedMethodArgument
|
|
15
|
+
document = {
|
|
16
|
+
"schema_version" => SCHEMA_VERSION,
|
|
17
|
+
"tool_version" => VERSION,
|
|
18
|
+
"status" => result.status(fail_on_inconclusive: fail_on_inconclusive),
|
|
19
|
+
"summary" => result.summary,
|
|
20
|
+
"fixtures" => result.fixture_results.map { |r| { "name" => r.name, "status" => r.status.to_s } },
|
|
21
|
+
"findings" => result.findings.map(&:to_json_hash)
|
|
22
|
+
}
|
|
23
|
+
Canonical.pretty(document)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobPayload
|
|
4
|
+
module Formatter
|
|
5
|
+
# Human-readable output. Ordering is fully determined by fixture name,
|
|
6
|
+
# finding code and argument path.
|
|
7
|
+
module Text
|
|
8
|
+
LABELS = { breaking: "breaking", inconclusive: "inconclusive", fatal: "fatal" }.freeze
|
|
9
|
+
|
|
10
|
+
NOTES = {
|
|
11
|
+
"AJP202" => "This does not by itself prove a payload compatibility break."
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def call(result, fail_on_inconclusive: false, debug: false) # rubocop:disable Lint/UnusedMethodArgument
|
|
17
|
+
blocks = result.fixture_results.flat_map do |fixture_result|
|
|
18
|
+
if fixture_result.findings.empty?
|
|
19
|
+
["PASS #{fixture_result.name}\n"]
|
|
20
|
+
else
|
|
21
|
+
fixture_result.findings.sort_by(&:sort_key).map { |finding| finding_block(finding, debug: debug) }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
out = +""
|
|
25
|
+
blocks.each_with_index do |block, index|
|
|
26
|
+
previous = blocks[index - 1] if index.positive?
|
|
27
|
+
# Keep consecutive PASS lines together; separate multi-line blocks.
|
|
28
|
+
out << "\n" if previous && (multiline?(block) || multiline?(previous))
|
|
29
|
+
out << block
|
|
30
|
+
end
|
|
31
|
+
out << "\n" << summary(result)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def multiline?(block)
|
|
35
|
+
block.count("\n") > 1
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def finding_block(finding, debug:)
|
|
39
|
+
lines = ["#{finding.code} #{LABELS.fetch(finding.kind)} #{finding.fixture}", ""]
|
|
40
|
+
if finding.job_class
|
|
41
|
+
lines += ["Job:", " #{finding.job_class}", ""]
|
|
42
|
+
end
|
|
43
|
+
if finding.argument_path
|
|
44
|
+
lines += ["Argument:", " #{finding.argument_path}", ""]
|
|
45
|
+
end
|
|
46
|
+
lines << finding.message
|
|
47
|
+
if (note = NOTES[finding.code])
|
|
48
|
+
lines += ["", note]
|
|
49
|
+
end
|
|
50
|
+
if (root = finding.cause_chain.last)
|
|
51
|
+
lines += ["", "Cause:", " #{root['class']}: #{root['message']}"]
|
|
52
|
+
end
|
|
53
|
+
lines += debug_lines(finding) if debug
|
|
54
|
+
"#{lines.join("\n")}\n"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def debug_lines(finding)
|
|
58
|
+
return [] if finding.cause_chain.empty?
|
|
59
|
+
|
|
60
|
+
lines = ["", "Cause chain:"]
|
|
61
|
+
finding.cause_chain.each_with_index do |cause, index|
|
|
62
|
+
lines << " #{index + 1}. #{cause['class']}: #{cause['message']}"
|
|
63
|
+
finding.backtraces.fetch(index, []).each { |frame| lines << " #{frame}" }
|
|
64
|
+
end
|
|
65
|
+
lines
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def summary(result)
|
|
69
|
+
counts = result.summary
|
|
70
|
+
lines = [
|
|
71
|
+
"#{counts['fixtures']} fixtures checked",
|
|
72
|
+
"#{counts['compatible']} compatible",
|
|
73
|
+
"#{counts['incompatible']} incompatible",
|
|
74
|
+
"#{counts['inconclusive']} inconclusive"
|
|
75
|
+
]
|
|
76
|
+
tool_errors = counts["tool_errors"]
|
|
77
|
+
lines << "#{tool_errors} tool error#{'s' unless tool_errors == 1}" if tool_errors.positive?
|
|
78
|
+
"#{lines.join("\n")}\n"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobPayload
|
|
4
|
+
# Output formatters for `check` results.
|
|
5
|
+
module Formatter
|
|
6
|
+
autoload :Text, "jobpayload/formatter/text"
|
|
7
|
+
autoload :JSON, "jobpayload/formatter/json"
|
|
8
|
+
|
|
9
|
+
FORMATS = %w[text json].freeze
|
|
10
|
+
|
|
11
|
+
def self.for(name)
|
|
12
|
+
case name
|
|
13
|
+
when "text" then Text
|
|
14
|
+
when "json" then JSON
|
|
15
|
+
else raise UsageError, "unknown format #{name.inspect} (expected one of: #{FORMATS.join(', ')})"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobPayload
|
|
4
|
+
# Outcome of a `check` run.
|
|
5
|
+
class Result
|
|
6
|
+
# Per-fixture outcome. status is :pass, :fail, :inconclusive or
|
|
7
|
+
# :tool_error (the worst finding wins).
|
|
8
|
+
FixtureResult = Struct.new(:name, :path, :findings) do
|
|
9
|
+
def status
|
|
10
|
+
kinds = findings.map(&:kind)
|
|
11
|
+
if kinds.include?(:fatal) then :tool_error
|
|
12
|
+
elsif kinds.include?(:breaking) then :fail
|
|
13
|
+
elsif kinds.include?(:inconclusive) then :inconclusive
|
|
14
|
+
else :pass
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
EXIT_SUCCESS = 0
|
|
20
|
+
EXIT_FAILURE = 1
|
|
21
|
+
EXIT_ERROR = 2
|
|
22
|
+
|
|
23
|
+
attr_reader :fixture_results
|
|
24
|
+
|
|
25
|
+
def initialize(fixture_results)
|
|
26
|
+
@fixture_results = fixture_results
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# All findings, sorted by fixture, code, then argument path.
|
|
30
|
+
def findings
|
|
31
|
+
fixture_results.flat_map(&:findings).sort_by(&:sort_key)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def summary
|
|
35
|
+
counts = fixture_results.map(&:status).tally
|
|
36
|
+
{
|
|
37
|
+
"fixtures" => fixture_results.size,
|
|
38
|
+
"compatible" => counts.fetch(:pass, 0),
|
|
39
|
+
"incompatible" => counts.fetch(:fail, 0),
|
|
40
|
+
"inconclusive" => counts.fetch(:inconclusive, 0),
|
|
41
|
+
"tool_errors" => counts.fetch(:tool_error, 0)
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# "pass", "fail" or "tool_error".
|
|
46
|
+
def status(fail_on_inconclusive: false)
|
|
47
|
+
statuses = fixture_results.map(&:status)
|
|
48
|
+
if statuses.include?(:tool_error) then "tool_error"
|
|
49
|
+
elsif statuses.include?(:fail) then "fail"
|
|
50
|
+
elsif fail_on_inconclusive && statuses.include?(:inconclusive) then "fail"
|
|
51
|
+
else "pass"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def exit_code(fail_on_inconclusive: false)
|
|
56
|
+
{ "tool_error" => EXIT_ERROR, "fail" => EXIT_FAILURE, "pass" => EXIT_SUCCESS }
|
|
57
|
+
.fetch(status(fail_on_inconclusive: fail_on_inconclusive))
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module JobPayload
|
|
6
|
+
# Turns snapshot cases into fixture files.
|
|
7
|
+
#
|
|
8
|
+
# The source of truth is ActiveJob::Base#serialize. Volatile Active Job
|
|
9
|
+
# metadata is replaced with fixed values so repeated snapshots are
|
|
10
|
+
# byte-for-byte identical; every other key (including keys added by a job's
|
|
11
|
+
# own #serialize override) is kept as-is.
|
|
12
|
+
class Snapshotter
|
|
13
|
+
CANONICAL_JOB_ID = "00000000-0000-0000-0000-000000000000"
|
|
14
|
+
CANONICAL_TIME = "2000-01-01T00:00:00.000000000Z"
|
|
15
|
+
|
|
16
|
+
# Volatile keys and the stable values they are replaced with. Keys are only
|
|
17
|
+
# replaced when present, so the shape of #serialize is never changed.
|
|
18
|
+
NORMALIZED = {
|
|
19
|
+
"job_id" => CANONICAL_JOB_ID,
|
|
20
|
+
"provider_job_id" => nil,
|
|
21
|
+
"enqueued_at" => CANONICAL_TIME,
|
|
22
|
+
"executions" => 0,
|
|
23
|
+
"exception_executions" => {}
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
Entry = Struct.new(:name, :path, :status) # status: :created, :identical, :updated, :skipped
|
|
27
|
+
|
|
28
|
+
def initialize(registry:, output_dir:, update: false)
|
|
29
|
+
@registry = registry
|
|
30
|
+
@output_dir = output_dir
|
|
31
|
+
@update = update
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Generates every fixture in memory first, so a failing case leaves the
|
|
35
|
+
# fixture directory untouched, then writes them in name order.
|
|
36
|
+
def call
|
|
37
|
+
documents = @registry.cases.map { |kase| [kase.name, Canonical.generate(build_document(kase))] }
|
|
38
|
+
begin
|
|
39
|
+
FileUtils.mkdir_p(@output_dir)
|
|
40
|
+
rescue SystemCallError => e
|
|
41
|
+
raise ConfigurationError, "cannot create output directory #{@output_dir}: #{e.message}"
|
|
42
|
+
end
|
|
43
|
+
documents.map { |name, content| write(name, content) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def build_document(kase)
|
|
47
|
+
{
|
|
48
|
+
"jobpayload_schema" => FIXTURE_SCHEMA,
|
|
49
|
+
"name" => kase.name,
|
|
50
|
+
"source" => self.class.source_versions,
|
|
51
|
+
"job" => serialize_case(kase)
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.normalize(job_data)
|
|
56
|
+
data = job_data.to_h { |key, value| [key.to_s, value] }
|
|
57
|
+
NORMALIZED.each { |key, value| data[key] = Canonical.deep_copy(value) if data.key?(key) }
|
|
58
|
+
data["scheduled_at"] = CANONICAL_TIME if data.key?("scheduled_at") && !data["scheduled_at"].nil?
|
|
59
|
+
# Round-trip through JSON so the fixture holds exactly what a queue would store.
|
|
60
|
+
JSON.parse(JSON.generate(data))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.source_versions
|
|
64
|
+
{
|
|
65
|
+
"ruby_version" => RUBY_VERSION,
|
|
66
|
+
"active_job_version" => active_job_version,
|
|
67
|
+
"rails_version" => rails_version,
|
|
68
|
+
"jobpayload_version" => VERSION
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.active_job_version
|
|
73
|
+
spec = Gem.loaded_specs["activejob"]
|
|
74
|
+
return spec.version.to_s if spec
|
|
75
|
+
return ::ActiveJob.version.to_s if defined?(::ActiveJob) && ::ActiveJob.respond_to?(:version)
|
|
76
|
+
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.rails_version
|
|
81
|
+
return nil unless defined?(::Rails) && ::Rails.respond_to?(:version)
|
|
82
|
+
|
|
83
|
+
::Rails.version.to_s
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
# Runs the case block and serializes the job it returns. Any error is
|
|
89
|
+
# reported against the case, never as an internal error.
|
|
90
|
+
def serialize_case(kase)
|
|
91
|
+
job = kase.block.call
|
|
92
|
+
unless job.is_a?(::ActiveJob::Base)
|
|
93
|
+
raise ConfigurationError,
|
|
94
|
+
"fixture #{kase.name.inspect} (#{kase.location}) must return an ActiveJob::Base instance, got #{job.class}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
self.class.normalize(job.serialize)
|
|
98
|
+
rescue ConfigurationError
|
|
99
|
+
raise
|
|
100
|
+
rescue StandardError, ScriptError => e
|
|
101
|
+
raise ConfigurationError, "fixture #{kase.name.inspect} (#{kase.location}) raised #{e.class}: #{e.message}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def write(name, content)
|
|
105
|
+
path = File.join(@output_dir, "#{name}.json")
|
|
106
|
+
status =
|
|
107
|
+
if !File.exist?(path)
|
|
108
|
+
:created
|
|
109
|
+
elsif File.binread(path) == content.b
|
|
110
|
+
:identical
|
|
111
|
+
elsif @update
|
|
112
|
+
:updated
|
|
113
|
+
else
|
|
114
|
+
:skipped
|
|
115
|
+
end
|
|
116
|
+
Canonical.atomic_write(path, content) if %i[created updated].include?(status)
|
|
117
|
+
Entry.new(name, path, status)
|
|
118
|
+
rescue SystemCallError => e
|
|
119
|
+
raise ConfigurationError, "cannot write fixture #{path}: #{e.message}"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
data/lib/jobpayload.rb
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Entry point for the jobpayload gem.
|
|
4
|
+
#
|
|
5
|
+
# This file is intentionally lightweight: requiring it must not load Active Job
|
|
6
|
+
# (or any other part of the host application's framework). Active Job APIs are
|
|
7
|
+
# referenced lazily, only after the host application has been booted, so that
|
|
8
|
+
# custom serializer registration and initializer order are left untouched.
|
|
9
|
+
require_relative "jobpayload/version"
|
|
10
|
+
require_relative "jobpayload/errors"
|
|
11
|
+
require_relative "jobpayload/case_registry"
|
|
12
|
+
|
|
13
|
+
module JobPayload
|
|
14
|
+
autoload :CLI, "jobpayload/cli"
|
|
15
|
+
autoload :Boot, "jobpayload/boot"
|
|
16
|
+
autoload :Canonical, "jobpayload/canonical"
|
|
17
|
+
autoload :Snapshotter, "jobpayload/snapshotter"
|
|
18
|
+
autoload :Fixture, "jobpayload/fixture"
|
|
19
|
+
autoload :FixtureLoader, "jobpayload/fixture_loader"
|
|
20
|
+
autoload :Checker, "jobpayload/checker"
|
|
21
|
+
autoload :Result, "jobpayload/result"
|
|
22
|
+
autoload :Finding, "jobpayload/finding"
|
|
23
|
+
autoload :ExceptionClassifier, "jobpayload/exception_classifier"
|
|
24
|
+
autoload :Formatter, "jobpayload/formatter"
|
|
25
|
+
|
|
26
|
+
# Pattern every fixture name must match. Rejects path separators, leading
|
|
27
|
+
# dots and anything else that could escape the fixture directory.
|
|
28
|
+
NAME_PATTERN = /\A[A-Za-z0-9][A-Za-z0-9._-]*\z/
|
|
29
|
+
|
|
30
|
+
# Fixture wrapper schema version written by `snapshot` and read by `check`.
|
|
31
|
+
FIXTURE_SCHEMA = 1
|
|
32
|
+
|
|
33
|
+
# Defines snapshot cases. Called from the cases file (test/jobpayload_cases.rb
|
|
34
|
+
# by default) while `jobpayload snapshot` is loading it.
|
|
35
|
+
#
|
|
36
|
+
# JobPayload.define do
|
|
37
|
+
# fixture "billing-money-v1" do
|
|
38
|
+
# BillingJob.new(Money.new(1_250, "USD"))
|
|
39
|
+
# end
|
|
40
|
+
# end
|
|
41
|
+
def self.define(&block)
|
|
42
|
+
CaseRegistry.current.define(&block)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.valid_name?(name)
|
|
46
|
+
name.is_a?(String) && NAME_PATTERN.match?(name) && !name.include?("..")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Returns +string+ as valid UTF-8 (invalid bytes become U+FFFD), for text
|
|
50
|
+
# from outside jobpayload (exception messages, file names, paths) that ends
|
|
51
|
+
# up in JSON output.
|
|
52
|
+
def self.scrub_utf8(string)
|
|
53
|
+
if [Encoding::UTF_8, Encoding::BINARY, Encoding::US_ASCII].include?(string.encoding)
|
|
54
|
+
string.dup.force_encoding(Encoding::UTF_8).scrub("\uFFFD")
|
|
55
|
+
else
|
|
56
|
+
string.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "\uFFFD")
|
|
57
|
+
end
|
|
58
|
+
rescue EncodingError # no converter (for example UTF-7): keep the bytes
|
|
59
|
+
string.b.force_encoding(Encoding::UTF_8).scrub("\uFFFD")
|
|
60
|
+
end
|
|
61
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: jobpayload
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- cottondesu
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activejob
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.2'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '9'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '7.2'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '9'
|
|
32
|
+
description: |
|
|
33
|
+
jobpayload snapshots Active Job serialized job data (ActiveJob::Base#serialize) into
|
|
34
|
+
deterministic JSON fixtures and checks that the current application can still
|
|
35
|
+
deserialize them (ActiveJob::Base.deserialize and ActiveJob::Arguments.deserialize)
|
|
36
|
+
without ever performing the job.
|
|
37
|
+
executables:
|
|
38
|
+
- jobpayload
|
|
39
|
+
extensions: []
|
|
40
|
+
extra_rdoc_files: []
|
|
41
|
+
files:
|
|
42
|
+
- CHANGELOG.md
|
|
43
|
+
- LICENSE
|
|
44
|
+
- README.md
|
|
45
|
+
- exe/jobpayload
|
|
46
|
+
- lib/jobpayload.rb
|
|
47
|
+
- lib/jobpayload/boot.rb
|
|
48
|
+
- lib/jobpayload/canonical.rb
|
|
49
|
+
- lib/jobpayload/case_registry.rb
|
|
50
|
+
- lib/jobpayload/checker.rb
|
|
51
|
+
- lib/jobpayload/cli.rb
|
|
52
|
+
- lib/jobpayload/errors.rb
|
|
53
|
+
- lib/jobpayload/exception_classifier.rb
|
|
54
|
+
- lib/jobpayload/finding.rb
|
|
55
|
+
- lib/jobpayload/fixture.rb
|
|
56
|
+
- lib/jobpayload/fixture_loader.rb
|
|
57
|
+
- lib/jobpayload/formatter.rb
|
|
58
|
+
- lib/jobpayload/formatter/json.rb
|
|
59
|
+
- lib/jobpayload/formatter/text.rb
|
|
60
|
+
- lib/jobpayload/result.rb
|
|
61
|
+
- lib/jobpayload/snapshotter.rb
|
|
62
|
+
- lib/jobpayload/version.rb
|
|
63
|
+
homepage: https://github.com/cottondesu/jobpayload
|
|
64
|
+
licenses:
|
|
65
|
+
- MIT
|
|
66
|
+
metadata:
|
|
67
|
+
homepage_uri: https://github.com/cottondesu/jobpayload
|
|
68
|
+
source_code_uri: https://github.com/cottondesu/jobpayload
|
|
69
|
+
changelog_uri: https://github.com/cottondesu/jobpayload/blob/main/CHANGELOG.md
|
|
70
|
+
rubygems_mfa_required: 'true'
|
|
71
|
+
rdoc_options: []
|
|
72
|
+
require_paths:
|
|
73
|
+
- lib
|
|
74
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
75
|
+
requirements:
|
|
76
|
+
- - ">="
|
|
77
|
+
- !ruby/object:Gem::Version
|
|
78
|
+
version: '3.3'
|
|
79
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
80
|
+
requirements:
|
|
81
|
+
- - ">="
|
|
82
|
+
- !ruby/object:Gem::Version
|
|
83
|
+
version: '0'
|
|
84
|
+
requirements: []
|
|
85
|
+
rubygems_version: 3.6.9
|
|
86
|
+
specification_version: 4
|
|
87
|
+
summary: Checks that previously serialized Active Job payloads still deserialize on
|
|
88
|
+
new code.
|
|
89
|
+
test_files: []
|