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.
data/test/test_cli.rb CHANGED
@@ -1,9 +1,365 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "minitest/autorun"
4
+ require "stringio"
4
5
  require_relative "../lib/appship"
5
6
 
6
7
  class CLITest < Minitest::Test
8
+ def setup
9
+ @cli_home = Dir.mktmpdir("appship-cli-")
10
+ @original_home = ENV["HOME"]
11
+ ENV["HOME"] = @cli_home
12
+ @project_dir = File.join(@cli_home, "project")
13
+ FileUtils.mkdir_p(File.join(@project_dir, "Demo.xcodeproj"))
14
+ @cache_path = File.join(@cli_home, ".config")
15
+ @profiles = [
16
+ { "project_name" => "Demo", "app_name" => "DemoApp" },
17
+ { "project_name" => "Other", "custom" => "keep" }
18
+ ]
19
+ File.write(@cache_path, JSON.generate(@profiles))
20
+ @original_stdin = $stdin
21
+ $stdin = StringIO.new
22
+ end
23
+
24
+ def teardown
25
+ $stdin = @original_stdin
26
+ ENV["HOME"] = @original_home
27
+ FileUtils.remove_entry(@cli_home)
28
+ end
29
+
30
+ class FailingBuilder
31
+ attr_reader :calls, :options
32
+
33
+ def initialize(error)
34
+ @error = error
35
+ @calls = []
36
+ end
37
+
38
+ def find_cached_app!(_options)
39
+ @calls << :find_cached_app
40
+ raise @error
41
+ end
42
+
43
+ def build!(options)
44
+ @options = options
45
+ @calls << :build
46
+ raise @error
47
+ end
48
+
49
+ def package!(options)
50
+ @options = options
51
+ @calls << :package
52
+ raise @error
53
+ end
54
+ end
55
+
56
+ def with_release_fakes(error, notification_error: nil)
57
+ original_builder = Appship::Builder.method(:new)
58
+ original_webhook = Appship::Webhook.method(:new)
59
+ builder = FailingBuilder.new(error)
60
+ messages = []
61
+ Appship::Builder.define_singleton_method(:new) { |*| builder }
62
+ Appship::Webhook.define_singleton_method(:new) do |options|
63
+ webhook = original_webhook.call(options)
64
+ webhook.define_singleton_method(:notify) do |message, failure: false|
65
+ messages << { message: message, failure: failure }
66
+ raise notification_error if notification_error
67
+ end
68
+ webhook
69
+ end
70
+ yield builder, messages
71
+ ensure
72
+ Appship::Builder.define_singleton_method(:new, original_builder)
73
+ Appship::Webhook.define_singleton_method(:new, original_webhook)
74
+ end
75
+
76
+ def configured_release_args(provider = "pgyer")
77
+ @profiles.first.merge!(
78
+ "pgyer_api_key" => "pgyer-test-key", "pgyer_password" => "",
79
+ "fir_api_key" => "fir-test-key", "fir_password" => "",
80
+ "wechat_webhook" => "https://example.test/send?key=test-key"
81
+ )
82
+ File.write(@cache_path, JSON.generate(@profiles))
83
+ [provider, "--project-dir", @project_dir, "--env-file", @cache_path, "--description", "test", "--no-badge"]
84
+ end
85
+
86
+ def test_missing_cache_stops_both_platforms_and_sends_one_failure_notification
87
+ %w[pgyer fir].each do |provider|
88
+ args = configured_release_args(provider) + ["--cache"]
89
+ error = Appship::CommandError.new("未找到缓存 App")
90
+ with_release_fakes(error) do |builder, messages|
91
+ capture_io do
92
+ actual = assert_raises(Appship::CommandError) { Appship::CLI.new.start(args) }
93
+ assert_same error, actual
94
+ end
95
+
96
+ assert_equal [:find_cached_app], builder.calls
97
+ assert_equal 1, messages.length
98
+ assert messages.first[:failure]
99
+ assert_includes messages.first[:message], "未找到缓存 App"
100
+ assert_includes messages.first[:message], "DemoApp"
101
+ end
102
+ end
103
+ end
104
+
105
+ def test_missing_credentials_can_notify_using_the_matching_project_webhook
106
+ args = configured_release_args + ["--rebuild"]
107
+ @profiles.first.delete("pgyer_api_key")
108
+ File.write(@cache_path, JSON.generate(@profiles))
109
+ with_release_fakes(Appship::CommandError.new("must not build")) do |builder, messages|
110
+ capture_io do
111
+ assert_raises(Appship::ConfigurationError) { Appship::CLI.new.start(args) }
112
+ end
113
+
114
+ assert_empty builder.calls
115
+ assert_equal 1, messages.length
116
+ assert_includes messages.first[:message], "缓存缺少 pgyer_api_key"
117
+ assert_includes messages.first[:message], "DemoApp"
118
+ end
119
+ end
120
+
121
+ def test_both_release_commands_use_partial_project_config_and_persist_credentials_locally
122
+ %w[pgyer fir].each do |provider|
123
+ args = configured_release_args(provider) + ["--rebuild", "-key", "local-key", "-password", ""]
124
+ local_path = Appship::ProjectConfig.path(@project_dir)
125
+ FileUtils.mkdir_p(File.dirname(local_path))
126
+ File.write(local_path, YAML.dump("project_name" => "Demo", "app_name" => "LocalApp", "configuration" => "Release",
127
+ "app_icon_badge" => true, "badge" => "QA"))
128
+ global_content = File.read(@cache_path)
129
+
130
+ with_release_fakes(Appship::CommandError.new("stop before archive")) do |builder, messages|
131
+ capture_io { assert_raises(Appship::CommandError) { Appship::CLI.new.start(args) } }
132
+
133
+ assert_equal [:build], builder.calls
134
+ assert_equal "LocalApp", builder.options[:app_name]
135
+ assert_equal "Release", builder.options[:configuration]
136
+ assert_nil builder.options[:badge]
137
+ assert_equal "local-key", builder.options[provider == "fir" ? :fir_api_token : :api_key]
138
+ assert_equal "", builder.options[provider == "fir" ? :fir_password : :password]
139
+ assert_equal @profiles.first["wechat_webhook"], builder.options[:wechat_webhook]
140
+ assert_equal 1, messages.length
141
+ assert_includes messages.first[:message], "LocalApp"
142
+ end
143
+ saved = YAML.safe_load_file(local_path)
144
+ assert_equal "local-key", saved["#{provider}_api_key"]
145
+ assert_equal "", saved["#{provider}_password"]
146
+ refute saved.key?(provider == "fir" ? "pgyer_api_key" : "fir_api_key")
147
+ assert_equal global_content, File.read(@cache_path)
148
+ end
149
+ end
150
+
151
+ def test_app_icon_badge_settings_override_automatic_choices
152
+ cli = Appship::CLI.new
153
+ cases = [
154
+ [{ "app_icon_badge" => false, "app_icon_badge_environment" => true, "badge" => "QA" }, nil],
155
+ [{ "app_icon_badge" => true }, "RELEASE"],
156
+ [{ "app_icon_badge_environment" => true }, "RELEASE"],
157
+ [{ "app_icon_badge" => true, "app_icon_badge_environment" => false, "app_icon_badge_version" => true }, nil],
158
+ [{ "app_icon_badge_environment" => false, "badge" => "QA" }, "QA"]
159
+ ]
160
+ cases.each do |profile, expected|
161
+ options = { configuration: "Release" }
162
+ cli.send(:apply_badge_config!, options, profile) { flunk "should not prompt for a badge" }
163
+ expected ? assert_equal(expected, options[:badge]) : assert_nil(options[:badge])
164
+ end
165
+ assert_raises(Appship::ConfigurationError) do
166
+ cli.send(:apply_badge_config!, {}, { "app_icon_badge" => "false" })
167
+ end
168
+ end
169
+
170
+ def test_cache_badge_uses_configuration_of_the_package_found
171
+ cli = Appship::CLI.new
172
+ builder = Object.new
173
+ builder.define_singleton_method(:find_cached_app!) do |options|
174
+ options[:configuration] = "Release"
175
+ "Demo.app"
176
+ end
177
+ options = { build_mode: "cache", build_mode_explicit: true, configuration: "Debug", description: "test" }
178
+
179
+ capture_io { cli.send(:prepare_pgyer_flow!, options, builder, { "app_icon_badge" => true }) }
180
+
181
+ assert_equal "Demo.app", options[:app]
182
+ assert_equal "RELEASE", options[:badge]
183
+ end
184
+
185
+ def test_build_and_package_resolve_badges_after_cli_options
186
+ path = File.join(@cli_home, "config.yml")
187
+ File.write(path, YAML.dump("configuration" => "Debug", "app_icon_badge" => true))
188
+
189
+ [
190
+ [%w[build --configuration Release], "RELEASE"],
191
+ [%w[package --no-badge], nil],
192
+ [%w[package], "DEBUG"],
193
+ [%w[package --badge QA], "QA"]
194
+ ].each do |command, expected|
195
+ with_release_fakes(Appship::CommandError.new("stop before build")) do |builder, _messages|
196
+ capture_io { assert_raises(Appship::CommandError) { Appship::CLI.new.start(["--config", path, *command]) } }
197
+ expected ? assert_equal(expected, builder.options[:badge]) : assert_nil(builder.options[:badge])
198
+ end
199
+ end
200
+ end
201
+
202
+ def test_missing_credentials_can_notify_from_local_only_profile
203
+ args = ["pgyer", "--project-dir", @project_dir, "--env-file", File.join(@cli_home, "missing.config"), "--rebuild"]
204
+ local_path = Appship::ProjectConfig.path(@project_dir)
205
+ FileUtils.mkdir_p(File.dirname(local_path))
206
+ File.write(local_path, JSON.generate("project_name" => "Demo", "app_name" => "LocalApp", "feishu_webhook" => "https://example.test/local"))
207
+
208
+ with_release_fakes(Appship::CommandError.new("must not build")) do |builder, messages|
209
+ capture_io { assert_raises(Appship::ConfigurationError) { Appship::CLI.new.start(args) } }
210
+
211
+ assert_empty builder.calls
212
+ assert_equal 1, messages.length
213
+ assert_includes messages.first[:message], "缺少 pgyer_api_key"
214
+ assert_includes messages.first[:message], "LocalApp"
215
+ end
216
+ refute File.exist?(File.join(@cli_home, "missing.config"))
217
+ end
218
+
219
+ def test_unexpected_exception_and_user_interruption_notify_and_keep_original_error
220
+ [RuntimeError.new("unexpected failure"), Interrupt.new("cancelled")].each do |error|
221
+ args = configured_release_args + ["--rebuild", "--configuration", "Release"]
222
+ with_release_fakes(error) do |builder, messages|
223
+ capture_io do
224
+ actual = assert_raises(error.class) { Appship::CLI.new.start(args) }
225
+ assert_same error, actual
226
+ end
227
+
228
+ assert_equal [:build], builder.calls
229
+ assert_equal 1, messages.length
230
+ assert messages.first[:failure]
231
+ assert_includes messages.first[:message], error.message
232
+ end
233
+ end
234
+ end
235
+
236
+ def test_failure_notification_error_never_replaces_the_release_error
237
+ args = configured_release_args + ["--cache"]
238
+ error = Appship::CommandError.new("cache failed")
239
+ with_release_fakes(error, notification_error: IOError.new("webhook failed")) do |_builder, messages|
240
+ _output, warnings = capture_io do
241
+ actual = assert_raises(Appship::CommandError) { Appship::CLI.new.start(args) }
242
+ assert_same error, actual
243
+ end
244
+
245
+ assert_equal 1, messages.length
246
+ assert_includes warnings, "保留原始错误"
247
+ end
248
+ end
249
+
250
+ def test_failed_project_matching_does_not_notify_other_projects
251
+ args = configured_release_args + ["--cache"]
252
+ @profiles = [{ "project_name" => "Other", "wechat_webhook" => "https://example.test/other" }]
253
+ File.write(@cache_path, JSON.generate(@profiles))
254
+
255
+ with_release_fakes(Appship::CommandError.new("must not build")) do |builder, messages|
256
+ capture_io { assert_raises(Appship::ConfigurationError) { Appship::CLI.new.start(args) } }
257
+
258
+ assert_empty messages
259
+ assert_empty builder.calls
260
+ end
261
+ end
262
+
263
+ def test_webhook_settings_save_without_credentials_or_exposing_urls
264
+ {
265
+ "wechat" => "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test-key",
266
+ "dingtalk" => "https://oapi.dingtalk.com/robot/send?access_token=test-token",
267
+ "feishu" => "https://open.feishu.cn/open-apis/bot/v2/hook/test-token"
268
+ }.each do |platform, url|
269
+ output, errors = capture_io do
270
+ Appship::CLI.new.start(["webhook", platform, url, "--project-dir", @project_dir, "--env-file", @cache_path])
271
+ end
272
+
273
+ saved = JSON.parse(File.read(@cache_path))
274
+ assert_equal url, saved.first["#{platform}_webhook"]
275
+ assert_equal @profiles.last, saved.last
276
+ assert_equal 0o600, File.stat(@cache_path).mode & 0o777
277
+ assert_includes output, "已保存"
278
+ refute_includes output + errors, url
279
+ refute_includes output, "请输入"
280
+ end
281
+ end
282
+
283
+ def test_webhook_empty_url_disables_only_the_selected_project
284
+ @profiles.first["wechat_webhook"] = "https://example.test/old"
285
+ File.write(@cache_path, JSON.generate(@profiles))
286
+
287
+ capture_io do
288
+ Appship::CLI.new.start(["webhook", "wechat", "", "--project-name", "Demo", "--env-file", @cache_path])
289
+ end
290
+
291
+ saved = JSON.parse(File.read(@cache_path))
292
+ assert_equal "", saved.first["wechat_webhook"]
293
+ assert_equal @profiles.last, saved.last
294
+ end
295
+
296
+ def test_invalid_webhook_settings_do_not_modify_cache
297
+ [
298
+ ["wechat"],
299
+ ["unknown", "https://example.test"],
300
+ ["wechat", "http://example.test"],
301
+ ["wechat", "https://user:password@example.test"],
302
+ ["wechat", "https://example.test/#secret"],
303
+ ["wechat", "not a url"]
304
+ ].each do |args|
305
+ original = File.read(@cache_path)
306
+ capture_io do
307
+ assert_raises(Appship::ConfigurationError) do
308
+ Appship::CLI.new.start(["webhook", *args, "--env-file", @cache_path])
309
+ end
310
+ end
311
+ assert_equal original, File.read(@cache_path)
312
+ end
313
+ end
314
+
315
+ def test_pgyer_profile_passes_own_credentials_and_webhooks_to_upload_options
316
+ options = {}
317
+ profile = { "project_name" => "Demo", "app_name" => "DemoApp", "pgyer_api_key" => "key", "pgyer_password" => "",
318
+ "fir_api_key" => "fir-key", "fir_password" => "fir-password",
319
+ "wechat_webhook" => "https://example.test/wechat", "dingtalk_webhook" => "https://example.test/dingtalk",
320
+ "feishu_webhook" => "https://example.test/feishu", "feishu_webhook_secret" => "signing-secret" }
321
+
322
+ capture_io { Appship::CLI.new.send(:apply_cached_profile!, options, profile) }
323
+
324
+ assert_equal "key", options[:api_key]
325
+ assert_equal "", options[:password]
326
+ assert_equal 1, options[:install_type]
327
+ assert_equal profile["wechat_webhook"], options[:wechat_webhook]
328
+ assert_equal profile["dingtalk_webhook"], options[:dingtalk_webhook]
329
+ assert_equal profile["feishu_webhook"], options[:feishu_webhook]
330
+ assert_equal profile["feishu_webhook_secret"], options[:feishu_webhook_secret]
331
+ end
332
+
333
+ def test_unmatched_target_prompts_for_project_configuration
334
+ Dir.mktmpdir do |directory|
335
+ FileUtils.mkdir_p(File.join(directory, "Demo.xcodeproj"))
336
+ config_path = File.join(directory, "custom.config")
337
+ Dir.chdir(directory) do
338
+ error = assert_raises(Appship::ConfigurationError) do
339
+ Appship::CLI.new.send(:infer_pgyer_target!, { project_name: "WMApp", env_file: config_path })
340
+ end
341
+
342
+ assert_includes error.message, config_path
343
+ assert_includes error.message, "增加当前项目配置"
344
+ assert_includes error.message, "project_name: Demo"
345
+ end
346
+ end
347
+ end
348
+
349
+ def test_missing_target_prompts_for_project_directory
350
+ Dir.mktmpdir do |directory|
351
+ Dir.chdir(directory) do
352
+ error = assert_raises(Appship::ConfigurationError) do
353
+ Appship::CLI.new.send(:infer_pgyer_target!, { project_name: "WMApp" })
354
+ end
355
+
356
+ assert_includes error.message, "project_name"
357
+ assert_includes error.message, "--project-dir"
358
+ assert_includes error.message, directory
359
+ end
360
+ end
361
+ end
362
+
7
363
  def test_fir_short_options_are_normalized
