Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Export screenshots #5

Draft
wants to merge 24 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 17 additions & 12 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@ PATH
GEM
remote: https://rubygems.org/
specs:
ast (2.4.0)
ast (2.4.2)
diff-lcs (1.3)
jaro_winkler (1.5.3)
parallel (1.17.0)
parser (2.6.4.1)
ast (~> 2.4.0)
parallel (1.20.1)
parser (3.0.2.0)
ast (~> 2.4.1)
rainbow (3.0.0)
rake (13.0.1)
regexp_parser (2.1.1)
rexml (3.2.5)
rspec (3.8.0)
rspec-core (~> 3.8.0)
rspec-expectations (~> 3.8.0)
Expand All @@ -27,15 +28,19 @@ GEM
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.8.0)
rspec-support (3.8.2)
rubocop (0.73.0)
jaro_winkler (~> 1.5.1)
rubocop (1.18.3)
parallel (~> 1.10)
parser (>= 2.6)
parser (>= 3.0.0.0)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml
rubocop-ast (>= 1.7.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 1.7)
ruby-progressbar (1.10.1)
unicode-display_width (1.6.0)
unicode-display_width (>= 1.4.0, < 3.0)
rubocop-ast (1.8.0)
parser (>= 3.0.1.1)
ruby-progressbar (1.11.0)
unicode-display_width (2.0.0)

PLATFORMS
ruby
Expand All @@ -48,4 +53,4 @@ DEPENDENCIES
xcresult!

BUNDLED WITH
2.0.2
2.2.17
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Or install it yourself as:

- [x] Allow for easy querying of test plan summaires
- [x] Allow for easy exporting of `.xccovreport` files
- [ ] Allow for exporting of screenshots
- [x] Allow for exporting of screenshots
- [ ] Add full documentation on all classes and methods
- [ ] Add more and better explain examples
- [ ] Add tests and improved code coverage
Expand All @@ -47,6 +47,13 @@ parser = XCResult::Parser.new(path: 'YourProject.xcresult')
summaries = parser.action_test_plan_summaries
```

### Export screenshots

```rb
parser = XCResult::Parser.new(path: 'YourProject.xcresult')
parser = parser.export_screenshots(XCResult::ExportOptions.new(destination: './screenshots', by_device: true, by_locale: true))
```

## Development

After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
Expand Down
15 changes: 15 additions & 0 deletions bin/console
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

require "bundler/setup"
require "xcresult"

# You can add fixtures and/or initialization code here to make experimenting
# with your gem easier. You can also use a different console, if you like.

# (If you use this, don't forget to add pry to your Gemfile!)
# require "pry"
# Pry.start

require "irb"
IRB.start(__FILE__)
6 changes: 6 additions & 0 deletions bin/generate_models
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#! /usr/bin/env ruby

require_relative '../lib/xcresult/model_generator'

XCResult::ModelGenerator.generate(File.expand_path(File.join(File.dirname(__FILE__), '../lib/xcresult/models.gen.rb')))
puts 'Done! Check out lib/xcresult/models.gen.rb'
8 changes: 8 additions & 0 deletions bin/setup
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
set -vx

bundle install

# Do any other automated setup that you need to do here
52 changes: 52 additions & 0 deletions lib/xcresult/export_options.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
module XCResult
class ExportOptions
AVAILABLE_OPTIONS = %i[destination by_device by_locale by_region by_language by_os_version by_test_name].freeze

# A initializer of ExportOptions class. The order of options that are prefixed with `by_` is important.
# `XCResult::Parser#export_screenshots` will create nested directories by the order you give.
#
# @option [String] destination The base directory path of exported items
# @option [Boolean] by_device If true, a nested directory under destination will be made based on device model name
# @option [Boolean] by_locale If true, a nested directory under destination will be made based on locale; i.e. "en_US"
# @option [Boolean] by_region If true, a nested directory under destination will be made based on region
# @option [Boolean] by_language If true, a nested directory under destination will be made based on language
# @option [Boolean] by_os_version If true, a nested directory under destination will be made based on OS version
# @option [Boolean] by_test_name If true, a nested directory under destination will be made based on test name in TestPlan
def initialize(**options)
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

todo add document comments

raise ':destination option is required' unless options[:destination]

options.each do |key, value|
raise "Found unknown option #{key} - #{value}." unless AVAILABLE_OPTIONS.include?(key)
end

@options = options.dup
@destination = options.delete(:destination)
end

def output_directory(target_device_record:, action_test_plan_summary:, action_testable_summary:)
output_directory = @destination

# keep the order of given options so that you can customize output directory path based on your needs
@options.select { |key, value| key.to_s.start_with?('by_') && value == true }.each do |key, _|
case key
when :by_device
output_directory = File.join(output_directory, target_device_record.model_name)
when :by_language
output_directory = File.join(output_directory, action_testable_summary.test_language)
when :by_region
output_directory = File.join(output_directory, action_testable_summary.test_region)
when :by_locale
locale = [action_testable_summary.test_language, action_testable_summary.test_region].compact.join('_')
locale = 'UNKOWN' if locale.empty?
output_directory = File.join(output_directory, locale)
when :by_os_version
output_directory = File.join(output_directory, target_device_record.operating_system_version)
when :by_test_name
output_directory = File.join(output_directory, action_test_plan_summary.name)
end
end

