xcode-result-bundle-processor 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/.codeclimate.yml +23 -0
  3. data/.csslintrc +34 -0
  4. data/.eslintrc +253 -0
  5. data/.gitignore +6 -0
  6. data/.rspec +2 -0
  7. data/.rubocop.yml +1171 -0
  8. data/.ruby-gemset +1 -0
  9. data/.ruby-version +1 -0
  10. data/.travis.yml +8 -0
  11. data/Gemfile +4 -0
  12. data/Gemfile.lock +83 -0
  13. data/LICENSE +36 -0
  14. data/README.md +72 -0
  15. data/Rakefile +50 -0
  16. data/bin/xcode-result-bundle-processor +37 -0
  17. data/lib/xcoderesultbundleprocessor.rb +28 -0
  18. data/lib/xcoderesultbundleprocessor/activity_log_formatter.rb +20 -0
  19. data/lib/xcoderesultbundleprocessor/element_snapshot.rb +81 -0
  20. data/lib/xcoderesultbundleprocessor/indented_string_buffer.rb +33 -0
  21. data/lib/xcoderesultbundleprocessor/keyword_struct.rb +10 -0
  22. data/lib/xcoderesultbundleprocessor/log_deserializer.rb +26 -0
  23. data/lib/xcoderesultbundleprocessor/results_bundle.rb +45 -0
  24. data/lib/xcoderesultbundleprocessor/slf0/class_name_resolver.rb +26 -0
  25. data/lib/xcoderesultbundleprocessor/slf0/deserializer.rb +45 -0
  26. data/lib/xcoderesultbundleprocessor/slf0/model/dvtdocumentlocation.rb +16 -0
  27. data/lib/xcoderesultbundleprocessor/slf0/model/dvttextdocumentlocation.rb +23 -0
  28. data/lib/xcoderesultbundleprocessor/slf0/model/ideactivitylogmessage.rb +26 -0
  29. data/lib/xcoderesultbundleprocessor/slf0/model/ideactivitylogsection.rb +29 -0
  30. data/lib/xcoderesultbundleprocessor/slf0/model/ideactivitylogunittestsection.rb +24 -0
  31. data/lib/xcoderesultbundleprocessor/slf0/model/ideconsoleitem.rb +16 -0
  32. data/lib/xcoderesultbundleprocessor/slf0/tokenizer.rb +82 -0
  33. data/lib/xcoderesultbundleprocessor/snapshot_summary.rb +47 -0
  34. data/lib/xcoderesultbundleprocessor/test_summaries/html_report.rb +156 -0
  35. data/lib/xcoderesultbundleprocessor/test_summaries/test_summaries.rb +84 -0
  36. data/lib/xcoderesultbundleprocessor/test_summaries/text_report.rb +40 -0
  37. data/lib/xcoderesultbundleprocessor/version.rb +3 -0
  38. data/static/report.css +30 -0
  39. data/static/report.js +14 -0
  40. data/xcode-result-bundle-processor.gemspec +30 -0
  41. metadata +225 -0