8
364
  args = ["-key", "new-key", "-password", "new-password"]
9
365
 
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require_relative "../lib/appship"
5
+
6
+ class DisplayTest < Minitest::Test
7
+ def setup
8
+ @home = Dir.mktmpdir("appship-display-")
9
+ @original_home = ENV["HOME"]
10
+ @original_directory = Dir.pwd
11
+ ENV["HOME"] = @home
12
+ Dir.chdir(@home)
13
+ @message_path = Appship::Message.path
14
+ end
15
+
16
+ def teardown
17
+ Dir.chdir(@original_directory)
18
+ ENV["HOME"] = @original_home
19
+ FileUtils.remove_entry(@home)
20
+ end
21
+
22
+ def test_version_does_not_create_message_files
23
+ output, = capture_io { Appship::CLI.new.start(["--version"]) }
24
+
25
+ assert_equal "appship #{Appship::VERSION}\n", output
26
+ refute File.exist?(@message_path)
27
+ refute File.exist?(File.join(@home, ".appship"))
28
+ end
29
+
30
+ def test_missing_project_template_uses_bundle_without_reading_or_writing_global_messages
31
+ legacy_path = File.join(@home, ".appship", "message.txt")
32
+ FileUtils.mkdir_p(File.dirname(legacy_path))
33
+ File.write(legacy_path, "旧全局模板\n")
34
+ output, = capture_io { Appship::Display.upload_result({}, file: "Demo.ipa") }
35
+
36
+ assert_equal File.read(File.expand_path("../lib/appship/default_message", __dir__)), Appship::Message.template
37
+ assert_includes output, "应用名称: Demo\n"
38
+ assert_equal "旧全局模板\n", File.read(legacy_path)
39
+ refute File.exist?(File.join(@home, ".appship", ".default_message"))
40
+ refute File.exist?(@message_path)
41
+ end
42
+
43
+ def test_rejects_directory_at_message_path_without_changing_permissions
44
+ FileUtils.mkdir_p(@message_path, mode: 0o700)
45
+ original_mode = File.stat(@message_path).mode
46
+
47
+ error = assert_raises(Appship::ConfigurationError) { Appship::Message.template }
48
+
49
+ assert_includes error.message, "消息模板必须是普通文件"
50
+ assert_equal original_mode, File.stat(@message_path).mode
51
+ end
52
+
53
+ def test_rejects_symlink_without_changing_target
54
+ target = File.join(@home, "other-file")
55
+ File.write(target, "keep", perm: 0o600)
56
+ FileUtils.mkdir_p(File.dirname(@message_path))
57
+ File.symlink(target, @message_path)
58
+
59
+ assert_raises(Appship::ConfigurationError) { Appship::Message.template }
60
+
61
+ assert_equal "keep", File.read(target)
62
+ assert_equal 0o600, File.stat(target).mode & 0o777
63
+ end
64
+
65
+ def test_pgyer_output_renders_template_with_upload_data
66
+ result = { "data" => {
67
+ "buildName" => "Demo",
68
+ "buildType" => "1",
69
+ "buildVersion" => "1.2.3",
70
+ "buildBuildVersion" => "42",
71
+ "buildFileSize" => "1572864",
72
+ "buildUpdated" => "2026-10-26 10:00:00",
73
+ "buildKey" => "demo"
74
+ } }
75
+ options = { configuration: "Release", interactive_content: "完成 100% %{url}", password: "install-secret" }
76
+
77
+ output, = capture_io { Appship::Display.upload_result(result, file: "Demo.ipa", options: options) }
78
+
79
+ assert_includes output, "应用名称: Demo\n应用类型: iOS\n版本信息: 1.2.3(42)\n"
80
+ assert_includes output, "构建模式: Release\n应用大小: 1.5MB\n更新时间: 2026-10-26 10:00:00\n"
81
+ assert_includes output, "更新描述: 完成 100% %{url}\n安装密码: install-secret\n下载链接: https://www.pgyer.com/demo\n"
82
+ refute_includes output, "分发平台:"
83
+ refute_includes Appship::Message.template, "install-secret"
84
+ end
85
+
86
+ def test_pgyer_output_omits_missing_optional_fields
87
+ output, = capture_io do
88
+ Appship::Display.upload_result({ "buildUpdated" => "" }, file: "Demo.apk")
89
+ end
90
+
91
+ assert_includes output, "应用名称: Demo\n应用类型: Android\n构建模式: 未知\n"
92
+ assert_includes output, "安装密码: 未设置\n下载链接: https://www.pgyer.com/\n"
93
+ refute_match(/版本信息:|应用大小:|更新时间:|分发平台:/, output)
94
+ end
95
+
96
+ def test_fir_output_preserves_password_status
97
+ result = {
98
+ "provider" => "fir",
99
+ "data" => { "name" => "Demo", "type" => "ios", "version" => "1.2.3", "build" => "42", "password_protected" => true },
100
+ "url" => "https://fir.im/demo"
101
+ }
102
+
103
+ output, = capture_io do
104
+ Appship::Display.upload_result(result, file: "Demo.ipa", options: { description: "首次发布", fir_password: "install-secret" })
105
+ end
106
+
107
+ assert_includes output, "应用名称: Demo\n应用类型: iOS\n版本信息: 1.2.3(42)\n"
108
+ assert_includes output, "更新描述: 首次发布\n安装密码: 已设置\n下载链接: https://fir.im/demo\n"
109
+ refute_match(/分发平台:|构建模式:|应用大小:|更新时间:|install-secret/, output)
110
+ end
111
+
112
+ def test_fir_output_handles_missing_version_and_no_password
113
+ result = { "provider" => "fir", "data" => { "type" => "android" }, "url" => "https://fir.im/demo" }
114
+
115
+ output, = capture_io { Appship::Display.upload_result(result, file: "Demo.apk") }
116
+
117
+ assert_includes output, "应用名称: Demo\n应用类型: Android\n更新描述: \n安装密码: 未设置\n"
118
+ refute_includes output, "版本信息:"
119
+ end
120
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require_relative "../lib/appship"
5
+
6
+ class MessageTest < Minitest::Test
7
+ def setup
8
+ @home = Dir.mktmpdir("appship-messages-")
9
+ @original_home = ENV["HOME"]
10
+ @original_env_file = ENV["APPSHIP_ENV_FILE"]
11
+ @original_directory = Dir.pwd
12
+ ENV["HOME"] = @home
13
+ ENV["APPSHIP_ENV_FILE"] = File.join(@home, "global.config")
14
+ @project_dir = File.join(@home, "project")
15
+ FileUtils.mkdir_p(File.join(@project_dir, "Demo.xcodeproj"))
16
+ Dir.chdir(@project_dir)
17
+ @path = Appship::Message.path
18
+ end
19
+
20
+ def teardown
21
+ Dir.chdir(@original_directory)
22
+ ENV["HOME"] = @original_home
23
+ ENV["APPSHIP_ENV_FILE"] = @original_env_file
24
+ FileUtils.remove_entry(@home)
25
+ end
26
+
27
+ def test_init_creates_only_project_samples_without_global_files
28
+ output, = capture_io { Appship::CLI.new.start(["init"]) }
29
+
30
+ assert_equal %w[config.yml.sample message.yml.sample], Dir.children("appship").sort
31
+ config_sample = File.read("appship/config.yml.sample")
32
+ assert_equal({ "project_name" => "Demo" }, YAML.safe_load(config_sample))
33
+ assert_includes config_sample, "# app_name: app-name"
34
+ assert_includes config_sample, "# 机器人配置"
35
+ %w[pgyer_api_key pgyer_password fir_api_key fir_password wechat_webhook dingtalk_webhook feishu_webhook].each do |key|
36
+ assert_match(/^# #{key}: "/, config_sample)
37
+ end
38
+ assert_match(/^# app_icon_badge: true/, config_sample)
39
+ assert_match(/^# app_icon_badge_environment: true/, config_sample)
40
+ assert_match(/^# app_icon_badge_version: false/, config_sample)
41
+ assert_equal 0o600, File.stat("#{Appship::ProjectConfig.path}.sample").mode & 0o777
42
+ assert_equal 0o600, File.stat("#{@path}.sample").mode & 0o777
43
+ message_sample = File.read("#{@path}.sample")
44
+ assert_includes message_sample, "可用变量"
45
+ assert_nil YAML.safe_load(message_sample)
46
+ %w[wechat dingtalk feishu].product(%w[success failure]).each do |platform, status|
47
+ assert_match(/^# #{platform}_webhook_#{status}_content: ""/, message_sample)
48
+ end
49
+ cli_example = message_sample.lines.drop_while { |line| line != "# cli_content: |\n" }
50
+ .take_while { |line| line.start_with?("# ") }.map { |line| line.delete_prefix("# ") }.join
51
+ assert_includes YAML.safe_load(cli_example).fetch("cli_content"), "发布成功\n版本:"
52
+ assert_includes output, "#{@path}.sample"
53
+ refute_includes Appship::Message.template, "可用变量"
54
+ refute File.exist?(File.join(@home, ".appship"))
55
+ refute File.exist?(".appship.yml")
56
+ refute File.exist?(@path)
57
+ refute File.exist?(Appship::ProjectConfig.path)
58
+ File.write(@path, message_sample)
59
+ assert_equal File.read(Appship::Message.default_path), Appship::Message.template
60
+ end
61
+
62
+ def test_init_preserves_samples_and_active_files
63
+ capture_io { Appship::CLI.new.start(["init"]) }
64
+ config = "{\"project_name\":\"Demo\",\"app_name\":\"Custom\"}\n"
65
+ File.write(Appship::ProjectConfig.path, config)
66
+ File.write(@path, "自定义 %{name}\n")
67
+ File.write("#{Appship::ProjectConfig.path}.sample", config)
68
+ File.write("#{@path}.sample", "自定义示例 %{name}\n")
69
+
70
+ capture_io { 2.times { Appship::CLI.new.start(["init"]) } }
71
+
72
+ assert_equal config, File.read(Appship::ProjectConfig.path)
73
+ assert_equal "自定义 %{name}\n", File.read(@path)
74
+ assert_equal config, File.read("#{Appship::ProjectConfig.path}.sample")
75
+ assert_equal "自定义示例 %{name}\n", File.read("#{@path}.sample")
76
+ end
77
+
78
+ def test_init_can_target_another_project_directory
79
+ other = File.join(@home, "other")
80
+ FileUtils.mkdir_p(File.join(other, "Other.xcodeproj"))
81
+
82
+ capture_io { Appship::CLI.new.start(["init", "--project-dir", other]) }
83
+
84
+ assert_equal "Other", YAML.safe_load_file("#{Appship::ProjectConfig.path(other)}.sample")["project_name"]
85
+ assert File.file?("#{Appship::Message.path(other)}.sample")
86
+ refute File.exist?(@path)
87
+ end
88
+
89
+ def test_custom_template_preserves_literal_percent_and_body_comments
90
+ Appship::Message.init!
91
+ File.write(File.join(File.dirname(@path), "message"), "/* 备注 */\n%{name} 100%\n/* 正文 */\n%{description}\n")
92
+
93
+ assert_equal "Demo 100%\n/* 正文 */\n更新 %{url}\n", Appship::Message.render(name: "Demo", description: "更新 %{url}")
94
+ File.write(@path, YAML.dump("cli_content" => "YAML %{name}\n"))
95
+ assert_equal "YAML Demo\n", Appship::Message.render(name: "Demo")
96
+ end
97
+
98
+ def test_invalid_variables_are_reported_before_building_or_uploading
99
+ Appship::Message.init!
100
+ File.write(@path, YAML.dump("feishu_webhook_content" => "未知 %{unknown}\n"))
101
+
102
+ [["build", "--upload"], ["package", "--app", "missing.app", "--upload"], ["upload", "missing.ipa"]].each do |args|
103
+ error = assert_raises(Appship::ConfigurationError) { Appship::CLI.new.start(args) }
104
+ assert_includes error.message, "未知变量 unknown"
105
+ assert_includes error.message, @path
106
+ end
107
+ end
108
+
109
+ def test_invalid_template_syntax_is_rejected
110
+ Appship::Message.init!
111
+ ["cli_content: [", "cli_content: true", "cli_content: '应用 %{name'",
112
+ "wechat_webhook_format: image", "feishu_webhook_format: interactive", "unknown: text"].each do |content|
113
+ File.write(@path, content)
114
+ assert_raises(Appship::ConfigurationError) { Appship::Message.template }
115
+ end
116
+ end
117
+
118
+ def test_messages_are_isolated_by_project_directory
119
+ Appship::Message.init!
120
+ File.write(@path, YAML.dump("cli_content" => "第一个 %{name}\n"))
121
+ other = File.join(@home, "other")
122
+ Appship::Message.init!(project_dir: other)
123
+ File.write(Appship::Message.path(other), YAML.dump("cli_content" => "第二个 %{name}\n"))
124
+
125
+ assert_equal "第一个 Demo\n", Appship::Message.render(name: "Demo")
126
+ Dir.chdir(other) { assert_equal "第二个 Demo\n", Appship::Message.render(name: "Demo") }
127
+ end
128
+
129
+ def test_entire_message_command_group_is_removed
130
+ [[], ["init"], ["-e"], ["-o", "file.txt"]].each do |args|
131
+ error = assert_raises(Appship::ConfigurationError) { Appship::CLI.new.start(["message", *args]) }
132
+ assert_includes error.message, "未知命令: message"
133
+ end
134
+ output, = capture_io { Appship::CLI.new.start(["--help"]) }
135
+ refute_includes output, "appship message"
136
+ refute File.exist?(@path)
137
+ end
138
+ end