output_directory
end
end
end
172 changes: 172 additions & 0 deletions lib/xcresult/model_generator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
# frozen_string_literal: true

require 'json'
require 'erb'
require 'tsort'

module XCResult
class ModelGenerator
def self.format_description
`xcrun xcresulttool formatDescription get --format json`
end

def self.generate(destination)
parser = Parser.new(format: format_description)
parser.parse
generator = Generator.new(parser.format)
generator.write(destination)
end

class Generator
def initialize(format)
@format = format
@type_to_kind = format.types.map { |t| [t.name, t.kind] }.to_h
end

def write(destination)
file = File.open(destination, 'w+')
file.puts(<<~"HEADER")
# This is a generated file. Don't modify this directly!
# Last generated at: #{Time.now.utc}
#
# Name: #{@format.name}
# Version: #{@format.version}
# Signature: #{@format.signature}
require 'time'

HEADER

file.puts(<<~OPEN_MODULE)
module XCResult
module Models

OPEN_MODULE

sorted_types.each do |type|
# We use Ruby native classes for values, like Date, String, Int, Double, etc..
next if type.kind == 'value'

type_text = compose_type(type, 2 * 2)
file.puts(type_text)
file.puts('')
end

file.puts(<<~CLOSE_MODULE)
end
end
CLOSE_MODULE
ensure
file.close
end

# Use topological sort to correctly defines classes that may depend on another as its super class
def sorted_types
h = @format.types.map {|x| [x, [@format.types.find {|y| y.name == x.supertype}].compact] }.to_h
each_node = ->(&b) { h.each_key(&b) }
each_child = ->(n, &b) { h[n].each(&b) }
TSort.tsort(each_node, each_child)
end

def compose_type(type, indentation)
type_def = <<~"TYPE"
<% if type.supertype %>
class <%= type.name %> < <%= type.supertype %>
<% else %>
class <%= type.name %>
<% end%>
<% type.properties.each do |property| %>
<%= property.rdoc_comment %>
attr_reader :<%= property.name_in_snake_case %>
<% end %>

def initialize(data)
<% type.properties.each do |property| %>
@<%= property.name_in_snake_case %> = <%= property.mapping(type_to_kind[property.wrapped_type || property.type], 'data') %>
<% end %>
<% if type.supertype %>
super
<% end %>
end
end
TYPE
type_def = ERB.new(type_def, trim_mode: '<>').result_with_hash(type: type, type_to_kind: @type_to_kind)
type_def.each_line.map { |line| "#{' ' * indentation}#{line}" }.join
end
end

class Parser
attr_reader :format

def initialize(format:)
@raw_format = format
@json = JSON.parse(format)
@format = Format.new
end

def parse
@format.name = @json['name']
@format.version = @json['version'].values.join('.')
@format.signature = @json['signature']
@format.types = @json['types'].map do |type|
Type.new(
name: type.dig('type', 'name'),
supertype: type.dig('type', 'supertype'),
kind: type['kind'],
raw_text: JSON.pretty_generate(type),
properties: type.fetch('properties', []).map do |prop|
Property.new(
name: prop['name'],
type: prop['type'],
wrapped_type: prop['wrappedType'],
is_optional: prop['isOptional'],
is_internal: prop['isInternal']
)
end
)
end
end
end

Format = Struct.new(:name, :version, :signature, :types, keyword_init: true)
Type = Struct.new(:name, :supertype, :kind, :properties, :raw_text, keyword_init: true)
Property = Struct.new(:name, :type, :wrapped_type, :is_optional, :is_internal, keyword_init: true) do
def name_in_snake_case
name
.gsub(/(CPU|ID|MHz|UTI|SDK)/) { Regexp.last_match[1].downcase.capitalize } # normalize acronyms
.gsub(/([a-z])(?=[A-Z])/) { (Regexp.last_match[1] || Regexp.last_match[2]) << '_' }
.downcase
end

def mapping(kind, variable_name)
return "(#{variable_name}.dig('#{name}', '_values') || []).map {|d| #{_mapping(kind, 'd')} }" if type == 'Array'
_mapping(kind, variable_name) + (is_optional ? " if #{variable_name}['#{name}']" : '')
end

def _mapping(kind, variable_name)
if kind == 'object'
type_access_key = type == 'Array' ? "'_type', '_name'" : "'#{name}', '_type', '_name'"
value_access_key = type == 'Array' ? variable_name : "#{variable_name}.dig('#{name}')"
"Models.load_class(#{variable_name}.dig(#{type_access_key})).new(#{value_access_key})"
elsif kind == 'value' && [type, wrapped_type].include?('Date')
"Time.parse(#{variable_name}.dig('#{name}', '_value'))"
elsif kind == 'value' && [type, wrapped_type].include?('Int')
"#{variable_name}.dig('#{name}', '_value').to_i"
elsif kind == 'value' && [type, wrapped_type].include?('Double')
"#{variable_name}.dig('#{name}', '_value').to_f"
else
"#{variable_name}.dig('#{name}', '_value')"
end
end

def rdoc_comment
if type == 'Array'
"# @return [Array<#{wrapped_type}>] #{name_in_snake_case}"
elsif is_optional
"# @return [#{wrapped_type}, nil] #{name_in_snake_case}"
else
"# @return [#{type}] #{name_in_snake_case}"
end
end
end
end
end
Loading