appship 0.1.0 → 0.2.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 +4 -4
- data/README.md +164 -18
- data/lib/appship/builder.rb +48 -39
- data/lib/appship/cache.rb +55 -19
- data/lib/appship/cli.rb +143 -50
- data/lib/appship/config.rb +18 -2
- data/lib/appship/default_message +9 -0
- data/lib/appship/message.rb +193 -0
- data/lib/appship/project_config.rb +115 -0
- data/lib/appship/uploader.rb +26 -10
- data/lib/appship/version.rb +1 -1
- data/lib/appship/webhook.rb +151 -0
- data/lib/appship.rb +3 -0
- data/test/test_cache.rb +263 -0
- data/test/test_cli.rb +356 -0
- data/test/test_display.rb +120 -0
- data/test/test_message.rb +138 -0
- data/test/test_project_config.rb +221 -0
- data/test/test_upload_progress.rb +110 -0
- data/test/test_webhook.rb +352 -0
- metadata +13 -4
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "minitest/autorun"
|
|
4
|
+
require "stringio"
|
|
5
|
+
require_relative "../lib/appship"
|
|
6
|
+
|
|
7
|
+
class ProjectConfigTest < Minitest::Test
|
|
8
|
+
def setup
|
|
9
|
+
@directory = Dir.mktmpdir("appship-project-config-")
|
|
10
|
+
@original_directory = Dir.pwd
|
|
11
|
+
@original_env_file = ENV["APPSHIP_ENV_FILE"]
|
|
12
|
+
@original_stdin = $stdin
|
|
13
|
+
@project_dir = File.join(@directory, "project")
|
|
14
|
+
FileUtils.mkdir_p(File.join(@project_dir, "Demo.xcodeproj"))
|
|
15
|
+
Dir.chdir(@project_dir)
|
|
16
|
+
@local_path = Appship::ProjectConfig.path
|
|
17
|
+
@global_path = File.join(@directory, "global.config")
|
|
18
|
+
ENV["APPSHIP_ENV_FILE"] = @global_path
|
|
19
|
+
$stdin = StringIO.new
|
|
20
|
+
@profile = {
|
|
21
|
+
"project_name" => "Demo", "app_name" => "GlobalApp",
|
|
22
|
+
"pgyer_api_key" => "pgyer-key", "pgyer_password" => "global-password",
|
|
23
|
+
"fir_api_key" => "fir-key", "fir_password" => "fir-password",
|
|
24
|
+
"wechat_webhook" => "https://example.test/wechat", "configuration" => "Release"
|
|
25
|
+
}
|
|
26
|
+
@global_content = JSON.pretty_generate([@profile, { "project_name" => "Other", "custom" => "keep" }])
|
|
27
|
+
File.write(@global_path, @global_content)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def teardown
|
|
31
|
+
Dir.chdir(@original_directory)
|
|
32
|
+
ENV["APPSHIP_ENV_FILE"] = @original_env_file
|
|
33
|
+
$stdin = @original_stdin
|
|
34
|
+
FileUtils.remove_entry(@directory)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def write_local(data)
|
|
38
|
+
FileUtils.mkdir_p(File.dirname(@local_path))
|
|
39
|
+
File.write(@local_path, YAML.dump(data))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def resolve(**options)
|
|
43
|
+
Appship::ProjectConfig.load(@global_path).resolve(interactive: false, **options)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def test_partial_project_config_inherits_only_the_matching_global_profile
|
|
47
|
+
local = { "app_name" => "LocalApp", "configuration" => "Debug" }
|
|
48
|
+
write_local(local)
|
|
49
|
+
|
|
50
|
+
%w[pgyer fir].each do |provider|
|
|
51
|
+
profile = resolve(provider: provider)
|
|
52
|
+
assert_equal @profile.merge(local), profile
|
|
53
|
+
refute profile.key?("custom")
|
|
54
|
+
end
|
|
55
|
+
assert_equal local, YAML.safe_load_file(@local_path)
|
|
56
|
+
assert_equal @global_content, File.read(@global_path)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def test_explicit_empty_values_override_global_passwords_and_webhooks
|
|
60
|
+
local = {
|
|
61
|
+
"pgyer_api_key" => "local-pgyer-key", "pgyer_password" => "",
|
|
62
|
+
"fir_api_key" => "local-fir-key", "fir_password" => "",
|
|
63
|
+
"wechat_webhook" => "", "dingtalk_webhook" => "https://example.test/dingtalk"
|
|
64
|
+
}
|
|
65
|
+
write_local(local)
|
|
66
|
+
profile = resolve
|
|
67
|
+
options = {}
|
|
68
|
+
capture_io { Appship::CLI.new.send(:apply_cached_profile!, options, profile) }
|
|
69
|
+
|
|
70
|
+
assert_equal "local-pgyer-key", options[:api_key]
|
|
71
|
+
assert_equal "", options[:password]
|
|
72
|
+
assert_equal 1, options[:install_type]
|
|
73
|
+
assert_equal "", options[:wechat_webhook]
|
|
74
|
+
assert_equal "https://example.test/dingtalk", options[:dingtalk_webhook]
|
|
75
|
+
assert_equal local, resolve(provider: "fir").slice(*local.keys)
|
|
76
|
+
assert_equal @global_content, File.read(@global_path)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def test_complete_project_config_does_not_create_global_cache
|
|
80
|
+
write_local(@profile)
|
|
81
|
+
missing = File.join(@directory, "missing", ".config")
|
|
82
|
+
|
|
83
|
+
profile = Appship::ProjectConfig.load(missing).resolve(interactive: false)
|
|
84
|
+
|
|
85
|
+
assert_equal @profile, profile
|
|
86
|
+
refute File.exist?(File.dirname(missing))
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def test_other_projects_credentials_are_never_used_as_fallback
|
|
90
|
+
File.write(@global_path, JSON.generate([@profile.merge("project_name" => "Other")]))
|
|
91
|
+
write_local("app_name" => "LocalApp")
|
|
92
|
+
|
|
93
|
+
error = assert_raises(Appship::ConfigurationError) { resolve }
|
|
94
|
+
|
|
95
|
+
assert_includes error.message, "缺少 pgyer_api_key"
|
|
96
|
+
refute_includes File.read(@local_path), "pgyer-key"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def test_missing_values_are_prompted_into_local_config_and_can_resume
|
|
100
|
+
write_local("project_name" => "Demo")
|
|
101
|
+
missing = File.join(@directory, "missing.config")
|
|
102
|
+
input = StringIO.new("LocalApp\n")
|
|
103
|
+
input.define_singleton_method(:tty?) { true }
|
|
104
|
+
$stdin = input
|
|
105
|
+
|
|
106
|
+
capture_io do
|
|
107
|
+
assert_raises(Appship::ConfigurationError) do
|
|
108
|
+
Appship::ProjectConfig.load(missing).resolve
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
assert_equal "LocalApp", YAML.safe_load_file(@local_path)["app_name"]
|
|
112
|
+
$stdin = StringIO.new("new-key\n\n")
|
|
113
|
+
$stdin.define_singleton_method(:tty?) { true }
|
|
114
|
+
capture_io { Appship::ProjectConfig.load(missing).resolve }
|
|
115
|
+
|
|
116
|
+
assert_equal({ "project_name" => "Demo", "app_name" => "LocalApp", "pgyer_api_key" => "new-key", "pgyer_password" => "" }, YAML.safe_load_file(@local_path))
|
|
117
|
+
refute File.exist?(missing)
|
|
118
|
+
assert_equal @global_content, File.read(@global_path)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def test_credential_overrides_only_persist_locally_and_keep_other_platforms
|
|
122
|
+
write_local("project_name" => "Demo", "custom" => "keep")
|
|
123
|
+
|
|
124
|
+
profile = resolve(credential_overrides: { "pgyer_api_key" => "local-key", "pgyer_password" => "" })
|
|
125
|
+
|
|
126
|
+
assert_equal "local-key", profile["pgyer_api_key"]
|
|
127
|
+
assert_equal "fir-key", profile["fir_api_key"]
|
|
128
|
+
assert_equal({ "project_name" => "Demo", "custom" => "keep", "pgyer_api_key" => "local-key", "pgyer_password" => "" }, YAML.safe_load_file(@local_path))
|
|
129
|
+
assert_equal @global_content, File.read(@global_path)
|
|
130
|
+
assert_equal 0o600, File.stat(@local_path).mode & 0o777
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def test_setting_a_value_equal_to_global_still_saves_an_explicit_local_override
|
|
134
|
+
write_local("project_name" => "Demo")
|
|
135
|
+
cache = Appship::ProjectConfig.load(@global_path)
|
|
136
|
+
profile = cache.resolve(interactive: false)
|
|
137
|
+
cache.update_profile!(profile, "pgyer_password" => @profile["pgyer_password"])
|
|
138
|
+
|
|
139
|
+
assert_equal @profile["pgyer_password"], YAML.safe_load_file(@local_path)["pgyer_password"]
|
|
140
|
+
assert_equal @global_content, File.read(@global_path)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def test_webhook_command_writes_project_config_without_copying_global_secrets
|
|
144
|
+
write_local("project_name" => "Demo")
|
|
145
|
+
capture_io { Appship::CLI.new.start(["webhook", "wechat", ""]) }
|
|
146
|
+
|
|
147
|
+
assert_equal({ "project_name" => "Demo", "wechat_webhook" => "" }, YAML.safe_load_file(@local_path))
|
|
148
|
+
assert_equal @global_content, File.read(@global_path)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def test_without_local_config_the_existing_global_cache_is_used
|
|
152
|
+
cache = Appship::ProjectConfig.load(@global_path)
|
|
153
|
+
assert_instance_of Appship::Cache, cache
|
|
154
|
+
profile = cache.resolve(interactive: false)
|
|
155
|
+
cache.update_profile!(profile, "pgyer_password" => "")
|
|
156
|
+
|
|
157
|
+
assert_equal "", JSON.parse(File.read(@global_path)).first["pgyer_password"]
|
|
158
|
+
refute File.exist?(@local_path)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def test_generic_commands_also_receive_merged_build_and_upload_defaults
|
|
162
|
+
write_local("app_name" => "LocalApp", "pgyer_password" => "", "wechat_webhook" => "")
|
|
163
|
+
config = Appship::Config.find
|
|
164
|
+
cli = Appship::CLI.new
|
|
165
|
+
build = cli.send(:build_options, config)
|
|
166
|
+
upload = cli.send(:upload_options, config)
|
|
167
|
+
|
|
168
|
+
assert_equal @local_path, config.path
|
|
169
|
+
assert_equal "Demo", build[:scheme]
|
|
170
|
+
assert_equal "Demo.xcodeproj", build[:project]
|
|
171
|
+
assert_equal "Release", build[:configuration]
|
|
172
|
+
assert_equal "pgyer-key", upload[:api_key]
|
|
173
|
+
assert_equal "LocalApp", upload[:app_name]
|
|
174
|
+
assert_equal "", upload[:password]
|
|
175
|
+
assert_equal 1, upload[:install_type]
|
|
176
|
+
assert_equal "", upload[:wechat_webhook]
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def test_invalid_project_config_does_not_silently_use_global_config
|
|
180
|
+
write_local([])
|
|
181
|
+
assert_raises(Appship::ConfigurationError) { resolve }
|
|
182
|
+
File.write(@local_path, "{broken")
|
|
183
|
+
assert_raises(Appship::ConfigurationError) { resolve }
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def test_duplicate_global_project_names_are_rejected
|
|
187
|
+
write_local("app_name" => "LocalApp")
|
|
188
|
+
File.write(@global_path, JSON.generate([@profile, @profile]))
|
|
189
|
+
|
|
190
|
+
error = assert_raises(Appship::ConfigurationError) { resolve }
|
|
191
|
+
|
|
192
|
+
assert_includes error.message, "project_name 重复"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def test_symlinked_project_config_is_rejected_without_changing_target
|
|
196
|
+
FileUtils.mkdir_p(File.dirname(@local_path))
|
|
197
|
+
File.symlink(@global_path, @local_path)
|
|
198
|
+
|
|
199
|
+
assert_raises(Appship::ConfigurationError) { Appship::ProjectConfig.load(@global_path) }
|
|
200
|
+
assert_equal @global_content, File.read(@global_path)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def test_samples_are_not_loaded_as_active_configuration_or_message
|
|
204
|
+
Appship::ProjectConfig.init!
|
|
205
|
+
File.write("#{@local_path}.sample", JSON.generate("app_name" => "MustNotUse"))
|
|
206
|
+
File.write("#{Appship::Message.path}.sample", "MustNotUse %{name}\n")
|
|
207
|
+
|
|
208
|
+
assert_equal "GlobalApp", resolve["app_name"]
|
|
209
|
+
refute_includes Appship::Message.template, "MustNotUse"
|
|
210
|
+
refute File.exist?(@local_path)
|
|
211
|
+
refute File.exist?(Appship::Message.path)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def test_init_rejects_symlinked_sample_without_changing_target
|
|
215
|
+
FileUtils.mkdir_p(File.dirname(@local_path))
|
|
216
|
+
File.symlink(@global_path, "#{@local_path}.sample")
|
|
217
|
+
|
|
218
|
+
assert_raises(Appship::ConfigurationError) { Appship::ProjectConfig.init! }
|
|
219
|
+
assert_equal @global_content, File.read(@global_path)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "minitest/autorun"
|
|
4
|
+
require "stringio"
|
|
5
|
+
require_relative "../lib/appship"
|
|
6
|
+
|
|
7
|
+
class UploadProgressTest < Minitest::Test
|
|
8
|
+
class Terminal < StringIO
|
|
9
|
+
def tty?
|
|
10
|
+
true
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class Connection
|
|
15
|
+
attr_reader :body
|
|
16
|
+
|
|
17
|
+
def initialize(result)
|
|
18
|
+
@result = result
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def request(request)
|
|
22
|
+
@body = request.body_stream
|
|
23
|
+
if @result.is_a?(Exception)
|
|
24
|
+
@body.read(1024)
|
|
25
|
+
raise @result
|
|
26
|
+
end
|
|
27
|
+
while @body.read(16 * 1024); end
|
|
28
|
+
@result
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def test_progress_does_not_report_success_until_server_acknowledges
|
|
33
|
+
[StringIO.new, Terminal.new].each do |output|
|
|
34
|
+
progress = Appship::UploadProgress.new(100, output: output)
|
|
35
|
+
progress.start
|
|
36
|
+
progress.update(100)
|
|
37
|
+
refute_match(/100%|100 %|上传完成/, output.string)
|
|
38
|
+
|
|
39
|
+
progress.finish(success: true)
|
|
40
|
+
assert_includes output.string, "上传完成"
|
|
41
|
+
assert_match(/100%/, output.string)
|
|
42
|
+
completed = output.string.dup
|
|
43
|
+
progress.finish
|
|
44
|
+
assert_equal completed, output.string
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def test_failed_upload_keeps_actual_byte_count_without_fake_completion
|
|
49
|
+
output = Terminal.new
|
|
50
|
+
progress = Appship::UploadProgress.new(100, output: output)
|
|
51
|
+
progress.start
|
|
52
|
+
progress.update(25)
|
|
53
|
+
progress.finish(success: false)
|
|
54
|
+
|
|
55
|
+
assert_includes output.string, "上传失败"
|
|
56
|
+
assert_includes output.string, "25B/100B"
|
|
57
|
+
refute_match(/100%|上传完成/, output.string)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def test_both_platforms_complete_progress_only_on_http_success_and_close_streams
|
|
61
|
+
Dir.mktmpdir do |directory|
|
|
62
|
+
file = File.join(directory, "Demo.ipa")
|
|
63
|
+
File.write(file, "x" * 65_536)
|
|
64
|
+
[Appship::PgyerUploader, Appship::FirUploader].each do |base|
|
|
65
|
+
[200, 403, SocketError.new("network failed")].each do |status|
|
|
66
|
+
result = if status.is_a?(Exception)
|
|
67
|
+
status
|
|
68
|
+
else
|
|
69
|
+
response = Net::HTTPResponse::CODE_TO_OBJ.fetch(status.to_s).new("1.1", status.to_s, "")
|
|
70
|
+
response.body = "response"
|
|
71
|
+
response.instance_variable_set(:@read, true)
|
|
72
|
+
response
|
|
73
|
+
end
|
|
74
|
+
connection = Connection.new(result)
|
|
75
|
+
klass = Class.new(base) do
|
|
76
|
+
attr_accessor :connection
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def http(_uri)
|
|
81
|
+
connection
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
uploader = klass.new(api_key: "key", fir_api_token: "token")
|
|
85
|
+
uploader.connection = connection
|
|
86
|
+
call = if base == Appship::PgyerUploader
|
|
87
|
+
-> { uploader.send(:upload_to_cos!, file, { endpoint: "https://example.test/upload", key: "key", signature: "signature", security_token: "token" }) }
|
|
88
|
+
else
|
|
89
|
+
-> { uploader.send(:multipart_upload!, "https://example.test/upload", { "key" => "key" }, file) }
|
|
90
|
+
end
|
|
91
|
+
output, = capture_io do
|
|
92
|
+
if status == 200
|
|
93
|
+
assert call.call
|
|
94
|
+
else
|
|
95
|
+
assert_raises(status.is_a?(Exception) ? SocketError : Appship::UploadError) { call.call }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
assert connection.body.instance_variable_get(:@file).closed?
|
|
100
|
+
if status == 200
|
|
101
|
+
assert_includes output, "上传完成"
|
|
102
|
+
else
|
|
103
|
+
assert_includes output, "上传失败"
|
|
104
|
+
refute_match(/100%|上传完成/, output)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "minitest/autorun"
|
|
4
|
+
require_relative "../lib/appship"
|
|
5
|
+
|
|
6
|
+
class WebhookTest < Minitest::Test
|
|
7
|
+
class FakeHTTP
|
|
8
|
+
attr_accessor :use_ssl, :open_timeout, :read_timeout, :write_timeout, :max_retries
|
|
9
|
+
attr_reader :requests
|
|
10
|
+
|
|
11
|
+
def initialize(*responses)
|
|
12
|
+
@responses = responses
|
|
13
|
+
@requests = []
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def request(request)
|
|
17
|
+
@requests << request
|
|
18
|
+
response = @responses.shift
|
|
19
|
+
raise response if response.is_a?(Exception)
|
|
20
|
+
raise "Unexpected request" unless response
|
|
21
|
+
|
|
22
|
+
response
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
FakeUploader = Struct.new(:result) do
|
|
27
|
+
def upload!(_file)
|
|
28
|
+
raise result if result.is_a?(Exception)
|
|
29
|
+
|
|
30
|
+
result
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def setup
|
|
35
|
+
@webhook_home = Dir.mktmpdir("appship-webhook-")
|
|
36
|
+
@original_home = ENV["HOME"]
|
|
37
|
+
@original_directory = Dir.pwd
|
|
38
|
+
ENV["HOME"] = @webhook_home
|
|
39
|
+
Dir.chdir(@webhook_home)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def teardown
|
|
43
|
+
Dir.chdir(@original_directory)
|
|
44
|
+
ENV["HOME"] = @original_home
|
|
45
|
+
FileUtils.remove_entry(@webhook_home)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def response(body = '{"errcode":0,"errmsg":"ok"}', status: "200")
|
|
49
|
+
result = Net::HTTPResponse::CODE_TO_OBJ.fetch(status).new("1.1", status, "")
|
|
50
|
+
result.body = body
|
|
51
|
+
result.instance_variable_set(:@read, true)
|
|
52
|
+
result
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def with_constructor(klass, object)
|
|
56
|
+
original = klass.method(:new)
|
|
57
|
+
klass.define_singleton_method(:new) { |*| object }
|
|
58
|
+
yield
|
|
59
|
+
ensure
|
|
60
|
+
klass.define_singleton_method(:new, original)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def test_posts_release_message_as_utf8_text_to_both_robots
|
|
64
|
+
http = FakeHTTP.new(response, response)
|
|
65
|
+
message = "应用名称: Demo\n更新描述: 完成 100%\n"
|
|
66
|
+
options = {
|
|
67
|
+
wechat_webhook: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test-key",
|
|
68
|
+
dingtalk_webhook: "https://oapi.dingtalk.com/robot/send?access_token=test-token"
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
output, errors = capture_io do
|
|
72
|
+
with_constructor(Net::HTTP, http) { Appship::Webhook.new(options).notify(message) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
assert_equal 2, http.requests.length
|
|
76
|
+
http.requests.each do |request|
|
|
77
|
+
assert_equal "POST", request.method
|
|
78
|
+
assert_equal "application/json; charset=utf-8", request["Content-Type"]
|
|
79
|
+
assert_equal({ "msgtype" => "text", "text" => { "content" => message } }, JSON.parse(request.body))
|
|
80
|
+
end
|
|
81
|
+
assert http.use_ssl
|
|
82
|
+
assert_equal 0, http.max_retries
|
|
83
|
+
assert_includes output, "企业微信发布通知已发送"
|
|
84
|
+
assert_includes output, "钉钉发布通知已发送"
|
|
85
|
+
assert_empty errors
|
|
86
|
+
refute_includes output, "test-key"
|
|
87
|
+
refute_includes output, "test-token"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def test_unset_webhooks_do_not_make_requests
|
|
91
|
+
http = FakeHTTP.new
|
|
92
|
+
output, errors = capture_io do
|
|
93
|
+
with_constructor(Net::HTTP, http) do
|
|
94
|
+
Appship::Webhook.new(wechat_webhook: "", dingtalk_webhook: nil).notify("message")
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
assert_empty output
|
|
99
|
+
assert_empty errors
|
|
100
|
+
assert_empty http.requests
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def test_failure_notification_contains_context_but_hides_credentials
|
|
104
|
+
url = "https://example.test/send?key=webhook-token"
|
|
105
|
+
http = FakeHTTP.new(response)
|
|
106
|
+
webhook = Appship::Webhook.new(
|
|
107
|
+
app_name: "Demo", provider: "fir", configuration: "Release",
|
|
108
|
+
api_key: "api-secret", password: 123456, fir_api_token: "fir-secret",
|
|
109
|
+
wechat_webhook: url
|
|
110
|
+
)
|
|
111
|
+
error = Appship::UploadError.new("failed api-secret 123456 fir-secret #{url} https://upload.test/path?signature=upload-secret")
|
|
112
|
+
output, errors = capture_io do
|
|
113
|
+
with_constructor(Net::HTTP, http) { webhook.failure(error) }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
content = JSON.parse(http.requests.first.body).dig("text", "content")
|
|
117
|
+
assert_includes content, "发布流程失败"
|
|
118
|
+
assert_includes content, "应用名称: Demo"
|
|
119
|
+
assert_includes content, "分发平台: fir.im"
|
|
120
|
+
assert_includes content, "构建配置: Release"
|
|
121
|
+
%w[api-secret 123456 fir-secret webhook-token upload-secret].each { |secret| refute_includes content, secret }
|
|
122
|
+
assert_includes output, "企业微信失败通知已发送"
|
|
123
|
+
assert_empty errors
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def test_failure_notification_error_does_not_claim_the_app_was_published
|
|
127
|
+
http = FakeHTTP.new(SocketError.new("network failed"))
|
|
128
|
+
output, errors = capture_io do
|
|
129
|
+
with_constructor(Net::HTTP, http) do
|
|
130
|
+
Appship::Webhook.new(wechat_webhook: "https://example.test/send").failure(Appship::CommandError.new("archive failed"))
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
assert_equal 1, http.requests.length
|
|
135
|
+
assert_empty output
|
|
136
|
+
assert_includes errors, "失败通知失败"
|
|
137
|
+
refute_includes errors, "应用已发布"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def test_failed_robot_does_not_prevent_other_robot_from_receiving_message
|
|
141
|
+
http = FakeHTTP.new(response('{"errcode":310000,"errmsg":"keyword missing"}'), response)
|
|
142
|
+
output, errors = capture_io do
|
|
143
|
+
with_constructor(Net::HTTP, http) do
|
|
144
|
+
Appship::Webhook.new(
|
|
145
|
+
wechat_webhook: "https://example.test/wechat?key=secret",
|
|
146
|
+
dingtalk_webhook: "https://example.test/dingtalk?access_token=secret"
|
|
147
|
+
).notify("发布消息")
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
assert_equal 2, http.requests.length
|
|
152
|
+
assert_includes errors, "310000"
|
|
153
|
+
assert_includes errors, "应用已发布"
|
|
154
|
+
refute_includes errors, "secret"
|
|
155
|
+
assert_includes output, "钉钉发布通知已发送"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def test_http_parse_and_network_failures_are_warnings
|
|
159
|
+
[
|
|
160
|
+
response("secret-body", status: "403"),
|
|
161
|
+
response("not-json"),
|
|
162
|
+
response("{}"),
|
|
163
|
+
Net::ReadTimeout.new
|
|
164
|
+
].each do |failure|
|
|
165
|
+
http = FakeHTTP.new(failure)
|
|
166
|
+
output, errors = capture_io do
|
|
167
|
+
with_constructor(Net::HTTP, http) do
|
|
168
|
+
Appship::Webhook.new(wechat_webhook: "https://example.test/send?key=secret-token").notify("message")
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
assert_empty output
|
|
173
|
+
assert_includes errors, "通知失败"
|
|
174
|
+
refute_includes errors, "secret"
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def test_successful_uploads_send_the_exact_rendered_message
|
|
179
|
+
FileUtils.mkdir_p(File.dirname(Appship::Message.path))
|
|
180
|
+
File.write(Appship::Message.path, YAML.dump("cli_content" => "发布成功\n应用: %{name}\n下载: %{url}\n"))
|
|
181
|
+
file = File.join(@webhook_home, "Demo.ipa")
|
|
182
|
+
File.write(file, "ipa-data")
|
|
183
|
+
results = [
|
|
184
|
+
{ "data" => { "buildName" => "Demo", "buildKey" => "demo" } },
|
|
185
|
+
{ "provider" => "fir", "data" => { "name" => "Demo" }, "url" => "https://fir.im/demo" }
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
results.each do |result|
|
|
189
|
+
[:direct, :after_build].each do |flow|
|
|
190
|
+
options = { file: file, upload: true, wechat_webhook: "https://example.test/send" }
|
|
191
|
+
http = FakeHTTP.new(response)
|
|
192
|
+
output, errors = capture_io do
|
|
193
|
+
with_constructor(Net::HTTP, http) do
|
|
194
|
+
with_constructor(Appship::Uploader, FakeUploader.new(result)) do
|
|
195
|
+
builder = Appship::Builder.new
|
|
196
|
+
if flow == :direct
|
|
197
|
+
assert_equal result, builder.upload!(options)
|
|
198
|
+
else
|
|
199
|
+
builder.send(:upload_if_requested!, file, options)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
assert_equal 1, http.requests.length
|
|
206
|
+
content = JSON.parse(http.requests.first.body).fetch("text").fetch("content")
|
|
207
|
+
assert_equal content + "✅ 企业微信发布通知已发送\n", output
|
|
208
|
+
assert_includes content, "应用: Demo"
|
|
209
|
+
assert_includes content, result["provider"] == "fir" ? "https://fir.im/demo" : "https://www.pgyer.com/demo"
|
|
210
|
+
assert_empty errors
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def test_successful_upload_uses_each_channel_template_and_approved_format
|
|
216
|
+
FileUtils.mkdir_p(File.dirname(Appship::Message.path))
|
|
217
|
+
File.write(Appship::Message.path, <<~YAML)
|
|
218
|
+
cli_content: |
|
|
219
|
+
终端: %{name}
|
|
220
|
+
wechat_webhook_success_content: |
|
|
221
|
+
**企微: %{name}**
|
|
222
|
+
wechat_webhook_content: 旧版企微文案
|
|
223
|
+
wechat_webhook_failure_content: "%{password}"
|
|
224
|
+
wechat_webhook_format: markdown
|
|
225
|
+
dingtalk_webhook_content: |
|
|
226
|
+
钉钉: %{name}
|
|
227
|
+
feishu_webhook_success_content: |
|
|
228
|
+
飞书: %{name}
|
|
229
|
+
下载: %{url}
|
|
230
|
+
feishu_webhook_format: post
|
|
231
|
+
YAML
|
|
232
|
+
file = File.join(@webhook_home, "Demo.ipa")
|
|
233
|
+
File.write(file, "ipa-data")
|
|
234
|
+
options = {
|
|
235
|
+
file: file,
|
|
236
|
+
wechat_webhook: "https://example.test/wechat",
|
|
237
|
+
dingtalk_webhook: "https://example.test/dingtalk",
|
|
238
|
+
feishu_webhook: "https://example.test/feishu",
|
|
239
|
+
feishu_webhook_secret: "feishu-signing-secret"
|
|
240
|
+
}
|
|
241
|
+
result = { "provider" => "fir", "data" => { "name" => "Demo" }, "url" => "https://fir.im/demo" }
|
|
242
|
+
http = FakeHTTP.new(response, response, response('{"code":0,"msg":"success"}'))
|
|
243
|
+
output, errors = capture_io do
|
|
244
|
+
with_constructor(Net::HTTP, http) do
|
|
245
|
+
with_constructor(Appship::Uploader, FakeUploader.new(result)) { Appship::Builder.new.upload!(options) }
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
wechat, dingtalk, feishu = http.requests.map { |request| JSON.parse(request.body) }
|
|
250
|
+
assert_includes output, "终端: Demo\n"
|
|
251
|
+
refute_includes output, "企微: Demo"
|
|
252
|
+
refute_includes output, "feishu-signing-secret"
|
|
253
|
+
assert_empty errors
|
|
254
|
+
assert_equal({ "msgtype" => "markdown", "markdown" => { "content" => "**企微: Demo**\n" } }, wechat)
|
|
255
|
+
assert_equal "钉钉: Demo\n", dingtalk.dig("text", "content")
|
|
256
|
+
assert_equal "post", feishu["msg_type"]
|
|
257
|
+
paragraphs = feishu.dig("content", "post", "zh_cn", "content")
|
|
258
|
+
assert_equal({ "tag" => "text", "text" => "飞书: Demo" }, paragraphs.first.first)
|
|
259
|
+
assert_equal({ "tag" => "a", "text" => "https://fir.im/demo", "href" => "https://fir.im/demo" }, paragraphs.last.last)
|
|
260
|
+
expected_signature = OpenSSL::HMAC.digest("SHA256", "#{feishu.fetch("timestamp")}\nfeishu-signing-secret", "")
|
|
261
|
+
assert_equal expected_signature, feishu.fetch("sign").unpack1("m0")
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def test_custom_failure_messages_use_project_directory_and_keep_redaction
|
|
265
|
+
project = File.join(@webhook_home, "project")
|
|
266
|
+
path = Appship::Message.path(project)
|
|
267
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
268
|
+
File.write(path, <<~YAML)
|
|
269
|
+
cli_content: "成功文案"
|
|
270
|
+
wechat_webhook_success_content: "企微成功"
|
|
271
|
+
wechat_webhook_failure_content: |
|
|
272
|
+
企微失败:%{name} / %{provider} / %{configuration}
|
|
273
|
+
%{error_type}:%{error_message}
|
|
274
|
+
wechat_webhook_format: markdown
|
|
275
|
+
dingtalk_webhook_failure_content: "%{password}"
|
|
276
|
+
feishu_webhook_failure_content: "飞书失败:%{error_message}"
|
|
277
|
+
feishu_webhook_format: post
|
|
278
|
+
YAML
|
|
279
|
+
options = {
|
|
280
|
+
project_dir: project, app_name: "Demo", provider: "fir", configuration: "Release",
|
|
281
|
+
wechat_webhook: "https://example.test/wechat?key=wechat-secret",
|
|
282
|
+
dingtalk_webhook: "https://example.test/dingtalk",
|
|
283
|
+
feishu_webhook: "https://open.feishu.cn/open-apis/bot/v2/hook/feishu-token",
|
|
284
|
+
feishu_webhook_secret: "signing-secret", api_key: "api-secret"
|
|
285
|
+
}
|
|
286
|
+
error = Appship::CommandError.new("归档失败 api-secret feishu-token signing-secret https://upload.test/path?token=query-secret")
|
|
287
|
+
http = FakeHTTP.new(response, response, response('{"code":0}'))
|
|
288
|
+
_output, warnings = capture_io do
|
|
289
|
+
with_constructor(Net::HTTP, http) { Appship::Webhook.new(options).failure(error) }
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
wechat, dingtalk, feishu = http.requests.map { |request| JSON.parse(request.body) }
|
|
293
|
+
assert_equal "text", wechat["msgtype"]
|
|
294
|
+
assert_equal "text", feishu["msg_type"]
|
|
295
|
+
assert feishu.key?("sign")
|
|
296
|
+
assert_includes wechat.dig("text", "content"), "企微失败:Demo / fir.im / Release"
|
|
297
|
+
assert_includes wechat.dig("text", "content"), "Appship::CommandError"
|
|
298
|
+
assert_includes dingtalk.dig("text", "content"), "Appship 发布流程失败"
|
|
299
|
+
assert_includes feishu.dig("content", "text"), "飞书失败:归档失败"
|
|
300
|
+
assert_includes warnings, "dingtalk_webhook_failure_content 无效"
|
|
301
|
+
refute_match(/api-secret|feishu-token|signing-secret|query-secret|企微成功/, http.requests.map(&:body).join)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def test_invalid_message_yaml_does_not_prevent_builtin_failure_notification
|
|
305
|
+
path = Appship::Message.path
|
|
306
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
307
|
+
File.write(path, "cli_content: [")
|
|
308
|
+
http = FakeHTTP.new(response)
|
|
309
|
+
_output, warnings = capture_io do
|
|
310
|
+
with_constructor(Net::HTTP, http) do
|
|
311
|
+
Appship::Webhook.new(wechat_webhook: "https://example.test/send").failure(Appship::CommandError.new("上传失败"))
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
assert_includes JSON.parse(http.requests.first.body).dig("text", "content"), "上传失败"
|
|
316
|
+
assert_includes warnings, "无法读取失败消息模板"
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def test_feishu_uses_text_without_signature_by_default_and_hides_secrets_on_failure
|
|
320
|
+
url = "https://open.feishu.cn/open-apis/bot/v2/hook/feishu-token"
|
|
321
|
+
http = FakeHTTP.new(response('{"code":0}'), response('{"code":0}'))
|
|
322
|
+
capture_io do
|
|
323
|
+
with_constructor(Net::HTTP, http) do
|
|
324
|
+
Appship::Webhook.new(feishu_webhook: url).notify("发布成功")
|
|
325
|
+
Appship::Webhook.new(feishu_webhook: url, feishu_webhook_secret: "signing-secret")
|
|
326
|
+
.failure(Appship::CommandError.new("error #{url} feishu-token signing-secret"))
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
text, failure = http.requests.map { |request| JSON.parse(request.body) }
|
|
331
|
+
assert_equal({ "msg_type" => "text", "content" => { "text" => "发布成功" } }, text)
|
|
332
|
+
assert_equal "text", failure["msg_type"]
|
|
333
|
+
assert failure.key?("sign")
|
|
334
|
+
assert_includes failure.dig("content", "text"), "发布流程失败"
|
|
335
|
+
refute_match(/feishu-token|signing-secret/, failure.dig("content", "text"))
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def test_upload_failure_never_sends_success_notification
|
|
339
|
+
file = File.join(@webhook_home, "Demo.ipa")
|
|
340
|
+
File.write(file, "ipa-data")
|
|
341
|
+
options = { file: file, upload: true, wechat_webhook: "https://example.test/send" }
|
|
342
|
+
|
|
343
|
+
http = FakeHTTP.new
|
|
344
|
+
with_constructor(Net::HTTP, http) do
|
|
345
|
+
with_constructor(Appship::Uploader, FakeUploader.new(Appship::UploadError.new("upload failed"))) do
|
|
346
|
+
assert_raises(Appship::UploadError) { Appship::Builder.new.upload!(options) }
|
|
347
|
+
assert_raises(Appship::UploadError) { Appship::Builder.new.send(:upload_if_requested!, file, options) }
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
assert_empty http.requests
|
|
351
|
+
end
|
|
352
|
+
end
|