xcode-result-bundle-processor 1.0.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 (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