browser4-cli 4.14.0-rc.5 → 4.14.0-rc.7

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.
@@ -27,8 +27,8 @@ dim() { echo -e "\033[2m$*\033[0m"; }
27
27
 
28
28
  test() {
29
29
  local name="$1"
30
- local fn="$2"
31
- if "$fn"; then
30
+ shift
31
+ if "$@"; then
32
32
  PASS=$((PASS + 1))
33
33
  green " PASS $name"
34
34
  else
@@ -109,26 +109,65 @@ test "--source invalid rejected" bash -c "
109
109
  bash '$INSTALL_SCRIPT' --source invalid --dry-run 2>&1 | grep -qi 'github.*oss\|must be'
110
110
  "
111
111
 
112
+ test "--version rejects a following option as its value" bash -c "
113
+ rc=0
114
+ out=\$(bash '$INSTALL_SCRIPT' --version --dry-run 2>&1) || rc=\$?
115
+ [[ \$rc -ne 0 ]] && echo \"\$out\" | grep -q 'requires a value'
116
+ "
117
+
118
+ test "--install-dir rejects a following option as its value" bash -c "
119
+ rc=0
120
+ out=\$(bash '$INSTALL_SCRIPT' --install-dir --silent 2>&1) || rc=\$?
121
+ [[ \$rc -ne 0 ]] && echo \"\$out\" | grep -q 'requires a value'
122
+ "
123
+
124
+ test "--version accepts a bare semver and prefixes it with v" bash -c "
125
+ bash '$INSTALL_SCRIPT' --locate --version 4.13.18 2>&1 | grep -q 'releases/download/v4\.13\.18/'
126
+ "
127
+
128
+ test "--version with the v prefix is left untouched" bash -c "
129
+ bash '$INSTALL_SCRIPT' --locate --version v4.13.18 2>&1 | grep -q 'releases/download/v4\.13\.18/'
130
+ "
131
+
112
132
  test "--dry-run flag accepted" bash -c "
113
- bash '$INSTALL_SCRIPT' --dry-run 2>&1 | grep -qi 'DRY-RUN\|dry-run\|Install'
133
+ bash '$INSTALL_SCRIPT' --dry-run 2>&1 | grep -q 'DRY-RUN'
114
134
  "
115
135
 
116
- test "--silent flag accepted" bash -c "
117
- bash '$INSTALL_SCRIPT' --silent --dry-run 2>&1 | grep -v '^$' | head -1 | grep -qv '→\|step'
136
+ test "--silent suppresses progress output" bash -c "
137
+ out=\$(bash '$INSTALL_SCRIPT' --silent --dry-run 2>&1)
138
+ echo \"\$out\" | grep -q 'DRY-RUN' && ! echo \"\$out\" | grep -q 'Install:'
118
139
  "
119
140
 
120
141
  test "--skip-local flag accepted" bash -c "
121
- bash '$INSTALL_SCRIPT' --skip-local --dry-run 2>&1 | grep -qi 'skip\|DRY-RUN\|Already\|Install'
142
+ bash '$INSTALL_SCRIPT' --skip-local --dry-run 2>&1 | grep -q 'Skipping local binary check'
122
143
  "
123
144
 
124
145
  test "--no-path flag accepted" bash -c "
125
- bash '$INSTALL_SCRIPT' --no-path --dry-run 2>&1 | grep -vq 'Added to PATH'
146
+ ! bash '$INSTALL_SCRIPT' --no-path --dry-run 2>&1 | grep -q 'Added to PATH'
126
147
  "
127
148
 
128
149
  test "unknown arg rejected" bash -c "
129
150
  bash '$INSTALL_SCRIPT' --bogus-flag 2>&1 | grep -q 'Unknown argument'
130
151
  "
131
152
 
153
+ # A PATH that has everything the diagnostics need except the download tooling.
154
+ # `--locate` must still answer; a real install must refuse with a clear message
155
+ # instead of failing later with 'curl: command not found'.
156
+ minimal_path_locate_works() {
157
+ local dir; dir="$(mktemp -d)"
158
+ local bash_bin; bash_bin=$(command -v bash)
159
+ local tool tool_path
160
+ for tool in uname stat cat grep awk sed dirname basename tr od; do
161
+ tool_path=$(command -v "$tool") && ln -sf "$tool_path" "$dir/$tool"
162
+ done
163
+ local locate_rc=0 install_rc=0
164
+ PATH="$dir" "$bash_bin" "$INSTALL_SCRIPT" --locate >/dev/null 2>&1 || locate_rc=$?
165
+ PATH="$dir" "$bash_bin" "$INSTALL_SCRIPT" --dry-run --no-path >/dev/null 2>&1 || install_rc=$?
166
+ rm -rf "$dir"
167
+ [[ $locate_rc -eq 0 ]] && [[ $install_rc -ne 0 ]]
168
+ }
169
+ test "--locate needs no download tooling, installing does" minimal_path_locate_works
170
+
132
171
  echo ""
133
172
 
134
173
  # ── Locate mode ────────────────────────────────────────
@@ -191,6 +230,103 @@ test "OSS URL NOT using broken download/latest pattern" bash -c "
191
230
 
192
231
  echo ""
193
232
 
233
+ # ── Download integrity (stubbed curl -- no network) ────
234
+
235
+ echo "--- Download integrity ---" | cyan
236
+
237
+ STUB_DIR="$(mktemp -d)"
238
+ mkdir -p "$STUB_DIR"
239
+
240
+ cat > "$STUB_DIR/curl" << 'STUBEOF'
241
+ #!/usr/bin/env bash
242
+ # curl stub for the installer tests. Honours -o <file>, takes the body from
243
+ # STUB_BODY (html = an error page, elf = a native binary), reports STUB_STATUS
244
+ # and records its own command line in STUB_ARGS_FILE so a test can assert on
245
+ # the headers the installer would have sent.
246
+ out=""
247
+ printf '%s\n' "$*" >> "${STUB_ARGS_FILE:-/dev/null}"
248
+ while [[ $# -gt 0 ]]; do
249
+ case "$1" in
250
+ -o) out="$2"; shift 2 ;;
251
+ *) shift ;;
252
+ esac
253
+ done
254
+ if [[ "${STUB_STATUS:-200}" == "200" ]]; then
255
+ if [[ "${STUB_BODY:-elf}" == "html" ]]; then
256
+ { printf '<html>'; head -c 200000 /dev/zero | tr '\0' 'x'; printf '</html>'; } > "$out"
257
+ else
258
+ { printf '\177ELF\002\001\001\000'; head -c 200000 /dev/zero; } > "$out"
259
+ fi
260
+ fi
261
+ echo "${STUB_STATUS:-200}"
262
+ exit "${STUB_EXIT:-0}"
263
+ STUBEOF
264
+ chmod +x "$STUB_DIR/curl"
265
+
266
+ stub_install() {
267
+ # $1 = body kind (html|elf), $2 = source, $3 = args file, $4 = extra env
268
+ local body="$1" source="$2" args_file="$3" dir rc=0
269
+ dir="$(mktemp -d)"
270
+ STUB_BODY="$body" STUB_ARGS_FILE="$args_file" \
271
+ PATH="$STUB_DIR:$PATH" bash "$INSTALL_SCRIPT" \
272
+ --source "$source" --no-path --skip-backend --skip-local \
273
+ --install-dir "$dir/inst" >"$dir/out" 2>&1 || rc=$?
274
+ printf '%s\n' "$rc" > "$dir/rc"
275
+ echo "$dir"
276
+ }
277
+
278
+ download_rejects_html() {
279
+ local args; args="$(mktemp)"
280
+ local dir; dir=$(stub_install html oss "$args")
281
+ local rc; rc=$(cat "$dir/rc")
282
+ local rejected=0
283
+ grep -q 'not a native executable' "$dir/out" && rejected=1
284
+ rm -rf "$dir" "$args"
285
+ [[ "$rc" -ne 0 ]] && [[ $rejected -eq 1 ]]
286
+ }
287
+
288
+ download_accepts_elf() {
289
+ local args; args="$(mktemp)"
290
+ local dir; dir=$(stub_install elf oss "$args")
291
+ local rc; rc=$(cat "$dir/rc")
292
+ local installed; installed=$(find "$dir/inst" -maxdepth 1 -type f -name 'browser4-cli-*' 2>/dev/null | head -1)
293
+ local ok=1
294
+ [[ "$rc" -eq 0 ]] && [[ -n "$installed" ]] && [[ -x "$installed" ]] && ok=0
295
+ rm -rf "$dir" "$args"
296
+ [[ $ok -eq 0 ]]
297
+ }
298
+
299
+ token_not_sent_to_oss() {
300
+ local args; args="$(mktemp)"
301
+ export GITHUB_TOKEN=stub-secret-token
302
+ local dir; dir=$(stub_install elf oss "$args")
303
+ unset GITHUB_TOKEN
304
+ local leaked=0
305
+ grep -q 'stub-secret-token' "$args" && leaked=1
306
+ rm -rf "$dir" "$args"
307
+ [[ $leaked -eq 0 ]]
308
+ }
309
+
310
+ token_sent_to_github() {
311
+ local args; args="$(mktemp)"
312
+ export GITHUB_TOKEN=stub-secret-token
313
+ local dir; dir=$(stub_install elf github "$args")
314
+ unset GITHUB_TOKEN
315
+ local sent=0
316
+ grep -q 'stub-secret-token' "$args" && sent=1
317
+ rm -rf "$dir" "$args"
318
+ [[ $sent -eq 1 ]]
319
+ }
320
+
321
+ test "download rejects an HTML error page (bad magic bytes)" download_rejects_html
322
+ test "download accepts a native binary body" download_accepts_elf
323
+ test "GITHUB_TOKEN is never sent to the OSS mirror" token_not_sent_to_oss
324
+ test "GITHUB_TOKEN is sent to GitHub" token_sent_to_github
325
+
326
+ rm -rf "$STUB_DIR"
327
+
328
+ echo ""
329
+
194
330
  # ── Functions (extracted via copy without main call) ──
195
331
 
196
332
  echo "--- Functions ---" | cyan
@@ -232,7 +368,9 @@ test "get_default_install_dir returns non-empty" bash -c "
232
368
 
233
369
  test "detect_china_locale returns 0 or 1 (no crash)" bash -c "
234
370
  source '$FUNC_TEST_SCRIPT' >/dev/null 2>&1
235
- detect_china_locale && true || true
371
+ rc=0
372
+ detect_china_locale || rc=\$?
373
+ [[ \$rc -eq 0 || \$rc -eq 1 ]]
236
374
  "
237
375
 
238
376
  test "detect_os returns valid OS" bash -c "
@@ -323,7 +461,10 @@ test "header is ASCII (no box-drawing chars)" bash -c "
323
461
  "
324
462
 
325
463
  test "header does not contain Unicode box-drawing" bash -c "
326
- ! bash '$INSTALL_SCRIPT' --locate 2>&1 | grep -P '[\x{2500}-\x{257F}]' 2>/dev/null || true
464
+ # Portable (no grep -P): drop the ANSI colour escapes first, then any byte
465
+ # outside printable ASCII would be a non-ASCII character; the installer
466
+ # prints ASCII only.
467
+ ! bash '$INSTALL_SCRIPT' --locate 2>&1 | tr -d '\033' | LC_ALL=C grep -q '[^ -~]'
327
468
  "
328
469
 
329
470
  echo ""
@@ -333,7 +474,13 @@ echo ""
333
474
  echo "--- Edge cases ---" | cyan
334
475
 
335
476
  test "double dash in --version handled (no operator parsing)" bash -c "
336
- bash '$INSTALL_SCRIPT' --locate 2>&1 | grep -q 'version'
477
+ # '--version --dry-run' must not be read as the tag '--dry-run'; the value
478
+ # has to be a release tag, and a bare semver gets the 'v' prefix.
479
+ rc=0
480
+ out=\$(bash '$INSTALL_SCRIPT' --version --dry-run 2>&1) || rc=\$?
481
+ [[ \$rc -ne 0 ]] || exit 1
482
+ echo \"\$out\" | grep -q 'looks like another option' || exit 1
483
+ bash '$INSTALL_SCRIPT' --locate --version 4.13.18 2>&1 | grep -q 'releases/download/v4\.13\.18/'
337
484
  "
338
485
 
339
486
  test "empty --version with dry-run doesn't download" bash -c "
@@ -0,0 +1,421 @@
1
+ #!/usr/bin/env bash
2
+ # reconcile-release-assets.tests.sh
3
+ # Test suite for reconcile-release-assets.sh
4
+ # Uses only bash builtins and standard Unix tools -- no external test
5
+ # frameworks and no network: `gh` is stubbed on PATH.
6
+ #
7
+ # Run:
8
+ # bash cli/scripts/tests/reconcile-release-assets.tests.sh
9
+
10
+ set -euo pipefail
11
+
12
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
13
+ SCRIPT_UNDER_TEST="$(cd "$SCRIPT_DIR/.." && pwd)/reconcile-release-assets.sh"
14
+
15
+ PASS=0
16
+ FAIL=0
17
+
18
+ green() { echo -e "\033[0;32m$*\033[0m"; }
19
+ red() { echo -e "\033[0;31m$*\033[0m"; }
20
+ cyan() { echo -e "\033[0;36m$*\033[0m"; }
21
+ dim() { echo -e "\033[2m$*\033[0m"; }
22
+
23
+ # test <name> <command...>
24
+ # The whole command is forwarded: `test "x" bash -c "..."` must not drop the
25
+ # `-c "..."` part, or a bare `bash` would exit 0 and the assertion would pass
26
+ # vacuously.
27
+ test() {
28
+ local name="$1"
29
+ shift
30
+ if "$@"; then
31
+ PASS=$((PASS + 1))
32
+ green " PASS $name"
33
+ else
34
+ FAIL=$((FAIL + 1))
35
+ red " FAIL $name"
36
+ fi
37
+ }
38
+
39
+ # ---- fixture: three release assets ------------------------------------
40
+
41
+ STUB_DIR="$(mktemp -d)"
42
+ cleanup() { rm -rf "$STUB_DIR"; }
43
+ trap cleanup EXIT
44
+
45
+ ASSET_DIR="$STUB_DIR/assets"
46
+ mkdir -p "$ASSET_DIR"
47
+
48
+ # make_asset <name> <bytes> -- a real file of an exact size, so the script's
49
+ # size comparison runs against real byte counts.
50
+ make_asset() {
51
+ local path="$ASSET_DIR/$1" bytes="$2" i=0
52
+ rm -f "$path"
53
+ : > "$path"
54
+ while [ "$i" -lt "$bytes" ]; do
55
+ printf 'x' >> "$path"
56
+ i=$((i + 1))
57
+ done
58
+ }
59
+
60
+ JAR="Browser4.jar"
61
+ CLI_BIN="browser4-cli-linux-x64"
62
+ BUNDLE="browser4-bundle-runtime-linux-x64.tar.gz"
63
+ JAR_BYTES=101
64
+ CLI_BIN_BYTES=202
65
+ BUNDLE_BYTES=303
66
+ make_asset "$JAR" "$JAR_BYTES"
67
+ make_asset "$CLI_BIN" "$CLI_BIN_BYTES"
68
+ make_asset "$BUNDLE" "$BUNDLE_BYTES"
69
+
70
+ ASSET_PATHS=("$ASSET_DIR/$JAR" "$ASSET_DIR/$CLI_BIN" "$ASSET_DIR/$BUNDLE")
71
+
72
+ # ---- stub gh ----------------------------------------------------------
73
+
74
+ cat > "$STUB_DIR/gh" <<'STUB'
75
+ #!/usr/bin/env bash
76
+ # Fake `gh release view` / `gh release upload`, driven by the test environment:
77
+ #
78
+ # FAKE_GH_STATE directory holding one file per asset the release "has",
79
+ # whose content is the size GitHub reports for it
80
+ # FAKE_GH_COUNTS directory holding the per-command invocation counters
81
+ # FAKE_GH_LOG append-only command log the assertions read
82
+ # FAKE_GH_MODE ok | view-fails-once | upload-fails-once |
83
+ # upload-never-succeeds | release-missing
84
+ #
85
+ # upload-fails-once and upload-never-succeeds answer with the message the
86
+ # v4.14.0-rc.6 release run died on.
87
+ set -euo pipefail
88
+
89
+ state="${FAKE_GH_STATE:?FAKE_GH_STATE is required}"
90
+ counts="${FAKE_GH_COUNTS:?FAKE_GH_COUNTS is required}"
91
+ log="${FAKE_GH_LOG:?FAKE_GH_LOG is required}"
92
+ mode="${FAKE_GH_MODE:?FAKE_GH_MODE is required}"
93
+
94
+ bump() {
95
+ local file="$counts/$1" count=0
96
+ if [ -f "$file" ]; then count="$(cat "$file")"; fi
97
+ count=$((count + 1))
98
+ printf '%s' "$count" > "$file"
99
+ printf '%s' "$count"
100
+ }
101
+
102
+ command_name="${1:-} ${2:-}"
103
+
104
+ case "$command_name" in
105
+ "release view")
106
+ printf 'view\n' >> "$log"
107
+ n="$(bump view)"
108
+
109
+ if [ "$mode" = "release-missing" ]; then
110
+ echo "release not found" >&2
111
+ exit 1
112
+ fi
113
+ if [ "$mode" = "view-fails-once" ] && [ "$n" -le 1 ]; then
114
+ echo "HTTP 502: Bad Gateway (https://api.github.com/repos/o/r/releases/tags/v1.2.3)" >&2
115
+ exit 1
116
+ fi
117
+
118
+ for asset in "$state"/*; do
119
+ [ -e "$asset" ] || continue
120
+ printf '%s %s\n' "$(basename "$asset")" "$(cat "$asset")"
121
+ done
122
+ ;;
123
+
124
+ "release upload")
125
+ # Skip the subcommand and the tag; keep the arguments that are real
126
+ # files -- --repo and its value are not.
127
+ shift 2
128
+ shift
129
+ files=()
130
+ for arg in "$@"; do
131
+ [ -f "$arg" ] || continue
132
+ files+=("$arg")
133
+ done
134
+
135
+ names=()
136
+ for f in "${files[@]}"; do names+=("${f##*/}"); done
137
+ printf 'upload %s\n' "${names[*]}" >> "$log"
138
+
139
+ n="$(bump upload)"
140
+ fail_round=0
141
+ case "$mode" in
142
+ upload-fails-once)
143
+ if [ "$n" -le 1 ]; then fail_round=1; fi
144
+ ;;
145
+ upload-never-succeeds)
146
+ fail_round=1
147
+ ;;
148
+ esac
149
+ if [ "$fail_round" -eq 1 ]; then
150
+ echo "Error creating asset temp dir" >&2
151
+ exit 1
152
+ fi
153
+
154
+ for f in "${files[@]}"; do
155
+ printf '%s' "$(wc -c < "$f" | tr -d '[:space:]')" > "$state/${f##*/}"
156
+ echo "Uploaded ${f##*/}"
157
+ done
158
+ ;;
159
+
160
+ *)
161
+ echo "fake gh: unhandled command '${command_name}'" >&2
162
+ exit 2
163
+ ;;
164
+ esac
165
+ STUB
166
+ chmod +x "$STUB_DIR/gh"
167
+
168
+ # ---- runner -----------------------------------------------------------
169
+
170
+ RUN_STATUS=0
171
+ RUN_OUTPUT=''
172
+ RUN_STATE=''
173
+ RUN_COUNTS=''
174
+ RUN_LOG=''
175
+
176
+ # new_run -- fresh release state, command log and counters for one scenario
177
+ new_run() {
178
+ RUN_STATE="$STUB_DIR/state.$$.$RANDOM"
179
+ RUN_COUNTS="$STUB_DIR/counts.$$.$RANDOM"
180
+ RUN_LOG="$STUB_DIR/log.$$.$RANDOM"
181
+ rm -rf "$RUN_STATE" "$RUN_COUNTS" "$RUN_LOG"
182
+ mkdir -p "$RUN_STATE" "$RUN_COUNTS"
183
+ : > "$RUN_LOG"
184
+ }
185
+
186
+ # seed_asset <name> [<bytes>] -- an asset the release already carries, at the
187
+ # size GitHub reports for it (defaults to the local size, i.e. a good upload)
188
+ seed_asset() {
189
+ local name="$1" bytes="${2:-}"
190
+ if [ -z "$bytes" ]; then
191
+ bytes="$(wc -c < "$ASSET_DIR/$name" | tr -d '[:space:]')"
192
+ fi
193
+ printf '%s' "$bytes" > "$RUN_STATE/$name"
194
+ }
195
+
196
+ # run_script <mode> <timeout> <interval> <settle> [file...]
197
+ run_script() {
198
+ local mode="$1" timeout="$2" interval="$3" settle="$4"
199
+ shift 4
200
+
201
+ RUN_STATUS=0
202
+ RUN_OUTPUT="$(
203
+ FAKE_GH_MODE="$mode" \
204
+ FAKE_GH_STATE="$RUN_STATE" \
205
+ FAKE_GH_COUNTS="$RUN_COUNTS" \
206
+ FAKE_GH_LOG="$RUN_LOG" \
207
+ PATH="$STUB_DIR:$PATH" \
208
+ bash "$SCRIPT_UNDER_TEST" \
209
+ --tag v1.2.3 \
210
+ --repo owner/repo \
211
+ --timeout "$timeout" \
212
+ --interval "$interval" \
213
+ --settle "$settle" \
214
+ "$@" 2>&1
215
+ )" || RUN_STATUS=$?
216
+ }
217
+
218
+ count_of() {
219
+ local file="$RUN_COUNTS/$1"
220
+ if [ -f "$file" ]; then cat "$file"; else printf '0'; fi
221
+ }
222
+
223
+ upload_calls() { count_of upload; }
224
+
225
+ # uploaded_names -- every asset name the script asked gh to upload
226
+ uploaded_names() {
227
+ sed -n 's/^upload //p' "$RUN_LOG" | tr ' ' '\n' | sed '/^$/d' | LC_ALL=C sort
228
+ }
229
+
230
+ output_has() {
231
+ case "$RUN_OUTPUT" in
232
+ *"$1"*) return 0 ;;
233
+ *) return 1 ;;
234
+ esac
235
+ }
236
+
237
+ # ---- pre-flight -------------------------------------------------------
238
+
239
+ cyan "============================================"
240
+ cyan " reconcile-release-assets.sh Test Suite"
241
+ cyan "============================================"
242
+ echo ""
243
+
244
+ dim "--- Pre-flight ---"
245
+
246
+ test "file exists" bash -c "[ -f '$SCRIPT_UNDER_TEST' ]"
247
+ test "starts with shebang" bash -c "head -c 20 '$SCRIPT_UNDER_TEST' | grep -q '^#!/bin/bash'"
248
+ test "bash syntax check passes" bash -c "bash -n '$SCRIPT_UNDER_TEST' 2>&1"
249
+ # Byte-oriented on purpose: `grep -P` is unavailable in some environments and
250
+ # decodes the file through the host locale.
251
+ test "no non-ASCII bytes" bash -c "! LC_ALL=C grep -q '[^[:print:][:space:]]' '$SCRIPT_UNDER_TEST'"
252
+
253
+ echo ""
254
+
255
+ # ---- behaviour --------------------------------------------------------
256
+
257
+ # The common case: the publishing action already uploaded everything. The
258
+ # script must cost one listing call, no upload, and say so.
259
+ test_verifies_a_complete_release_without_uploading() {
260
+ new_run
261
+ seed_asset "$JAR"
262
+ seed_asset "$CLI_BIN"
263
+ seed_asset "$BUNDLE"
264
+ run_script ok 20 1 0 "${ASSET_PATHS[@]}"
265
+ [ "$RUN_STATUS" -eq 0 ] || return 1
266
+ output_has "Verified 3 asset(s) on release v1.2.3" || return 1
267
+ [ "$(upload_calls)" -eq 0 ] || return 1
268
+ [ "$(count_of view)" -eq 1 ] || return 1
269
+ }
270
+
271
+ # The v4.14.0-rc.6 failure: some assets never made it. Only those are sent
272
+ # again -- re-uploading the whole set is what makes the job slow.
273
+ test_uploads_only_the_missing_assets() {
274
+ new_run
275
+ seed_asset "$JAR"
276
+ run_script ok 20 1 0 "${ASSET_PATHS[@]}"
277
+ [ "$RUN_STATUS" -eq 0 ] || return 1
278
+ [ "$(uploaded_names)" = "$(printf '%s\n%s\n' "$BUNDLE" "$CLI_BIN" | LC_ALL=C sort)" ] || return 1
279
+ [ "$(count_of view)" -ge 2 ] || return 1
280
+ }
281
+
282
+ # A failed upload can leave a partial asset behind: size, not presence, decides.
283
+ test_repairs_a_partial_asset_by_size() {
284
+ new_run
285
+ seed_asset "$JAR"
286
+ seed_asset "$CLI_BIN"
287
+ seed_asset "$BUNDLE" 7
288
+ run_script ok 20 1 0 "${ASSET_PATHS[@]}"
289
+ [ "$RUN_STATUS" -eq 0 ] || return 1
290
+ output_has "size mismatch: the release has 7 bytes, the file has 303" || return 1
291
+ [ "$(uploaded_names)" = "$BUNDLE" ] || return 1
292
+ [ "$(cat "$RUN_STATE/$BUNDLE")" = "$BUNDLE_BYTES" ] || return 1
293
+ }
294
+
295
+ # The CI failure itself: GitHub answers "Error creating asset temp dir". The
296
+ # round must be retried instead of failing the release.
297
+ test_survives_the_transient_upload_error() {
298
+ new_run
299
+ run_script upload-fails-once 20 1 0 "${ASSET_PATHS[@]}"
300
+ [ "$RUN_STATUS" -eq 0 ] || return 1
301
+ output_has "Error creating asset temp dir" || return 1
302
+ output_has "WARN: gh release upload failed" || return 1
303
+ output_has "Verified 3 asset(s) on release v1.2.3" || return 1
304
+ [ "$(upload_calls)" -ge 2 ] || return 1
305
+ [ "$(cat "$RUN_STATE/$BUNDLE")" = "$BUNDLE_BYTES" ] || return 1
306
+ }
307
+
308
+ # The listing API is just as capable of a 5xx as the upload endpoint.
309
+ test_survives_a_transient_listing_error() {
310
+ new_run
311
+ seed_asset "$JAR"
312
+ seed_asset "$CLI_BIN"
313
+ seed_asset "$BUNDLE"
314
+ run_script view-fails-once 20 1 0 "${ASSET_PATHS[@]}"
315
+ [ "$RUN_STATUS" -eq 0 ] || return 1
316
+ output_has "WARN: could not list the assets of release v1.2.3" || return 1
317
+ output_has "Verified 3 asset(s) on release v1.2.3" || return 1
318
+ }
319
+
320
+ # A missing release is the creation path, not the upload path: say so instead
321
+ # of recreating it here with metadata the workflow did not pass.
322
+ test_fails_when_the_release_does_not_exist() {
323
+ new_run
324
+ run_script release-missing 20 1 0 "${ASSET_PATHS[@]}"
325
+ [ "$RUN_STATUS" -eq 1 ] || return 1
326
+ output_has "release v1.2.3 does not exist" || return 1
327
+ output_has "release not found" || return 1
328
+ }
329
+
330
+ # Uploads that never take effect must fail the job, with the offenders named.
331
+ test_fails_when_uploads_never_take_effect() {
332
+ new_run
333
+ run_script upload-never-succeeds 2 1 0 "${ASSET_PATHS[@]}"
334
+ [ "$RUN_STATUS" -eq 1 ] || return 1
335
+ output_has "still does not match the build output after 2s" || return 1
336
+ output_has " - $BUNDLE: missing from the release" || return 1
337
+ output_has "Re-run the failed jobs of this workflow" || return 1
338
+ }
339
+
340
+ # ... and it must give up on time rather than sleep past its budget.
341
+ test_respects_the_time_budget() {
342
+ local started elapsed
343
+ new_run
344
+ started=$SECONDS
345
+ run_script upload-never-succeeds 2 1 0 "${ASSET_PATHS[@]}"
346
+ elapsed=$((SECONDS - started))
347
+ [ "$RUN_STATUS" -eq 1 ] || return 1
348
+ [ "$elapsed" -le 10 ] || return 1
349
+ }
350
+
351
+ # The release workflow hands over the manifest it already builds: one asset
352
+ # path per line, with blank lines and CRLF tolerated.
353
+ test_reads_a_newline_separated_manifest() {
354
+ local manifest="$STUB_DIR/manifest.txt"
355
+ {
356
+ printf '%s\r\n' "${ASSET_PATHS[0]}"
357
+ printf '\n'
358
+ printf '%s\n%s\n' "${ASSET_PATHS[1]}" "${ASSET_PATHS[2]}"
359
+ } > "$manifest"
360
+
361
+ new_run
362
+ seed_asset "$JAR"
363
+ seed_asset "$CLI_BIN"
364
+ seed_asset "$BUNDLE"
365
+ run_script ok 20 1 0 --files-from "$manifest"
366
+ [ "$RUN_STATUS" -eq 0 ] || return 1
367
+ output_has "Verified 3 asset(s) on release v1.2.3" || return 1
368
+ [ "$(upload_calls)" -eq 0 ] || return 1
369
+ }
370
+
371
+ test_rejects_a_missing_asset_file() {
372
+ new_run
373
+ run_script ok 20 1 0 "$ASSET_DIR/not-built.tar.gz"
374
+ [ "$RUN_STATUS" -eq 1 ] || return 1
375
+ output_has "does not exist" || return 1
376
+ [ "$(count_of view)" -eq 0 ] || return 1
377
+ }
378
+
379
+ test_requires_a_tag() {
380
+ local output status
381
+ output="$(PATH="$STUB_DIR:$PATH" bash "$SCRIPT_UNDER_TEST" "${ASSET_PATHS[@]}" 2>&1)" && status=0 || status=$?
382
+ [ "$status" -ne 0 ] || return 1
383
+ [[ "$output" == *"--tag is required"* ]] || return 1
384
+ [[ "$output" == *"Usage:"* ]] || return 1
385
+ }
386
+
387
+ test_requires_asset_files() {
388
+ local output status
389
+ output="$(PATH="$STUB_DIR:$PATH" bash "$SCRIPT_UNDER_TEST" --tag v1.2.3 2>&1)" && status=0 || status=$?
390
+ [ "$status" -ne 0 ] || return 1
391
+ [[ "$output" == *"no asset files given"* ]] || return 1
392
+ [[ "$output" == *"Usage:"* ]] || return 1
393
+ }
394
+
395
+ dim "--- Behaviour ---"
396
+
397
+ test "complete release: verified, nothing uploaded" test_verifies_a_complete_release_without_uploading
398
+ test "uploads only the missing assets" test_uploads_only_the_missing_assets
399
+ test "repairs a partial asset by size" test_repairs_a_partial_asset_by_size
400
+ test "survives the transient upload error that failed v4.14.0-rc.6" test_survives_the_transient_upload_error
401
+ test "survives a transient listing error" test_survives_a_transient_listing_error
402
+ test "fails when the release does not exist" test_fails_when_the_release_does_not_exist
403
+ test "fails when uploads never take effect" test_fails_when_uploads_never_take_effect
404
+ test "respects the time budget" test_respects_the_time_budget
405
+ test "reads a newline-separated manifest" test_reads_a_newline_separated_manifest
406
+ test "rejects a missing asset file" test_rejects_a_missing_asset_file
407
+ test "requires a tag" test_requires_a_tag
408
+ test "requires asset files" test_requires_asset_files
409
+
410
+ # ---- summary ----------------------------------------------------------
411
+
412
+ echo ""
413
+ cyan "============================================"
414
+ if [ "$FAIL" -eq 0 ]; then
415
+ green " All $PASS tests passed"
416
+ else
417
+ red " $FAIL of $((PASS + FAIL)) tests failed"
418
+ fi
419
+ cyan "============================================"
420
+
421
+ [ "$FAIL" -eq 0 ]