gowalk-cicd 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.
- package/CLAUDE.md +94 -0
- package/README.md +417 -0
- package/action/.daemux-version +1 -0
- package/action/MIGRATION.md +232 -0
- package/action/action.yml +973 -0
- package/action/prompts/generate_dependent_fields.prompt.yml +78 -0
- package/action/prompts/generate_descriptions.prompt.yml +59 -0
- package/action/scripts/app_context_scanner.py +239 -0
- package/action/scripts/asc_build_history.py +284 -0
- package/action/scripts/asc_common.py +246 -0
- package/action/scripts/asc_metadata_applier.py +368 -0
- package/action/scripts/asc_metadata_detector.py +250 -0
- package/action/scripts/asc_version_create.py +365 -0
- package/action/scripts/asc_version_fetch.py +274 -0
- package/action/scripts/asc_version_reuse.py +177 -0
- package/action/scripts/auto_detect.py +391 -0
- package/action/scripts/autoupdate_check.sh +118 -0
- package/action/scripts/cert_factory.py +190 -0
- package/action/scripts/cfg_io.py +27 -0
- package/action/scripts/cfg_resolve.py +147 -0
- package/action/scripts/commit_bot_changes.sh +82 -0
- package/action/scripts/creds_store.py +328 -0
- package/action/scripts/keychain.py +103 -0
- package/action/scripts/lookup_app_id.py +51 -0
- package/action/scripts/manage_marketing_version.py +337 -0
- package/action/scripts/metadata_constants.py +102 -0
- package/action/scripts/mmv_decide_create.py +181 -0
- package/action/scripts/mmv_floor_check.py +265 -0
- package/action/scripts/next_build_number.py +195 -0
- package/action/scripts/pbxproj_editor.py +220 -0
- package/action/scripts/prepare_signing.py +223 -0
- package/action/scripts/profile_io.py +64 -0
- package/action/scripts/profile_manager.py +307 -0
- package/action/scripts/read_config.py +315 -0
- package/action/scripts/resolve_marketing_version.py +173 -0
- package/action/scripts/set_app_store_whats_new.py +317 -0
- package/action/scripts/team_resolver.py +130 -0
- package/action/scripts/test_app_context_scanner.py +167 -0
- package/action/scripts/test_asc_build_history.py +221 -0
- package/action/scripts/test_asc_builds_prerelease.py +219 -0
- package/action/scripts/test_asc_common_timeouts.py +121 -0
- package/action/scripts/test_asc_metadata_applier.py +850 -0
- package/action/scripts/test_asc_metadata_detector.py +445 -0
- package/action/scripts/test_auto_detect.py +432 -0
- package/action/scripts/test_cfg_resolve_credentials.py +48 -0
- package/action/scripts/test_compute_next_version.py +95 -0
- package/action/scripts/test_compute_next_version_patch_backcompat.py +41 -0
- package/action/scripts/test_compute_next_version_strictness.py +67 -0
- package/action/scripts/test_fetch_versions.py +163 -0
- package/action/scripts/test_ground_truth_floor.py +104 -0
- package/action/scripts/test_manage_marketing_version.py +134 -0
- package/action/scripts/test_manage_marketing_version_autoroll.py +149 -0
- package/action/scripts/test_manage_marketing_version_floor.py +170 -0
- package/action/scripts/test_manage_marketing_version_match.py +187 -0
- package/action/scripts/test_mmv_409_classifier.py +231 -0
- package/action/scripts/test_mmv_autobump_persist_gate.py +131 -0
- package/action/scripts/test_mmv_decide_create.py +119 -0
- package/action/scripts/test_mmv_floor_check.py +158 -0
- package/action/scripts/test_mmv_floor_crosscheck.py +132 -0
- package/action/scripts/test_mmv_helpers.py +57 -0
- package/action/scripts/test_next_build_number.py +174 -0
- package/action/scripts/test_read_config_auto_detect.py +257 -0
- package/action/scripts/test_resolve_marketing_version.py +298 -0
- package/action/scripts/test_reuse_stale_editable.py +182 -0
- package/action/scripts/test_set_app_store_whats_new.py +71 -0
- package/action/scripts/test_team_fallback_wiring.py +157 -0
- package/action/scripts/test_team_resolver.py +249 -0
- package/action/scripts/tests_common.py +167 -0
- package/action/scripts/version_utils.py +373 -0
- package/android-action/.daemux-version +1 -0
- package/android-action/action.yml +92 -0
- package/android-action/scripts/android_config.py +130 -0
- package/android-action/scripts/bitrise_deploy.py +160 -0
- package/android-action/scripts/find_bundle.py +25 -0
- package/android-action/scripts/play_preflight.py +69 -0
- package/android-action/scripts/resolve_android.py +70 -0
- package/android-action/scripts/sign_bundle.py +70 -0
- package/android-action/scripts/test_android_config.py +97 -0
- package/android-action/scripts/test_bitrise_deploy.py +124 -0
- package/android-action/scripts/test_sign_bundle.py +82 -0
- package/bin/cli.mjs +63 -0
- package/package.json +57 -0
- package/src/install.mjs +208 -0
- package/templates/deploy.yml +150 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Tests for resolve_marketing_version.py."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import io
|
|
7
|
+
import os
|
|
8
|
+
import plistlib
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
import unittest
|
|
13
|
+
from contextlib import redirect_stderr, redirect_stdout
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from unittest import mock
|
|
16
|
+
|
|
17
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
18
|
+
|
|
19
|
+
import resolve_marketing_version as rmv # noqa: E402
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _Env:
|
|
23
|
+
"""Scoped env override that restores prior state on exit."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, mapping: dict[str, str | None]) -> None:
|
|
26
|
+
self._mapping = mapping
|
|
27
|
+
self._prev: dict[str, str | None] = {}
|
|
28
|
+
|
|
29
|
+
def __enter__(self) -> "_Env":
|
|
30
|
+
for k, v in self._mapping.items():
|
|
31
|
+
self._prev[k] = os.environ.get(k)
|
|
32
|
+
if v is None:
|
|
33
|
+
os.environ.pop(k, None)
|
|
34
|
+
else:
|
|
35
|
+
os.environ[k] = v
|
|
36
|
+
return self
|
|
37
|
+
|
|
38
|
+
def __exit__(self, *_exc) -> None:
|
|
39
|
+
for k, prev in self._prev.items():
|
|
40
|
+
if prev is None:
|
|
41
|
+
os.environ.pop(k, None)
|
|
42
|
+
else:
|
|
43
|
+
os.environ[k] = prev
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _fake_run(stdout: str = "", returncode: int = 0):
|
|
47
|
+
"""Build a fake subprocess.run replacement returning stdout."""
|
|
48
|
+
def runner(cmd, check=False, capture_output=True, text=True, timeout=None):
|
|
49
|
+
return subprocess.CompletedProcess(
|
|
50
|
+
cmd, returncode=returncode, stdout=stdout, stderr="",
|
|
51
|
+
)
|
|
52
|
+
return runner
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class GrepSettingTests(unittest.TestCase):
|
|
56
|
+
def test_grep_marketing_version(self):
|
|
57
|
+
text = (
|
|
58
|
+
" OTHER = foo\n"
|
|
59
|
+
" MARKETING_VERSION = 1.2.3\n"
|
|
60
|
+
" SOMETHING_ELSE = bar\n"
|
|
61
|
+
)
|
|
62
|
+
self.assertEqual(rmv._grep_setting(text, "MARKETING_VERSION"), "1.2.3")
|
|
63
|
+
|
|
64
|
+
def test_grep_returns_empty_on_miss(self):
|
|
65
|
+
text = " OTHER = foo\n"
|
|
66
|
+
self.assertEqual(rmv._grep_setting(text, "MARKETING_VERSION"), "")
|
|
67
|
+
|
|
68
|
+
def test_grep_strips_surrounding_whitespace(self):
|
|
69
|
+
text = " MARKETING_VERSION = 2.0 \n"
|
|
70
|
+
self.assertEqual(rmv._grep_setting(text, "MARKETING_VERSION"), "2.0")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ResolveTests(unittest.TestCase):
|
|
74
|
+
"""End-to-end resolution paths via main() exercise."""
|
|
75
|
+
|
|
76
|
+
def test_marketing_version_from_build_settings(self):
|
|
77
|
+
stdout = " MARKETING_VERSION = 1.1.2\n INFOPLIST_FILE = Info.plist\n"
|
|
78
|
+
out_buf = io.StringIO()
|
|
79
|
+
err_buf = io.StringIO()
|
|
80
|
+
env = {"PROJECT": "App.xcodeproj", "WORKSPACE": "",
|
|
81
|
+
"SCHEME": "App", "CONFIGURATION": "Release"}
|
|
82
|
+
with _Env(env), \
|
|
83
|
+
mock.patch.object(rmv.subprocess, "run", side_effect=_fake_run(stdout)), \
|
|
84
|
+
redirect_stdout(out_buf), redirect_stderr(err_buf):
|
|
85
|
+
rmv.main()
|
|
86
|
+
self.assertEqual(out_buf.getvalue().strip(), "MARKETING_VERSION=1.1.2")
|
|
87
|
+
self.assertIn("Resolved MARKETING_VERSION=1.1.2", err_buf.getvalue())
|
|
88
|
+
self.assertIn("xcodebuild -showBuildSettings", err_buf.getvalue())
|
|
89
|
+
|
|
90
|
+
def test_falls_back_to_info_plist(self):
|
|
91
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
92
|
+
tmp_dir = Path(tmp)
|
|
93
|
+
plist_path = tmp_dir / "Info.plist"
|
|
94
|
+
with open(plist_path, "wb") as fh:
|
|
95
|
+
plistlib.dump({"CFBundleShortVersionString": "1.0.7"}, fh)
|
|
96
|
+
stdout = f" INFOPLIST_FILE = {plist_path.name}\n"
|
|
97
|
+
project = str(tmp_dir / "App.xcodeproj")
|
|
98
|
+
out_buf = io.StringIO()
|
|
99
|
+
err_buf = io.StringIO()
|
|
100
|
+
env = {"PROJECT": project, "WORKSPACE": "",
|
|
101
|
+
"SCHEME": "App", "CONFIGURATION": "Release"}
|
|
102
|
+
with _Env(env), \
|
|
103
|
+
mock.patch.object(
|
|
104
|
+
rmv.subprocess, "run", side_effect=_fake_run(stdout)), \
|
|
105
|
+
redirect_stdout(out_buf), redirect_stderr(err_buf):
|
|
106
|
+
rmv.main()
|
|
107
|
+
self.assertEqual(out_buf.getvalue().strip(),
|
|
108
|
+
"MARKETING_VERSION=1.0.7")
|
|
109
|
+
self.assertIn("Info.plist CFBundleShortVersionString",
|
|
110
|
+
err_buf.getvalue())
|
|
111
|
+
|
|
112
|
+
def test_empty_settings_exits_1_with_migration_hint(self):
|
|
113
|
+
out_buf = io.StringIO()
|
|
114
|
+
err_buf = io.StringIO()
|
|
115
|
+
env = {"PROJECT": "App.xcodeproj", "WORKSPACE": "",
|
|
116
|
+
"SCHEME": "App", "CONFIGURATION": "Release"}
|
|
117
|
+
with _Env(env), \
|
|
118
|
+
mock.patch.object(rmv.subprocess, "run", side_effect=_fake_run("")), \
|
|
119
|
+
redirect_stdout(out_buf), redirect_stderr(err_buf):
|
|
120
|
+
with self.assertRaises(SystemExit) as ctx:
|
|
121
|
+
rmv.main()
|
|
122
|
+
self.assertEqual(ctx.exception.code, 1)
|
|
123
|
+
self.assertEqual(out_buf.getvalue(), "")
|
|
124
|
+
stderr = err_buf.getvalue()
|
|
125
|
+
self.assertIn("::error::", stderr)
|
|
126
|
+
self.assertIn("MARKETING_VERSION", stderr)
|
|
127
|
+
self.assertIn("MIGRATION.md", stderr)
|
|
128
|
+
|
|
129
|
+
def test_invalid_semver_in_settings_exits_1(self):
|
|
130
|
+
stdout = " MARKETING_VERSION = banana\n"
|
|
131
|
+
out_buf = io.StringIO()
|
|
132
|
+
err_buf = io.StringIO()
|
|
133
|
+
env = {"PROJECT": "App.xcodeproj", "WORKSPACE": "",
|
|
134
|
+
"SCHEME": "App", "CONFIGURATION": "Release"}
|
|
135
|
+
with _Env(env), \
|
|
136
|
+
mock.patch.object(rmv.subprocess, "run", side_effect=_fake_run(stdout)), \
|
|
137
|
+
redirect_stdout(out_buf), redirect_stderr(err_buf):
|
|
138
|
+
with self.assertRaises(SystemExit) as ctx:
|
|
139
|
+
rmv.main()
|
|
140
|
+
self.assertEqual(ctx.exception.code, 1)
|
|
141
|
+
self.assertIn("::error::", err_buf.getvalue())
|
|
142
|
+
|
|
143
|
+
def test_subprocess_error_falls_through_to_error(self):
|
|
144
|
+
def raiser(*_a, **_kw):
|
|
145
|
+
raise OSError("xcodebuild not found")
|
|
146
|
+
out_buf = io.StringIO()
|
|
147
|
+
err_buf = io.StringIO()
|
|
148
|
+
env = {"PROJECT": "App.xcodeproj", "WORKSPACE": "",
|
|
149
|
+
"SCHEME": "App", "CONFIGURATION": "Release"}
|
|
150
|
+
with _Env(env), \
|
|
151
|
+
mock.patch.object(rmv.subprocess, "run", side_effect=raiser), \
|
|
152
|
+
redirect_stdout(out_buf), redirect_stderr(err_buf):
|
|
153
|
+
with self.assertRaises(SystemExit) as ctx:
|
|
154
|
+
rmv.main()
|
|
155
|
+
self.assertEqual(ctx.exception.code, 1)
|
|
156
|
+
self.assertIn("::error::", err_buf.getvalue())
|
|
157
|
+
|
|
158
|
+
def test_project_takes_precedence_over_workspace(self):
|
|
159
|
+
# MARKETING_VERSION is an app-target setting; the bare -project
|
|
160
|
+
# avoids evaluating the whole CocoaPods workspace graph, which can
|
|
161
|
+
# time out -showBuildSettings on CI runners.
|
|
162
|
+
captured: dict = {}
|
|
163
|
+
|
|
164
|
+
def runner(cmd, **_kw):
|
|
165
|
+
captured["cmd"] = cmd
|
|
166
|
+
return subprocess.CompletedProcess(
|
|
167
|
+
cmd, returncode=0, stdout=" MARKETING_VERSION = 1.0\n", stderr="",
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
env = {"WORKSPACE": "App.xcworkspace", "PROJECT": "App.xcodeproj",
|
|
171
|
+
"SCHEME": "App", "CONFIGURATION": "Release"}
|
|
172
|
+
out_buf = io.StringIO()
|
|
173
|
+
err_buf = io.StringIO()
|
|
174
|
+
with _Env(env), \
|
|
175
|
+
mock.patch.object(rmv.subprocess, "run", side_effect=runner), \
|
|
176
|
+
redirect_stdout(out_buf), redirect_stderr(err_buf):
|
|
177
|
+
rmv.main()
|
|
178
|
+
self.assertIn("-project", captured["cmd"])
|
|
179
|
+
self.assertIn("App.xcodeproj", captured["cmd"])
|
|
180
|
+
self.assertNotIn("-workspace", captured["cmd"])
|
|
181
|
+
self.assertEqual(out_buf.getvalue().strip(),
|
|
182
|
+
"MARKETING_VERSION=1.0")
|
|
183
|
+
|
|
184
|
+
def test_workspace_used_when_no_project(self):
|
|
185
|
+
captured: dict = {}
|
|
186
|
+
|
|
187
|
+
def runner(cmd, **_kw):
|
|
188
|
+
captured["cmd"] = cmd
|
|
189
|
+
return subprocess.CompletedProcess(
|
|
190
|
+
cmd, returncode=0, stdout=" MARKETING_VERSION = 1.0\n", stderr="",
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
env = {"WORKSPACE": "App.xcworkspace", "PROJECT": "",
|
|
194
|
+
"SCHEME": "App", "CONFIGURATION": "Release"}
|
|
195
|
+
out_buf = io.StringIO()
|
|
196
|
+
err_buf = io.StringIO()
|
|
197
|
+
with _Env(env), \
|
|
198
|
+
mock.patch.object(rmv.subprocess, "run", side_effect=runner), \
|
|
199
|
+
redirect_stdout(out_buf), redirect_stderr(err_buf):
|
|
200
|
+
rmv.main()
|
|
201
|
+
self.assertIn("-workspace", captured["cmd"])
|
|
202
|
+
self.assertNotIn("-project", captured["cmd"])
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class FlutterVarRefExpansionTests(unittest.TestCase):
|
|
206
|
+
"""Flutter templates put $(FLUTTER_BUILD_NAME) in the plist; the real
|
|
207
|
+
value comes from Generated.xcconfig and shows up in build settings."""
|
|
208
|
+
|
|
209
|
+
def test_expands_flutter_build_name_from_settings(self):
|
|
210
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
211
|
+
plist_path = Path(tmp) / "Info.plist"
|
|
212
|
+
with open(plist_path, "wb") as fh:
|
|
213
|
+
plistlib.dump(
|
|
214
|
+
{"CFBundleShortVersionString": "$(FLUTTER_BUILD_NAME)"}, fh)
|
|
215
|
+
stdout = (
|
|
216
|
+
f" INFOPLIST_FILE = {plist_path.name}\n"
|
|
217
|
+
" FLUTTER_BUILD_NAME = 1.2.0\n"
|
|
218
|
+
)
|
|
219
|
+
project = str(Path(tmp) / "Runner.xcodeproj")
|
|
220
|
+
out_buf = io.StringIO()
|
|
221
|
+
err_buf = io.StringIO()
|
|
222
|
+
env = {"PROJECT": project, "WORKSPACE": "",
|
|
223
|
+
"SCHEME": "Runner", "CONFIGURATION": "Release"}
|
|
224
|
+
with _Env(env), \
|
|
225
|
+
mock.patch.object(
|
|
226
|
+
rmv.subprocess, "run", side_effect=_fake_run(stdout)), \
|
|
227
|
+
redirect_stdout(out_buf), redirect_stderr(err_buf):
|
|
228
|
+
rmv.main()
|
|
229
|
+
self.assertEqual(out_buf.getvalue().strip(),
|
|
230
|
+
"MARKETING_VERSION=1.2.0")
|
|
231
|
+
self.assertIn("expanded via build settings", err_buf.getvalue())
|
|
232
|
+
|
|
233
|
+
def test_unresolvable_reference_exits_1(self):
|
|
234
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
235
|
+
plist_path = Path(tmp) / "Info.plist"
|
|
236
|
+
with open(plist_path, "wb") as fh:
|
|
237
|
+
plistlib.dump(
|
|
238
|
+
{"CFBundleShortVersionString": "$(NOT_DEFINED_ANYWHERE)"}, fh)
|
|
239
|
+
stdout = f" INFOPLIST_FILE = {plist_path.name}\n"
|
|
240
|
+
project = str(Path(tmp) / "Runner.xcodeproj")
|
|
241
|
+
env = {"PROJECT": project, "WORKSPACE": "",
|
|
242
|
+
"SCHEME": "Runner", "CONFIGURATION": "Release"}
|
|
243
|
+
out_buf = io.StringIO()
|
|
244
|
+
err_buf = io.StringIO()
|
|
245
|
+
with _Env(env), \
|
|
246
|
+
mock.patch.object(
|
|
247
|
+
rmv.subprocess, "run", side_effect=_fake_run(stdout)), \
|
|
248
|
+
redirect_stdout(out_buf), redirect_stderr(err_buf):
|
|
249
|
+
with self.assertRaises(SystemExit) as ctx:
|
|
250
|
+
rmv.main()
|
|
251
|
+
self.assertEqual(ctx.exception.code, 1)
|
|
252
|
+
self.assertIn("::error::", err_buf.getvalue())
|
|
253
|
+
|
|
254
|
+
def test_expands_braced_and_modifier_forms(self):
|
|
255
|
+
settings = " FLUTTER_BUILD_NAME = 2.1.0\n"
|
|
256
|
+
self.assertEqual(
|
|
257
|
+
rmv._expand_setting_refs("${FLUTTER_BUILD_NAME}", settings),
|
|
258
|
+
"2.1.0")
|
|
259
|
+
self.assertEqual(
|
|
260
|
+
rmv._expand_setting_refs(
|
|
261
|
+
"$(FLUTTER_BUILD_NAME:default=1.0)", settings),
|
|
262
|
+
"2.1.0")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class PlistEdgeCasesTests(unittest.TestCase):
|
|
266
|
+
def test_missing_short_version_string_returns_empty(self):
|
|
267
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
268
|
+
plist_path = Path(tmp) / "Info.plist"
|
|
269
|
+
with open(plist_path, "wb") as fh:
|
|
270
|
+
plistlib.dump({"CFBundleName": "App"}, fh) # no version key
|
|
271
|
+
stdout = f" INFOPLIST_FILE = {plist_path.name}\n"
|
|
272
|
+
project = str(Path(tmp) / "App.xcodeproj")
|
|
273
|
+
env = {"PROJECT": project, "WORKSPACE": "",
|
|
274
|
+
"SCHEME": "App", "CONFIGURATION": "Release"}
|
|
275
|
+
with _Env(env), \
|
|
276
|
+
mock.patch.object(
|
|
277
|
+
rmv.subprocess, "run", side_effect=_fake_run(stdout)):
|
|
278
|
+
value, source = rmv._resolve()
|
|
279
|
+
self.assertEqual(value, "")
|
|
280
|
+
self.assertEqual(source, "")
|
|
281
|
+
|
|
282
|
+
def test_corrupt_plist_falls_through_safely(self):
|
|
283
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
284
|
+
plist_path = Path(tmp) / "Info.plist"
|
|
285
|
+
plist_path.write_bytes(b"not a plist")
|
|
286
|
+
stdout = f" INFOPLIST_FILE = {plist_path.name}\n"
|
|
287
|
+
project = str(Path(tmp) / "App.xcodeproj")
|
|
288
|
+
env = {"PROJECT": project, "WORKSPACE": "",
|
|
289
|
+
"SCHEME": "App", "CONFIGURATION": "Release"}
|
|
290
|
+
with _Env(env), \
|
|
291
|
+
mock.patch.object(
|
|
292
|
+
rmv.subprocess, "run", side_effect=_fake_run(stdout)):
|
|
293
|
+
value, _source = rmv._resolve()
|
|
294
|
+
self.assertEqual(value, "")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
if __name__ == "__main__":
|
|
298
|
+
unittest.main()
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Tests for ``asc_version_create.reuse_stale_editable`` — the
|
|
4
|
+
PATCH-rename / DELETE-then-POST fallback / failure-mode helper.
|
|
5
|
+
|
|
6
|
+
Split out of ``test_mmv_decide_create.py`` (Issue 15: file
|
|
7
|
+
function-count cap) so that:
|
|
8
|
+
- ``test_reuse_stale_editable.py`` owns the asc_version_create
|
|
9
|
+
seam: PATCH succeeds, PATCH fails -> DELETE+POST, PATCH+DELETE
|
|
10
|
+
both fail -> exit 5, full-flow logging.
|
|
11
|
+
- ``test_mmv_decide_create.py`` owns the orchestrator
|
|
12
|
+
decisions made via ``_stale_editable_id`` and ``decide_create``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import io
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
import unittest
|
|
21
|
+
from contextlib import redirect_stderr
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
25
|
+
|
|
26
|
+
import asc_version_create # noqa: E402
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _FakeResp:
|
|
30
|
+
def __init__(self, status_code: int, *, payload: dict | None = None,
|
|
31
|
+
text: str = "", headers: dict | None = None) -> None:
|
|
32
|
+
self.status_code = status_code
|
|
33
|
+
self.text = text if text else (json.dumps(payload) if payload else "")
|
|
34
|
+
self.headers = headers or {}
|
|
35
|
+
self._payload = payload
|
|
36
|
+
|
|
37
|
+
def json(self) -> dict:
|
|
38
|
+
return self._payload or {}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ReuseStaleEditablePatchTests(unittest.TestCase):
|
|
42
|
+
"""PATCH succeeds: stale editable is renamed to target."""
|
|
43
|
+
|
|
44
|
+
def test_stale_editable_patch_path_reuses_and_skips_post(self):
|
|
45
|
+
patch_calls: list[dict] = []
|
|
46
|
+
post_calls: list[str] = []
|
|
47
|
+
delete_calls: list[str] = []
|
|
48
|
+
|
|
49
|
+
def fake_patch(app_id, existing_id, target, token):
|
|
50
|
+
patch_calls.append({
|
|
51
|
+
"app_id": app_id, "existing_id": existing_id,
|
|
52
|
+
"target": target, "token": token,
|
|
53
|
+
})
|
|
54
|
+
return _FakeResp(200, payload={
|
|
55
|
+
"data": {
|
|
56
|
+
"id": existing_id,
|
|
57
|
+
"type": "appStoreVersions",
|
|
58
|
+
"attributes": {"versionString": target},
|
|
59
|
+
},
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
def fake_post(app_id, version, token):
|
|
63
|
+
post_calls.append(version)
|
|
64
|
+
return _FakeResp(201, payload={"data": {"id": "should-not-happen"}})
|
|
65
|
+
|
|
66
|
+
def fake_delete(app_id, existing_id, token):
|
|
67
|
+
delete_calls.append(existing_id)
|
|
68
|
+
return _FakeResp(204)
|
|
69
|
+
|
|
70
|
+
buf = io.StringIO()
|
|
71
|
+
with redirect_stderr(buf):
|
|
72
|
+
got = asc_version_create.reuse_stale_editable(
|
|
73
|
+
app_id="111", existing_id="ed-101",
|
|
74
|
+
target_version="1.0.6", token="tok",
|
|
75
|
+
patch_fn=fake_patch, delete_fn=fake_delete,
|
|
76
|
+
post_fn=fake_post,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
self.assertEqual(len(patch_calls), 1)
|
|
80
|
+
self.assertEqual(patch_calls[0]["existing_id"], "ed-101")
|
|
81
|
+
self.assertEqual(patch_calls[0]["target"], "1.0.6")
|
|
82
|
+
self.assertEqual(post_calls, [])
|
|
83
|
+
self.assertEqual(delete_calls, [])
|
|
84
|
+
self.assertEqual(got, "ed-101")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ReuseStaleEditablePatchFailsDeleteCreateTests(unittest.TestCase):
|
|
88
|
+
def test_stale_editable_patch_fails_falls_back_to_delete_create(self):
|
|
89
|
+
sequence: list[str] = []
|
|
90
|
+
|
|
91
|
+
def fake_patch(app_id, existing_id, target, token):
|
|
92
|
+
sequence.append("PATCH")
|
|
93
|
+
return _FakeResp(409, payload={
|
|
94
|
+
"errors": [{"code": "SOME.REJECTION", "detail": "nope"}],
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
def fake_delete(app_id, existing_id, token):
|
|
98
|
+
sequence.append(f"DELETE:{existing_id}")
|
|
99
|
+
return _FakeResp(204)
|
|
100
|
+
|
|
101
|
+
def fake_post(app_id, version, token):
|
|
102
|
+
sequence.append(f"POST:{version}")
|
|
103
|
+
return _FakeResp(201, payload={"data": {"id": "new-106"}})
|
|
104
|
+
|
|
105
|
+
buf = io.StringIO()
|
|
106
|
+
with redirect_stderr(buf):
|
|
107
|
+
got = asc_version_create.reuse_stale_editable(
|
|
108
|
+
app_id="111", existing_id="ed-101",
|
|
109
|
+
target_version="1.0.6", token="tok",
|
|
110
|
+
patch_fn=fake_patch, delete_fn=fake_delete,
|
|
111
|
+
post_fn=fake_post,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
self.assertEqual(sequence, ["PATCH", "DELETE:ed-101", "POST:1.0.6"])
|
|
115
|
+
self.assertEqual(got, "new-106")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class ReuseStaleEditableDeleteFailsTests(unittest.TestCase):
|
|
119
|
+
def test_stale_editable_delete_fails_exits_clean(self):
|
|
120
|
+
def fake_patch(app_id, existing_id, target, token):
|
|
121
|
+
return _FakeResp(409, payload={
|
|
122
|
+
"errors": [{"code": "SOME.REJECTION", "detail": "nope"}],
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
def fake_delete(app_id, existing_id, token):
|
|
126
|
+
return _FakeResp(409, payload={
|
|
127
|
+
"errors": [{"code": "ENTITY_ERROR.STATE_INVALID",
|
|
128
|
+
"detail": "version locked by review"}],
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
def fake_post(app_id, version, token):
|
|
132
|
+
raise AssertionError("POST must NOT be called when DELETE fails")
|
|
133
|
+
|
|
134
|
+
buf = io.StringIO()
|
|
135
|
+
with redirect_stderr(buf):
|
|
136
|
+
with self.assertRaises(SystemExit) as ctx:
|
|
137
|
+
asc_version_create.reuse_stale_editable(
|
|
138
|
+
app_id="111", existing_id="ed-101",
|
|
139
|
+
target_version="1.0.6", token="tok",
|
|
140
|
+
patch_fn=fake_patch, delete_fn=fake_delete,
|
|
141
|
+
post_fn=fake_post,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
self.assertEqual(ctx.exception.code, 5)
|
|
145
|
+
stderr = buf.getvalue()
|
|
146
|
+
self.assertIn("ed-101", stderr)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class ReuseStaleEditableLoggingTests(unittest.TestCase):
|
|
150
|
+
def test_reuse_stale_editable_logs_full_flow(self):
|
|
151
|
+
def fake_patch(app_id, existing_id, target, token):
|
|
152
|
+
return _FakeResp(409, payload={
|
|
153
|
+
"errors": [{"code": "SOME.REJECTION", "detail": "nope"}],
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
def fake_delete(app_id, existing_id, token):
|
|
157
|
+
return _FakeResp(204)
|
|
158
|
+
|
|
159
|
+
def fake_post(app_id, version, token):
|
|
160
|
+
return _FakeResp(201, payload={"data": {"id": "new-106"}})
|
|
161
|
+
|
|
162
|
+
buf = io.StringIO()
|
|
163
|
+
with redirect_stderr(buf):
|
|
164
|
+
asc_version_create.reuse_stale_editable(
|
|
165
|
+
app_id="111", existing_id="ed-101",
|
|
166
|
+
target_version="1.0.6", token="tok",
|
|
167
|
+
patch_fn=fake_patch, delete_fn=fake_delete,
|
|
168
|
+
post_fn=fake_post,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
stderr = buf.getvalue()
|
|
172
|
+
self.assertIn("[reuse-stale]", stderr)
|
|
173
|
+
self.assertIn("PATCH", stderr)
|
|
174
|
+
self.assertIn("ed-101", stderr)
|
|
175
|
+
self.assertIn("1.0.6", stderr)
|
|
176
|
+
self.assertIn("PATCH failed", stderr)
|
|
177
|
+
self.assertIn("DELETE", stderr)
|
|
178
|
+
self.assertIn("retrying POST", stderr)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
if __name__ == "__main__":
|
|
182
|
+
unittest.main()
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Tests for set_app_store_whats_new.py's fill-empty backstop mode."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
import unittest
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from unittest import mock
|
|
10
|
+
|
|
11
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
12
|
+
|
|
13
|
+
import set_app_store_whats_new as sw # noqa: E402
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _loc(loc_id, locale, whats_new=None):
|
|
17
|
+
attrs = {"locale": locale}
|
|
18
|
+
if whats_new is not None:
|
|
19
|
+
attrs["whatsNew"] = whats_new
|
|
20
|
+
return {"id": loc_id, "attributes": attrs}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class UpdateAllLocalizationsTests(unittest.TestCase):
|
|
24
|
+
def _run(self, entries, only_if_empty):
|
|
25
|
+
patched = []
|
|
26
|
+
|
|
27
|
+
def fake_patch(token, loc_id, whats_new):
|
|
28
|
+
patched.append(loc_id)
|
|
29
|
+
return True
|
|
30
|
+
|
|
31
|
+
with mock.patch.object(sw, "get_json", return_value={"data": entries}), \
|
|
32
|
+
mock.patch.object(sw, "_patch_localization", side_effect=fake_patch):
|
|
33
|
+
count = sw._update_all_localizations(
|
|
34
|
+
"tok", "v1", "1.0.1", "DEFAULT", "en-US",
|
|
35
|
+
only_if_empty=only_if_empty)
|
|
36
|
+
return count, patched
|
|
37
|
+
|
|
38
|
+
def test_force_mode_patches_every_locale(self):
|
|
39
|
+
entries = [_loc("a", "en-US", "existing"), _loc("b", "ru", ""),
|
|
40
|
+
_loc("c", "de", None)]
|
|
41
|
+
count, patched = self._run(entries, only_if_empty=False)
|
|
42
|
+
self.assertEqual(count, 3)
|
|
43
|
+
self.assertEqual(set(patched), {"a", "b", "c"})
|
|
44
|
+
|
|
45
|
+
def test_backstop_fills_only_empty_locales(self):
|
|
46
|
+
entries = [_loc("a", "en-US", "AI generated notes"),
|
|
47
|
+
_loc("b", "ru", ""), # empty -> fill
|
|
48
|
+
_loc("c", "de", " "), # blank whitespace -> fill
|
|
49
|
+
_loc("d", "fr", None)] # missing -> fill
|
|
50
|
+
count, patched = self._run(entries, only_if_empty=True)
|
|
51
|
+
self.assertEqual(count, 3)
|
|
52
|
+
self.assertEqual(set(patched), {"b", "c", "d"})
|
|
53
|
+
self.assertNotIn("a", patched) # AI content preserved
|
|
54
|
+
|
|
55
|
+
def test_backstop_noop_when_all_present(self):
|
|
56
|
+
entries = [_loc("a", "en-US", "notes"), _loc("b", "ru", "notes")]
|
|
57
|
+
count, patched = self._run(entries, only_if_empty=True)
|
|
58
|
+
self.assertEqual(count, 0)
|
|
59
|
+
self.assertEqual(patched, [])
|
|
60
|
+
|
|
61
|
+
def test_seeds_when_no_localizations(self):
|
|
62
|
+
with mock.patch.object(sw, "get_json", return_value={"data": []}), \
|
|
63
|
+
mock.patch.object(sw, "_create_localization", return_value="new1") as cre:
|
|
64
|
+
count = sw._update_all_localizations(
|
|
65
|
+
"tok", "v1", "1.0.1", "DEFAULT", "en-US", only_if_empty=True)
|
|
66
|
+
self.assertEqual(count, 1)
|
|
67
|
+
cre.assert_called_once()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if __name__ == "__main__":
|
|
71
|
+
unittest.main()
|