lokalise_manager 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.
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+
5
+ module LokaliseManager
6
+ module TaskDefinitions
7
+ class Exporter < Base
8
+ # Performs translation file export to Lokalise and returns an array of queued processes
9
+ #
10
+ # @return [Array]
11
+ def export!
12
+ check_options_errors!
13
+
14
+ queued_processes = []
15
+ each_file do |full_path, relative_path|
16
+ queued_processes << do_upload(full_path, relative_path)
17
+ rescue StandardError => e
18
+ raise e.class, "Error while trying to upload #{full_path}: #{e.message}"
19
+ end
20
+
21
+ $stdout.print 'Task complete!'
22
+
23
+ queued_processes
24
+ end
25
+
26
+ private
27
+
28
+ # Performs the actual file uploading to Lokalise. If the API rate limit is exceeed,
29
+ # applies exponential backoff
30
+ def do_upload(f_path, r_path)
31
+ with_exp_backoff(config.max_retries_export) do
32
+ api_client.upload_file project_id_with_branch, opts(f_path, r_path)
33
+ end
34
+ end
35
+
36
+ # Processes each translation file in the specified directory
37
+ def each_file
38
+ return unless block_given?
39
+
40
+ loc_path = config.locales_path
41
+ Dir["#{loc_path}/**/*"].sort.each do |f|
42
+ full_path = Pathname.new f
43
+
44
+ next unless file_matches_criteria? full_path
45
+
46
+ relative_path = full_path.relative_path_from Pathname.new(loc_path)
47
+
48
+ yield full_path, relative_path
49
+ end
50
+ end
51
+
52
+ # Generates export options
53
+ #
54
+ # @return [Hash]
55
+ # @param full_p [Pathname]
56
+ # @param relative_p [Pathname]
57
+ def opts(full_p, relative_p)
58
+ content = File.read full_p
59
+
60
+ initial_opts = {
61
+ data: Base64.strict_encode64(content.strip),
62
+ filename: relative_p,
63
+ lang_iso: config.lang_iso_inferer.call(content)
64
+ }
65
+
66
+ initial_opts.merge config.export_opts
67
+ end
68
+
69
+ # Checks whether the specified file has to be processed or not
70
+ #
71
+ # @return [Boolean]
72
+ # @param full_path [Pathname]
73
+ def file_matches_criteria?(full_path)
74
+ full_path.file? && proper_ext?(full_path) &&
75
+ !config.skip_file_export.call(full_path)
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zip'
4
+ require 'open-uri'
5
+ require 'fileutils'
6
+
7
+ module LokaliseManager
8
+ module TaskDefinitions
9
+ class Importer < Base
10
+ # Performs translation files import from Lokalise
11
+ #
12
+ # @return [Boolean]
13
+ def import!
14
+ check_options_errors!
15
+
16
+ unless proceed_when_safe_mode?
17
+ $stdout.print 'Task cancelled!'
18
+ return false
19
+ end
20
+
21
+ open_and_process_zip download_files['bundle_url']
22
+
23
+ $stdout.print 'Task complete!'
24
+ true
25
+ end
26
+
27
+ private
28
+
29
+ # Downloads files from Lokalise using the specified config.
30
+ # Utilizes exponential backoff if "too many requests" error is received
31
+ #
32
+ # @return [Hash]
33
+ def download_files
34
+ with_exp_backoff(config.max_retries_import) do
35
+ api_client.download_files project_id_with_branch, config.import_opts
36
+ end
37
+ rescue StandardError => e
38
+ raise e.class, "There was an error when trying to download files: #{e.message}"
39
+ end
40
+
41
+ # Opens ZIP archive (local or remote) with translations and processes its entries
42
+ #
43
+ # @param path [String]
44
+ def open_and_process_zip(path)
45
+ Zip::File.open_buffer(open_file_or_remote(path)) do |zip|
46
+ fetch_zip_entries(zip) { |entry| process!(entry) }
47
+ end
48
+ rescue StandardError => e
49
+ raise e.class, "There was an error when trying to process the downloaded files: #{e.message}"
50
+ end
51
+
52
+ # Iterates over ZIP entries. Each entry may be a file or folder.
53
+ def fetch_zip_entries(zip)
54
+ return unless block_given?
55
+
56
+ zip.each do |entry|
57
+ next unless proper_ext? entry.name
58
+
59
+ yield entry
60
+ end
61
+ end
62
+
63
+ # Processes ZIP entry by reading its contents and creating the corresponding translation file
64
+ def process!(zip_entry)
65
+ data = data_from zip_entry
66
+ subdir, filename = subdir_and_filename_for zip_entry.name
67
+ full_path = "#{config.locales_path}/#{subdir}"
68
+ FileUtils.mkdir_p full_path
69
+
70
+ File.open(File.join(full_path, filename), 'w+:UTF-8') do |f|
71
+ f.write config.translations_converter.call(data)
72
+ end
73
+ rescue StandardError => e
74
+ raise e.class, "Error when trying to process #{zip_entry&.name}: #{e.message}"
75
+ end
76
+
77
+ # Checks whether the user wishes to proceed when safe mode is enabled and the target directory is not empty
78
+ #
79
+ # @return [Boolean]
80
+ def proceed_when_safe_mode?
81
+ return true unless config.import_safe_mode && !Dir.empty?(config.locales_path.to_s)
82
+
83
+ $stdout.puts "The target directory #{config.locales_path} is not empty!"
84
+ $stdout.print 'Enter Y to continue: '
85
+ answer = $stdin.gets
86
+ answer.to_s.strip == 'Y'
87
+ end
88
+
89
+ # Opens a local file or a remote URL using the provided patf
90
+ #
91
+ # @return [String]
92
+ def open_file_or_remote(path)
93
+ parsed_path = URI.parse(path)
94
+ if parsed_path&.scheme&.include?('http')
95
+ parsed_path.open
96
+ else
97
+ File.open path
98
+ end
99
+ end
100
+
101
+ def data_from(zip_entry)
102
+ config.translations_loader.call zip_entry.get_input_stream.read
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LokaliseManager
4
+ VERSION = '1.0.0'
5
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ require 'lokalise_manager/version'
6
+ require 'lokalise_manager/error'
7
+ require 'lokalise_manager/global_config'
8
+ require 'lokalise_manager/task_definitions/base'
9
+ require 'lokalise_manager/task_definitions/importer'
10
+ require 'lokalise_manager/task_definitions/exporter'
11
+
12
+ module LokaliseManager
13
+ class << self
14
+ # Initializes a new importer client which is used to download
15
+ # translation files from Lokalise to the current project
16
+ #
17
+ # @return [LokaliseManager::TaskDefinitions::Importer]
18
+ # @param custom_opts [Hash]
19
+ # @param global_config [Object]
20
+ def importer(custom_opts = {}, global_config = LokaliseManager::GlobalConfig)
21
+ LokaliseManager::TaskDefinitions::Importer.new custom_opts, global_config
22
+ end
23
+
24
+ # Initializes a new exporter client which is used to upload
25
+ # translation files from the current project to Lokalise
26
+ #
27
+ # @return [LokaliseManager::TaskDefinitions::Exporter]
28
+ # @param custom_opts [Hash]
29
+ # @param global_config [Object]
30
+ def exporter(custom_opts = {}, global_config = LokaliseManager::GlobalConfig)
31
+ LokaliseManager::TaskDefinitions::Exporter.new custom_opts, global_config
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require File.expand_path('lib/lokalise_manager/version', __dir__)
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'lokalise_manager'
7
+ spec.version = LokaliseManager::VERSION
8
+ spec.authors = ['Ilya Bodrov']
9
+ spec.email = ['golosizpru@gmail.com']
10
+ spec.summary = 'Lokalise integration for Ruby'
11
+ spec.description = 'This gem contains a collection of some common tasks for Lokalise. Specifically, it allows to import/export translation files from/to Lokalise TMS.'
12
+ spec.homepage = 'https://github.com/bodrovis/lokalise_manager'
13
+ spec.license = 'MIT'
14
+ spec.platform = Gem::Platform::RUBY
15
+ spec.required_ruby_version = '>= 2.5.0'
16
+
17
+ spec.files = Dir['README.md', 'LICENSE',
18
+ 'CHANGELOG.md', 'lib/**/*.rb',
19
+ 'lib/**/*.rake',
20
+ 'lokalise_manager.gemspec', '.github/*.md',
21
+ 'Gemfile', 'Rakefile']
22
+ spec.test_files = Dir['spec/**/*.rb']
23
+ spec.extra_rdoc_files = ['README.md']
24
+ spec.require_paths = ['lib']
25
+
26
+ spec.add_dependency 'ruby-lokalise-api', '~> 4.0'
27
+ spec.add_dependency 'rubyzip', '~> 2.3'
28
+
29
+ spec.add_development_dependency 'codecov', '~> 0.2'
30
+ spec.add_development_dependency 'dotenv', '~> 2.5'
31
+ spec.add_development_dependency 'rake', '~> 13.0'
32
+ spec.add_development_dependency 'rspec', '~> 3.6'
33
+ spec.add_development_dependency 'rubocop', '~> 1.0'
34
+ spec.add_development_dependency 'rubocop-performance', '~> 1.5'
35
+ spec.add_development_dependency 'rubocop-rspec', '~> 2.5.0'
36
+ spec.add_development_dependency 'simplecov', '~> 0.16'
37
+ spec.add_development_dependency 'vcr', '~> 6.0'
38
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ describe LokaliseManager::GlobalConfig do
4
+ let(:fake_class) { class_double(described_class) }
5
+
6
+ it 'is possible to provide config' do
7
+ described_class.config do |c|
8
+ expect(c).to eq(described_class)
9
+ end
10
+ end
11
+
12
+ it 'is possible to set project_id' do
13
+ allow(fake_class).to receive(:project_id=).with('123.abc')
14
+ fake_class.project_id = '123.abc'
15
+ end
16
+
17
+ it 'is possible to set file_ext_regexp' do
18
+ allow(fake_class).to receive(:file_ext_regexp=).with(Regexp.new('.*'))
19
+ fake_class.file_ext_regexp = Regexp.new('.*')
20
+ end
21
+
22
+ it 'is possible to set import_opts' do
23
+ allow(fake_class).to receive(:import_opts=).with(duck_type(:each))
24
+ fake_class.import_opts = {
25
+ format: 'json',
26
+ indentation: '8sp'
27
+ }
28
+ end
29
+
30
+ it 'is possible to set export_opts' do
31
+ allow(fake_class).to receive(:export_opts=).with(duck_type(:each))
32
+ fake_class.export_opts = {
33
+ convert_placeholders: true,
34
+ detect_icu_plurals: true
35
+ }
36
+ end
37
+
38
+ it 'is possible to set branch' do
39
+ allow(fake_class).to receive(:branch=).with('custom')
40
+ fake_class.branch = 'custom'
41
+ end
42
+
43
+ it 'is possible to set timeouts' do
44
+ allow(fake_class).to receive(:timeouts=).with(duck_type(:each))
45
+ fake_class.timeouts = {
46
+ open_timeout: 100,
47
+ timeout: 500
48
+ }
49
+ end
50
+
51
+ it 'is possible to set import_safe_mode' do
52
+ allow(fake_class).to receive(:import_safe_mode=).with(true)
53
+ fake_class.import_safe_mode = true
54
+ end
55
+
56
+ it 'is possible to set max_retries_export' do
57
+ allow(fake_class).to receive(:max_retries_export=).with(10)
58
+ fake_class.max_retries_export = 10
59
+ end
60
+
61
+ it 'is possible to set max_retries_import' do
62
+ allow(fake_class).to receive(:max_retries_import=).with(10)
63
+ fake_class.max_retries_import = 10
64
+ end
65
+
66
+ it 'is possible to set api_token' do
67
+ allow(fake_class).to receive(:api_token=).with('abc')
68
+ fake_class.api_token = 'abc'
69
+ end
70
+
71
+ it 'is possible to override locales_path' do
72
+ allow(fake_class).to receive(:locales_path=).with('/demo/path')
73
+ fake_class.locales_path = '/demo/path'
74
+ end
75
+
76
+ it 'is possible to set skip_file_export' do
77
+ cond = ->(f) { f.nil? }
78
+ allow(fake_class).to receive(:skip_file_export=).with(cond)
79
+ fake_class.skip_file_export = cond
80
+ end
81
+
82
+ it 'is possible to set translations_loader' do
83
+ runner = ->(f) { f.to_json }
84
+ allow(fake_class).to receive(:translations_loader=).with(runner)
85
+ fake_class.translations_loader = runner
86
+ end
87
+
88
+ it 'is possible to set translations_converter' do
89
+ runner = ->(f) { f.to_json }
90
+ allow(fake_class).to receive(:translations_converter=).with(runner)
91
+ fake_class.translations_converter = runner
92
+ end
93
+
94
+ it 'is possible to set lang_iso_inferer' do
95
+ runner = ->(f) { f.to_json }
96
+ allow(fake_class).to receive(:lang_iso_inferer=).with(runner)
97
+ fake_class.lang_iso_inferer = runner
98
+ end
99
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ describe LokaliseManager::TaskDefinitions::Base do
4
+ let(:described_object) { described_class.new }
5
+
6
+ describe '.new' do
7
+ it 'allows to override config' do
8
+ obj = described_class.new token: 'fake'
9
+ expect(obj.config.token).to eq('fake')
10
+ end
11
+ end
12
+
13
+ describe '#config' do
14
+ it 'allows to update config after initialization' do
15
+ obj = described_class.new token: 'fake', project_id: '123'
16
+
17
+ obj.config.project_id = '345'
18
+
19
+ expect(obj.config.project_id).to eq('345')
20
+ expect(obj.config.token).to eq('fake')
21
+ end
22
+ end
23
+
24
+ specify '.reset_client!' do
25
+ expect(described_object.api_client).to be_an_instance_of(Lokalise::Client)
26
+ described_object.reset_api_client!
27
+ current_client = described_object.instance_variable_get '@api_client'
28
+ expect(current_client).to be_nil
29
+ end
30
+
31
+ specify '.project_id_with_branch!' do
32
+ result = described_object.send :project_id_with_branch
33
+ expect(result).to be_an_instance_of(String)
34
+ expect(result).not_to include(':master')
35
+
36
+ described_object.config.branch = 'develop'
37
+ result = described_object.send :project_id_with_branch
38
+ expect(result).to be_an_instance_of(String)
39
+ expect(result).to include(':develop')
40
+ end
41
+
42
+ describe '.subdir_and_filename_for' do
43
+ it 'works properly for longer paths' do
44
+ path = 'my_path/is/here/file.yml'
45
+ result = described_object.send(:subdir_and_filename_for, path)
46
+ expect(result.length).to eq(2)
47
+ expect(result[0]).to be_an_instance_of(Pathname)
48
+ expect(result[0].to_s).to eq('my_path/is/here')
49
+ expect(result[1].to_s).to eq('file.yml')
50
+ end
51
+
52
+ it 'works properly for shorter paths' do
53
+ path = 'file.yml'
54
+ result = described_object.send(:subdir_and_filename_for, path)
55
+ expect(result.length).to eq(2)
56
+ expect(result[1]).to be_an_instance_of(Pathname)
57
+ expect(result[0].to_s).to eq('.')
58
+ expect(result[1].to_s).to eq('file.yml')
59
+ end
60
+ end
61
+
62
+ describe '.check_options_errors!' do
63
+ it 'raises an error when the API key is not set' do
64
+ allow(LokaliseManager::GlobalConfig).to receive(:api_token).and_return(nil)
65
+
66
+ expect(-> { described_object.send(:check_options_errors!) }).to raise_error(LokaliseManager::Error, /API token is not set/i)
67
+
68
+ expect(LokaliseManager::GlobalConfig).to have_received(:api_token)
69
+ end
70
+
71
+ it 'returns an error when the project_id is not set' do
72
+ allow_project_id described_object, nil do
73
+ expect(-> { described_object.send(:check_options_errors!) }).to raise_error(LokaliseManager::Error, /ID is not set/i)
74
+ end
75
+ end
76
+ end
77
+
78
+ describe '.proper_ext?' do
79
+ it 'works properly with path represented as a string' do
80
+ path = 'my_path/here/file.yml'
81
+ expect(described_object.send(:proper_ext?, path)).to be true
82
+ end
83
+
84
+ it 'works properly with path represented as a pathname' do
85
+ path = Pathname.new 'my_path/here/file.json'
86
+ expect(described_object.send(:proper_ext?, path)).to be false
87
+ end
88
+ end
89
+
90
+ describe '.api_client' do
91
+ it 'is possible to set timeouts' do
92
+ allow(described_object.config).to receive(:timeouts).and_return({
93
+ open_timeout: 100,
94
+ timeout: 500
95
+ })
96
+
97
+ expect(described_object.api_client).to be_an_instance_of(Lokalise::Client)
98
+ expect(described_object.api_client.open_timeout).to eq(100)
99
+ expect(described_object.api_client.timeout).to eq(500)
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+
5
+ describe LokaliseManager::TaskDefinitions::Exporter do
6
+ let(:filename) { 'en.yml' }
7
+ let(:path) { "#{Dir.getwd}/locales/nested/#{filename}" }
8
+ let(:relative_name) { "nested/#{filename}" }
9
+ let(:project_id) { ENV['LOKALISE_PROJECT_ID'] }
10
+ let(:described_object) do
11
+ described_class.new project_id: project_id,
12
+ api_token: ENV['LOKALISE_API_TOKEN'],
13
+ max_retries_export: 2
14
+ end
15
+
16
+ context 'with many translation files' do
17
+ before :all do
18
+ add_translation_files! with_ru: true, additional: 5
19
+ end
20
+
21
+ after :all do
22
+ rm_translation_files
23
+ end
24
+
25
+ describe '.export!' do
26
+ it 'sends a proper API request and handles rate limiting' do
27
+ process = VCR.use_cassette('upload_files_multiple') do
28
+ described_object.export!
29
+ end.first
30
+
31
+ expect(process.project_id).to eq(project_id)
32
+ expect(process.status).to eq('queued')
33
+ end
34
+
35
+ it 'handles too many requests' do
36
+ allow(described_object).to receive(:sleep).and_return(0)
37
+
38
+ fake_client = instance_double('Lokalise::Client')
39
+ allow(fake_client).to receive(:upload_file).with(any_args).and_raise(Lokalise::Error::TooManyRequests)
40
+ allow(described_object).to receive(:api_client).and_return(fake_client)
41
+
42
+ expect(-> { described_object.export! }).to raise_error(Lokalise::Error::TooManyRequests, /Gave up after 2 retries/i)
43
+
44
+ expect(described_object).to have_received(:sleep).exactly(2).times
45
+ expect(described_object).to have_received(:api_client).exactly(3).times
46
+ expect(fake_client).to have_received(:upload_file).exactly(3).times
47
+ end
48
+ end
49
+ end
50
+
51
+ context 'with one translation file' do
52
+ before :all do
53
+ add_translation_files!
54
+ end
55
+
56
+ after :all do
57
+ rm_translation_files
58
+ end
59
+
60
+ describe '.export!' do
61
+ it 'sends a proper API request' do
62
+ process = VCR.use_cassette('upload_files') do
63
+ described_object.export!
64
+ end.first
65
+
66
+ expect(process.project_id).to eq(project_id)
67
+ expect(process.status).to eq('queued')
68
+ end
69
+
70
+ it 'sends a proper API request when a different branch is provided' do
71
+ allow(described_object.config).to receive(:branch).and_return('develop')
72
+
73
+ process = VCR.use_cassette('upload_files_branch') do
74
+ described_object.export!
75
+ end.first
76
+
77
+ expect(described_object.config).to have_received(:branch).at_most(2).times
78
+ expect(process.project_id).to eq(project_id)
79
+ expect(process.status).to eq('queued')
80
+ end
81
+
82
+ it 'halts when the API key is not set' do
83
+ allow(described_object.config).to receive(:api_token).and_return(nil)
84
+
85
+ expect(-> { described_object.export! }).to raise_error(LokaliseManager::Error, /API token is not set/i)
86
+ expect(described_object.config).to have_received(:api_token)
87
+ end
88
+
89
+ it 'halts when the project_id is not set' do
90
+ allow_project_id described_object, nil do
91
+ expect(-> { described_object.export! }).to raise_error(LokaliseManager::Error, /ID is not set/i)
92
+ end
93
+ end
94
+ end
95
+
96
+ describe '.each_file' do
97
+ it 'yield proper arguments' do
98
+ expect { |b| described_object.send(:each_file, &b) }.to yield_with_args(
99
+ Pathname.new(path),
100
+ Pathname.new(relative_name)
101
+ )
102
+ end
103
+ end
104
+
105
+ describe '.opts' do
106
+ let(:base64content) { Base64.strict_encode64(File.read(path).strip) }
107
+
108
+ it 'generates proper options' do
109
+ resulting_opts = described_object.send(:opts, path, relative_name)
110
+
111
+ expect(resulting_opts[:data]).to eq(base64content)
112
+ expect(resulting_opts[:filename]).to eq(relative_name)
113
+ expect(resulting_opts[:lang_iso]).to eq('en')
114
+ end
115
+
116
+ it 'allows to redefine options' do
117
+ allow(described_object.config).to receive(:export_opts).and_return({
118
+ detect_icu_plurals: true,
119
+ convert_placeholders: true
120
+ })
121
+
122
+ resulting_opts = described_object.send(:opts, path, relative_name)
123
+
124
+ expect(described_object.config).to have_received(:export_opts)
125
+ expect(resulting_opts[:data]).to eq(base64content)
126
+ expect(resulting_opts[:filename]).to eq(relative_name)
127
+ expect(resulting_opts[:lang_iso]).to eq('en')
128
+ expect(resulting_opts[:detect_icu_plurals]).to be true
129
+ expect(resulting_opts[:convert_placeholders]).to be true
130
+ end
131
+ end
132
+ end
133
+
134
+ context 'with two translation files' do
135
+ let(:filename_ru) { 'ru.yml' }
136
+ let(:path_ru) { "#{Dir.getwd}/locales/#{filename_ru}" }
137
+
138
+ before :all do
139
+ add_translation_files! with_ru: true
140
+ end
141
+
142
+ after :all do
143
+ rm_translation_files
144
+ end
145
+
146
+ describe '.export!' do
147
+ it 're-raises export errors' do
148
+ allow_project_id described_object, '542886116159f798720dc4.94769464'
149
+
150
+ VCR.use_cassette('upload_files_error') do
151
+ expect { described_object.export! }.to raise_error(Lokalise::Error::BadRequest, /Unknown `lang_iso`/)
152
+ end
153
+ end
154
+ end
155
+
156
+ describe '.opts' do
157
+ let(:base64content_ru) { Base64.strict_encode64(File.read(path_ru).strip) }
158
+
159
+ it 'generates proper options' do
160
+ resulting_opts = described_object.send(:opts, path_ru, filename_ru)
161
+
162
+ expect(resulting_opts[:data]).to eq(base64content_ru)
163
+ expect(resulting_opts[:filename]).to eq(filename_ru)
164
+ expect(resulting_opts[:lang_iso]).to eq('ru_RU')
165
+ end
166
+ end
167
+
168
+ describe '.each_file' do
169
+ it 'yields every translation file' do
170
+ expect { |b| described_object.send(:each_file, &b) }.to yield_successive_args(
171
+ [
172
+ Pathname.new(path),
173
+ Pathname.new(relative_name)
174
+ ],
175
+ [
176
+ Pathname.new(path_ru),
177
+ Pathname.new(filename_ru)
178
+ ]
179
+ )
180
+ end
181
+
182
+ it 'does not yield files that have to be skipped' do
183
+ allow(described_object.config).to receive(:skip_file_export).twice.and_return(
184
+ ->(f) { f.split[1].to_s.include?('ru') }
185
+ )
186
+ expect { |b| described_object.send(:each_file, &b) }.to yield_successive_args(
187
+ [
188
+ Pathname.new(path),
189
+ Pathname.new(relative_name)
190
+ ]
191
+ )
192
+
193
+ expect(described_object.config).to have_received(:skip_file_export).twice
194
+ end
195
+ end
196
+ end
197
+ end