@@ -0,0 +1,10 @@
1
+ module XcodeResultBundleProcessor
2
+
3
+ class KeywordStruct < Struct
4
+ def initialize(*args, **kwargs)
5
+ super()
6
+ param_hash = kwargs.any? ? kwargs : Hash[members.zip(args)]
7
+ param_hash.each { |k, v| self[k] = v }
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,26 @@
1
+ module XcodeResultBundleProcessor
2
+ module LogDeserializer
3
+ include Methadone::CLILogging
4
+
5
+ def self.deserialize_action_logs(results_bundle)
6
+
7
+ plist = results_bundle.read_plist('Info.plist')
8
+
9
+ action = plist['Actions'].first
10
+ log_pathname = action['ActionResult']['LogPath']
11
+ results_bundle.open_file(log_pathname) do |activity_log_io|
12
+ io = Zlib::GzipReader.new(activity_log_io)
13
+ tokens = SLF0::Tokenizer.read_token_stream(io)
14
+ tokens = SLF0::ClassNameResolver.resolve_class_names(tokens).to_a
15
+
16
+ # SLF0 files have a random int at the beginning; don't know its significance
17
+ tokens.shift
18
+
19
+ section = SLF0::Deserializer.deserialize(tokens)
20
+ debug section.ai
21
+
22
+ ActivityLogFormatter.format(section.subsections.first)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,45 @@
1
+ module XcodeResultBundleProcessor
2
+ class DirectoryResultsBundle
3
+ def initialize(path)
4
+ @path = Pathname.new(path)
5
+ end
6
+
7
+ def read_plist(path)
8
+ CFPropertyList.native_types(CFPropertyList::List.new(file: @path.join(path)).value)
9
+ end
10
+
11
+ def open_file(path, &block)
12
+ File.open(@path.join(path), &block)
13
+ end
14
+
15
+ def copy_file(source, destination)
16
+ FileUtils.copy(@path.join(source), destination)
17
+ end
18
+ end
19
+
20
+ class TarballResultsBundle
21
+ def initialize(path)
22
+ file = File.new(path)
23
+ zip = Zlib::GzipReader.new(file)
24
+ @tar = Gem::Package::TarReader.new(zip)
25
+ end
26
+
27
+ def read_plist(path)
28
+ @tar.seek("./#{path}") do |plist_entry|
29
+ CFPropertyList.native_types(CFPropertyList::List.new(data: plist_entry.read).value)
30
+ end
31
+ end
32
+
33
+ def open_file(path, &block)
34
+ @tar.seek("./#{path}", &block)
35
+ end
36
+
37
+ def copy_file(source, destination)
38
+ @tar.seek("./#{source}") do |source_entry|
39
+ File.open(destination, 'w') do |destination_file|
40
+ destination_file.write(source_entry.read)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,26 @@
1
+ module XcodeResultBundleProcessor
2
+ module SLF0
3
+ module ClassNameResolver
4
+
5
+ ResolvedClassName = Struct.new(:class_name)
6
+
7
+ def self.resolve_class_names(tokens)
8
+ class_names = []
9
+
10
+ Enumerator.new do |enumerator|
11
+ tokens.each do |token|
12
+ if token.is_a?(Tokenizer::ClassName)
13
+ class_names << token.class_name
14
+ elsif token.is_a?(Tokenizer::ClassNameRef)
15
+ raise "Invalid ClassNameRef to class index #{token.class_name_id}" if token.class_name_id > class_names.length
16
+ class_name = class_names[token.class_name_id - 1]
17
+ enumerator.yield(ResolvedClassName.new(class_name))
18
+ else
19
+ enumerator.yield(token)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,45 @@
1
+ module XcodeResultBundleProcessor
2
+ module SLF0
3
+ module Deserializer
4
+ include Methadone::CLILogging
5
+
6
+ def self.deserialize(tokens)
7
+ debug "Deserializing #{tokens.ai}"
8
+
9
+ if tokens.first.nil?
10
+ tokens.shift
11
+ return nil
12
+ end
13
+
14
+ self._assert_first_token_type(ClassNameResolver::ResolvedClassName, tokens)
15
+
16
+ resolved_class = tokens.shift
17
+ return nil if resolved_class.nil?
18
+
19
+ class_name = resolved_class.class_name
20
+ raise "Unsupported class #{class_name}" unless Model.const_defined?(class_name)
21
+
22
+ Model.const_get(class_name).deserialize(tokens)
23
+ end
24
+
25
+ def self.deserialize_list(tokens)
26
+ if tokens.first.nil?
27
+ tokens.shift
28
+ return []
29
+ end
30
+
31
+ self._assert_first_token_type(Tokenizer::ObjectList, tokens)
32
+
33
+ object_list_info = tokens.shift
34
+
35
+ object_list_info.mystery_number.times.map { Deserializer.deserialize(tokens) }
36
+ end
37
+
38
+ private
39
+
40
+ def self._assert_first_token_type(expected_type, tokens)
41
+ raise "First token should be #{expected_type} but was a <#{tokens.first}>" unless tokens.first.is_a?(expected_type)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,16 @@
1
+ module XcodeResultBundleProcessor
2
+ module SLF0
3
+ module Model
4
+ class DVTDocumentLocation < KeywordStruct.new(:document_url_string,
5
+ :timestamp)
6
+
7
+ def self.deserialize(tokens)
8
+ self.new(
9
+ document_url_string: tokens.shift,
10
+ timestamp: tokens.shift,
11
+ )
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,23 @@
1
+ module XcodeResultBundleProcessor
2
+ module SLF0
3
+ module Model
4
+ class DVTTextDocumentLocation < KeywordStruct.new(*(DVTDocumentLocation.members + [:starting_line_number, :starting_column_number, :ending_line_number, :ending_column_number, :character_range_1, :character_range_2, :location_encoding]))
5
+
6
+ def self.deserialize(tokens)
7
+ parent = DVTDocumentLocation.deserialize(tokens)
8
+ parent_values = DVTDocumentLocation.members.map { |member| parent[member] }
9
+
10
+ self.new(*(parent_values + [
11
+ tokens.shift,
12
+ tokens.shift,
13
+ tokens.shift,
14
+ tokens.shift,
15
+ tokens.shift,
16
+ tokens.shift,
17
+ tokens.shift,
18
+ ]))
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,26 @@
1
+ module XcodeResultBundleProcessor
2
+ module SLF0
3
+ module Model
4
+ class IDEActivityLogMessage < KeywordStruct.new(
5
+ :title, :short_title, :time_emitted, :range_in_section_text_1, :range_in_section_text_2, :submessages, :severity, :type, :location, :category_ident, :secondary_locations, :additional_description)
6
+
7
+ def self.deserialize(tokens)
8
+ self.new(
9
+ title: tokens.shift,
10
+ short_title: tokens.shift,
11
+ time_emitted: tokens.shift,
12
+ range_in_section_text_1: tokens.shift,
13
+ range_in_section_text_2: tokens.shift,
14
+ submessages: Deserializer.deserialize_list(tokens),
15
+ severity: tokens.shift,
16
+ type: tokens.shift,
17
+ location: Deserializer.deserialize(tokens),
18
+ category_ident: tokens.shift,
19
+ secondary_locations: Deserializer.deserialize_list(tokens),
20
+ additional_description: tokens.shift
21
+ )
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,29 @@
1
+ module XcodeResultBundleProcessor
2
+ module SLF0
3
+ module Model
4
+ class IDEActivityLogSection < KeywordStruct.new(:section_type, :domain_type, :title, :signature, :time_started_recording, :time_stopped_recording, :subsections, :text, :messages, :cancelled?, :quiet?, :fetched_from_cache?, :subtitle, :location, :command_detail_desc, :uuid, :localized_result_string)
5
+ def self.deserialize(tokens)
6
+ self.new(
7
+ section_type: tokens.shift,
8
+ domain_type: tokens.shift,
9
+ title: tokens.shift,
10
+ signature: tokens.shift,
11
+ time_started_recording: tokens.shift,
12
+ time_stopped_recording: tokens.shift,
13
+ subsections: Deserializer.deserialize_list(tokens),
14
+ text: tokens.shift,
15
+ messages: Deserializer.deserialize_list(tokens),
16
+ cancelled?: tokens.shift == 1,
17
+ quiet?: tokens.shift == 1,
18
+ fetched_from_cache?: tokens.shift == 1,
19
+ subtitle: tokens.shift,
20
+ location: tokens.shift,
21
+ command_detail_desc: tokens.shift,
22
+ uuid: tokens.shift,
23
+ localized_result_string: tokens.shift
24
+ )
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,24 @@
1
+ module XcodeResultBundleProcessor
2
+ module SLF0
3
+ module Model
4
+ class IDEActivityLogUnitTestSection < KeywordStruct.new(*(IDEActivityLogSection.members + [:tests_passed_string, :duration_string, :summary_string,
5
+ :suite_name, :test_name, :performance_test_output_string]))
6
+
7
+ def self.deserialize(tokens)
8
+ parent = IDEActivityLogSection.deserialize(tokens)
9
+ parent_values = IDEActivityLogSection.members.map { |member| parent[member] }
10
+
11
+ self.new(
12
+ *(parent_values +
13
+ [tokens.shift,
14
+ tokens.shift,
15
+ tokens.shift,
16
+ tokens.shift,
17
+ tokens.shift,
18
+ tokens.shift])
19
+ )
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,16 @@
1
+ module XcodeResultBundleProcessor
2
+ module SLF0
3
+ module Model
4
+ class IDEConsoleItem < KeywordStruct.new(:adaptor_type, :content, :kind, :timestamp)
5
+ def self.deserialize(tokens)
6
+ self.new(
7
+ adaptor_type: tokens.shift,
8
+ content: tokens.shift,
9
+ kind: tokens.shift,
10
+ timestamp: tokens.shift,
11
+ )
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,82 @@
1
+ module XcodeResultBundleProcessor
2
+ module SLF0
3
+ module Tokenizer
4
+ Token = Struct.new(:int, :identifier)
5
+ ClassName = Struct.new(:class_name)
6
+ ClassNameRef = Struct.new(:class_name_id)
7
+ ObjectList = Struct.new(:mystery_number)
8
+ TOKEN_INT = '#'
9
+ TOKEN_CLASS_NAME = '%'
10
+ TOKEN_CLASS_NAME_REF = '@'
11
+ TOKEN_STRING = '"'
12
+ TOKEN_DOUBLE = '^'
13
+ OBJECT_LIST_NIL = '-'
14
+ OBJECT_LIST = '('
15
+
16
+ def self.read_token_stream(io)
17
+ raise 'Not an SLF0 file' unless self.valid_slf0_io?(io)
18
+
19
+ Enumerator.new do |enumerator|
20
+ until io.eof?
21
+ token = self.read_length_and_token_type(io)
22
+
23
+ case token.identifier
24
+ when TOKEN_INT
25
+ enumerator.yield(token.int)
26
+ when TOKEN_DOUBLE
27
+ enumerator.yield(token.int)
28
+ when TOKEN_STRING
29
+ enumerator.yield(io.read(token.int).gsub("\r", "\n"))
30
+ when TOKEN_CLASS_NAME
31
+ enumerator.yield(ClassName.new(io.read(token.int)))
32
+ when TOKEN_CLASS_NAME_REF
33
+ enumerator.yield(ClassNameRef.new(token.int))
34
+ when OBJECT_LIST
35
+ enumerator.yield(ObjectList.new(token.int))
36
+ when OBJECT_LIST_NIL
37
+ enumerator.yield(nil)
38
+ else
39
+ enumerator.yield(token)
40
+ end
41
+
42
+ end
43
+ end
44
+ end
45
+
46
+ def self.valid_slf0_io?(io)
47
+ return false if io.nil?
48
+ io.read(4) == 'SLF0'
49
+ end
50
+
51
+ def self.read_length_and_token_type(io)
52
+ # The length appears as the start of the token as 0 or more ASCII decimal or hex digits until the token type marker
53
+ length_string = ''
54
+ until [TOKEN_INT, TOKEN_CLASS_NAME, TOKEN_CLASS_NAME_REF, TOKEN_STRING, TOKEN_DOUBLE, OBJECT_LIST_NIL, OBJECT_LIST].include?((c = io.readchar)) do
55
+ length_string << c
56
+ end
57
+
58
+ token = c
59
+
60
+ # TODO: Doubles are stored as hex strings. If it turns out the doubles contain useful information, implement
61
+ # logic to convert the hex string to doubles
62
+ if token == TOKEN_DOUBLE
63
+ length_string = ''
64
+ end
65
+
66
+ return Token.new(length_string.to_i, c)
67
+ end
68
+
69
+ private
70
+
71
+ def self._read_length_for_token(io, token_identifier)
72
+ # The length is an ASCII decimal string possibly preceded by dashes
73
+ length_string = ''
74
+ while (c = io.readchar) != token_identifier do
75
+ length_string << c unless c == '-' # Some strings lengths are preceded by 1 or more dashes, which we want to skip
76
+ end
77
+
78
+ length_string.to_i
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,47 @@
1
+ module XcodeResultBundleProcessor
2
+ class SnapshotSummary < KeywordStruct.new(:element_class, :children, :identifier, :title, :label, :value, :frame, :placeholder, :focused?, :keyboardFocused?, :selected?, :enabled?)
3
+
4
+ def self.parse(element_snapshot)
5
+ element_class = nil
6
+ if element_snapshot.key?(:additionalAttributes) and element_snapshot[:additionalAttributes].key?(5004.to_s.to_sym)
7
+ element_class = element_snapshot[:additionalAttributes][5004.to_s.to_sym]
8
+ end
9
+
10
+ SnapshotSummary.new(
11
+ element_class: element_class,
12
+ children: Array(element_snapshot[:children]).map { |child| SnapshotSummary.parse(child) },
13
+ identifier: element_snapshot[:identifier],
14
+ title: element_snapshot[:title],
15
+ label: element_snapshot[:label],
16
+ value: element_snapshot[:value],
17
+ frame: element_snapshot[:frame],
18
+ placeholder: element_snapshot[:placeholder],
19
+ focused?: !!element_snapshot[:hasFocus],
20
+ keyboardFocused?: !!element_snapshot[:hasKeyboardFocus],
21
+ selected?: !!element_snapshot[:selected],
22
+ enabled?: element_snapshot[:enabled] != false,
23
+ )
24
+
25
+ end
26
+
27
+ def summary
28
+ summary_components = [
29
+ self.element_class || 'UnknownElement',
30
+ self.frame
31
+ ]
32
+
33
+ summary_components += [:identifier, :title, :label, :value, :placeholder].map do |attribute|
34
+ unless self[attribute].nil? or self[attribute].empty?
35
+ "#{attribute}=#{self[attribute]}"
36
+ end
37
+ end
38
+
39
+ summary_components << 'focused' if self.focused?
40
+ summary_components << 'keyboardFocused' if self.keyboardFocused?
41
+ summary_components << 'selected' if self.selected?
42
+ summary_components << 'disabled' unless self.enabled?
43
+
44
+ summary_components.compact.join(' ')
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,156 @@
1
+ module XcodeResultBundleProcessor
2
+ module TestSummaries
3
+ class HTMLReport
4
+ include Methadone::CLILogging
5
+
6
+ def initialize(results_bundle)
7
+ @results_bundle = results_bundle
8
+ @stylesheet_path = File.join(File.dirname(__FILE__), '..', '..', '..', 'static', 'report.css')
9
+ @js_path = File.join(File.dirname(__FILE__), '..', '..', '..', 'static', 'report.js')
10
+ end
11
+
12
+ def save(destination_dir)
13
+ info "Saving HTML report to #{destination_dir}"
14
+
15
+ raise "Destination directory #{destination_dir} already exists" if Dir.exist?(destination_dir)
16
+
17
+ FileUtils.mkdir_p(File.join(destination_dir, 'screenshots'))
18
+
19
+ info 'Deserializing logs'
20
+ action_logs = LogDeserializer.deserialize_action_logs(@results_bundle)
21
+ debug action_logs
22
+
23
+
24
+ tests = TestSummaries.new(@results_bundle.read_plist('TestSummaries.plist'))
25
+
26
+ debug "Formatting test summaries <#{tests.ai}>"
27
+
28
+ Markaby::Builder.set_html5_options!
29
+ Markaby::Builder.set(:indent, 2)
30
+ mab = Markaby::Builder.new({}, self)
31
+ report = mab.html5 do
32
+ head do
33
+ link rel: 'stylesheet', href: 'report.css'
34
+ script src: 'report.js' do
35
+ ''
36
+ end
37
+ end
38
+
39
+ body do
40
+ unless tests.failed_tests.empty?
41
+ h1 'Failed Tests :('
42
+ ul do
43
+ tests.failed_tests.each do |failed_test|
44
+ li do
45
+ a href: "##{failed_test.identifier}" do
46
+ failed_test.identifier
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+
53
+ h1 'Test Timelines'
54
+ tests.tests.each do |test|
55
+ _format_test(test, mab, destination_dir)
56
+ end
57
+
58
+ hr
59
+
60
+ h1 'Action Logs'
61
+ pre action_logs
62
+
63
+ end
64
+ end
65
+
66
+ FileUtils.copy(@stylesheet_path, File.join(destination_dir, 'report.css'))
67
+ FileUtils.copy(@js_path, File.join(destination_dir, 'report.js'))
68
+ File.open(File.join(destination_dir, 'index.html'), 'w').write(report)
69
+ end
70
+
71
+ def _format_test(test, mab, destination_dir)
72
+ mab.a name: test.identifier do
73
+ if test.passed?
74
+ mab.h2 test.summary
75
+ else
76
+ mab.h2.testFailed test.summary
77
+ end
78
+ end
79
+
80
+ mab.ul do
81
+ test.failure_summaries.each do |failure_summary|
82
+ li do
83
+ em "Failure at #{failure_summary.location}"
84
+ pre failure_summary.message
85
+ end
86
+ end
87
+ ''
88
+ end
89
+
90
+ mab.div.timelineContainer do
91
+ table do
92
+ tr do
93
+ test.activities.each do |activity_summary|
94
+ td do
95
+ h4 activity_summary.title
96
+
97
+ table do
98
+ tr do
99
+ activity_summary.subactivities.each do |subactivity|
100
+ td do
101
+ h5 subactivity.title
102
+
103
+ unless subactivity.screenshot_path.nil?
104
+ basename = File.basename(subactivity.screenshot_path)
105
+
106
+ output_image_path = File.join(destination_dir, 'screenshots', basename)
107
+ @results_bundle.copy_file(File.join('Attachments', subactivity.screenshot_path), output_image_path)
108
+
109
+ img src: File.join('screenshots', basename)
110
+ end
111
+
112
+ unless subactivity.snapshot_path.nil?
113
+ a.viewSnapshot href: '#' do
114
+ 'View Snapshot'
115
+ end
116
+
117
+ pre.snapshot do
118
+ @results_bundle.open_file(File.join('Attachments', subactivity.snapshot_path)) do |file|
119
+ snapshot_plist = CFPropertyList::List.new(data: file.read)
120
+ element_snapshot = ElementSnapshot.new(snapshot_plist)
121
+
122
+ snapshot_summary = SnapshotSummary.parse(element_snapshot.to_h)
123
+
124
+ buffer = IndentedStringBuffer.new
125
+ _format_element_summary(buffer, snapshot_summary)
126
+ end
127
+ end
128
+ end
129
+
130
+ ''
131
+ end
132
+ end
133
+ ''
134
+ end
135
+ end
136
+ end
137
+ end
138
+ ''
139
+ end
140
+ end
141
+ end
142
+ ''
143
+ end
144
+
145
+ def _format_element_summary(buffer, element_summary)
146
+ buffer << element_summary.summary
147
+
148
+ element_summary.children.each do |child|
149
+ _format_element_summary(buffer.indent, child)
150
+ end
151
+
152
+ buffer
153
+ end
154
+ end
155
+ end
156
+ end