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.
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env bash
2
+ # wait-for-npm-version.tests.sh
3
+ # Test suite for wait-for-npm-version.sh
4
+ # Uses only bash builtins and standard Unix tools -- no external test
5
+ # frameworks and no network: `npm` is stubbed on PATH.
6
+ #
7
+ # Run:
8
+ # bash cli/scripts/tests/wait-for-npm-version.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)/wait-for-npm-version.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
+ # ---- stub npm ---------------------------------------------------------
40
+
41
+ STUB_DIR="$(mktemp -d)"
42
+ cleanup() { rm -rf "$STUB_DIR"; }
43
+ trap cleanup EXIT
44
+
45
+ cat > "$STUB_DIR/npm" <<'STUB'
46
+ #!/usr/bin/env bash
47
+ # Fake `npm view <package>@<version> version`, driven by the test environment:
48
+ # FAKE_NPM_MODE available | available-after:<n> | unavailable |
49
+ # network-error-once | wrong-version
50
+ # FAKE_NPM_VERSION version reported on success
51
+ # FAKE_NPM_COUNT_FILE file counting the invocations
52
+ set -euo pipefail
53
+
54
+ count_file="${FAKE_NPM_COUNT_FILE:?FAKE_NPM_COUNT_FILE is required}"
55
+ count=0
56
+ if [ -f "$count_file" ]; then count="$(cat "$count_file")"; fi
57
+ count=$((count + 1))
58
+ printf '%s' "$count" > "$count_file"
59
+
60
+ not_found() {
61
+ echo "npm error code E404" >&2
62
+ echo "npm error 404 No match found for version ${FAKE_NPM_VERSION:?FAKE_NPM_VERSION is required}" >&2
63
+ exit 1
64
+ }
65
+
66
+ mode="${FAKE_NPM_MODE:?FAKE_NPM_MODE is required}"
67
+ case "$mode" in
68
+ available)
69
+ printf '%s\n' "$FAKE_NPM_VERSION"
70
+ ;;
71
+ available-after:*)
72
+ threshold="${mode#available-after:}"
73
+ if [ "$count" -ge "$threshold" ]; then
74
+ printf '%s\n' "$FAKE_NPM_VERSION"
75
+ else
76
+ not_found
77
+ fi
78
+ ;;
79
+ unavailable)
80
+ not_found
81
+ ;;
82
+ wrong-version)
83
+ printf '%s\n' "0.0.1"
84
+ ;;
85
+ network-error-once)
86
+ if [ "$count" -ge 2 ]; then
87
+ printf '%s\n' "$FAKE_NPM_VERSION"
88
+ else
89
+ echo "npm error network request to https://registry.npmjs.org failed" >&2
90
+ exit 1
91
+ fi
92
+ ;;
93
+ *)
94
+ echo "fake npm: unknown FAKE_NPM_MODE '$mode'" >&2
95
+ exit 2
96
+ ;;
97
+ esac
98
+ STUB
99
+ chmod +x "$STUB_DIR/npm"
100
+
101
+ # run_wait <mode> <timeout-seconds> <poll-interval-seconds> <package> <version>
102
+ # Sets RUN_STATUS, RUN_OUTPUT (stdout+stderr) and RUN_COUNT (npm invocations).
103
+ RUN_STATUS=0
104
+ RUN_OUTPUT=''
105
+ RUN_COUNT=0
106
+
107
+ run_wait() {
108
+ local mode="$1" timeout="$2" interval="$3" package="$4" version="$5"
109
+ local count_file="$STUB_DIR/count.$$.$RANDOM"
110
+ rm -f "$count_file"
111
+
112
+ RUN_STATUS=0
113
+ RUN_OUTPUT="$(
114
+ FAKE_NPM_MODE="$mode" \
115
+ FAKE_NPM_VERSION="$version" \
116
+ FAKE_NPM_COUNT_FILE="$count_file" \
117
+ PATH="$STUB_DIR:$PATH" \
118
+ bash "$SCRIPT_UNDER_TEST" "$package" "$version" "$timeout" "$interval" 2>&1
119
+ )" || RUN_STATUS=$?
120
+
121
+ RUN_COUNT=0
122
+ if [ -f "$count_file" ]; then RUN_COUNT="$(cat "$count_file")"; fi
123
+ }
124
+
125
+ # ---- pre-flight -------------------------------------------------------
126
+
127
+ cyan "============================================"
128
+ cyan " wait-for-npm-version.sh Test Suite"
129
+ cyan "============================================"
130
+ echo ""
131
+
132
+ dim "--- Pre-flight ---"
133
+
134
+ test "file exists" bash -c "[[ -f '$SCRIPT_UNDER_TEST' ]]"
135
+ test "starts with shebang" bash -c "head -c 20 '$SCRIPT_UNDER_TEST' | grep -q '^#!/bin/bash'"
136
+ test "bash syntax check passes" bash -c "bash -n '$SCRIPT_UNDER_TEST' 2>&1"
137
+ test "no non-ASCII bytes" bash -c "! grep -Pn '[^\\x00-\\x7F]' '$SCRIPT_UNDER_TEST' >/dev/null 2>&1"
138
+
139
+ echo ""
140
+
141
+ # ---- behaviour --------------------------------------------------------
142
+
143
+ test_version_visible_immediately() {
144
+ run_wait available 30 1 browser4-cli 1.2.3
145
+ [ "$RUN_STATUS" -eq 0 ] || return 1
146
+ [[ "$RUN_OUTPUT" == *"Verified browser4-cli@1.2.3 on npm"* ]] || return 1
147
+ [ "$RUN_COUNT" -eq 1 ] || return 1
148
+ }
149
+
150
+ test_waits_until_the_version_appears() {
151
+ run_wait "available-after:3" 30 1 browser4-cli 1.2.3
152
+ [ "$RUN_STATUS" -eq 0 ] || return 1
153
+ [[ "$RUN_OUTPUT" == *"Verified browser4-cli@1.2.3 on npm"* ]] || return 1
154
+ [ "$RUN_COUNT" -ge 3 ] || return 1
155
+ }
156
+
157
+ # Discrimination test for the CI failure this script fixes: the registry
158
+ # reports the version only on the 7th call -- beyond the 5 attempts the old
159
+ # inline loop allowed. A 3 s budget reproduces the old give-up, a 30 s budget
160
+ # rides it out.
161
+ test_keeps_polling_past_the_old_five_attempt_window() {
162
+ run_wait "available-after:7" 3 1 browser4-cli 1.2.3
163
+ [ "$RUN_STATUS" -eq 1 ] || return 1
164
+
165
+ run_wait "available-after:7" 30 1 browser4-cli 1.2.3
166
+ [ "$RUN_STATUS" -eq 0 ] || return 1
167
+ [ "$RUN_COUNT" -ge 7 ] || return 1
168
+ [[ "$RUN_OUTPUT" == *"Verified browser4-cli@1.2.3 on npm"* ]] || return 1
169
+ }
170
+
171
+ test_fails_when_the_version_never_appears() {
172
+ run_wait unavailable 3 1 browser4-cli 1.2.3
173
+ [ "$RUN_STATUS" -eq 1 ] || return 1
174
+ [[ "$RUN_OUTPUT" == *"unable to verify browser4-cli@1.2.3 on npm within 3s"* ]] || return 1
175
+ [[ "$RUN_OUTPUT" == *"E404"* ]] || return 1
176
+ [[ "$RUN_OUTPUT" == *"npm view browser4-cli@1.2.3 version"* ]] || return 1
177
+ }
178
+
179
+ test_tolerates_a_transient_registry_error() {
180
+ run_wait network-error-once 30 1 browser4-cli 1.2.3
181
+ [ "$RUN_STATUS" -eq 0 ] || return 1
182
+ [[ "$RUN_OUTPUT" == *"WARN: npm view failed"* ]] || return 1
183
+ }
184
+
185
+ test_reports_a_mismatched_version_and_keeps_waiting() {
186
+ run_wait wrong-version 3 1 browser4-cli 1.2.3
187
+ [ "$RUN_STATUS" -eq 1 ] || return 1
188
+ [[ "$RUN_OUTPUT" == *"npm reports '0.0.1'"* ]] || return 1
189
+ }
190
+
191
+ test_requires_both_arguments() {
192
+ local output status
193
+ output="$(PATH="$STUB_DIR:$PATH" bash "$SCRIPT_UNDER_TEST" 2>&1)" && status=0 || status=$?
194
+ [ "$status" -ne 0 ] || return 1
195
+ [[ "$output" == *"Usage:"* ]] || return 1
196
+ }
197
+
198
+ dim "--- Behaviour ---"
199
+
200
+ test "version already visible: verifies on the first poll" test_version_visible_immediately
201
+ test "waits until the registry reports the version" test_waits_until_the_version_appears
202
+ test "keeps polling past the old 5-attempt window" test_keeps_polling_past_the_old_five_attempt_window
203
+ test "fails when the version never appears" test_fails_when_the_version_never_appears
204
+ test "tolerates a transient registry error" test_tolerates_a_transient_registry_error
205
+ test "reports a mismatched version and keeps waiting" test_reports_a_mismatched_version_and_keeps_waiting
206
+ test "requires both arguments" test_requires_both_arguments
207
+
208
+ # ---- summary ----------------------------------------------------------
209
+
210
+ echo ""
211
+ cyan "============================================"
212
+ if [ "$FAIL" -eq 0 ]; then
213
+ green " All $PASS tests passed"
214
+ else
215
+ red " $FAIL of $((PASS + FAIL)) tests failed"
216
+ fi
217
+ cyan "============================================"
218
+
219
+ [ "$FAIL" -eq 0 ]
@@ -0,0 +1,382 @@
1
+ #!/usr/bin/env bash
2
+ # wait-for-oss-sync.tests.sh
3
+ # Test suite for wait-for-oss-sync.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/wait-for-oss-sync.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)/wait-for-oss-sync.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
+ # ---- stub gh ----------------------------------------------------------
40
+ #
41
+ # The stub answers from files under $FAKE_GH_DIR, so every test reads as a
42
+ # description of what GitHub was reporting:
43
+ #
44
+ # baseline.tsv `gh run list --limit 50` (the pre-dispatch listing)
45
+ # baseline-fail if present, that listing fails instead
46
+ # discover-<n>.tsv n-th `gh run list --limit 20` (falls back to
47
+ # discover-last.tsv ...discover-last.tsv, and to empty output)
48
+ # view-<n>.tsv n-th `gh run view` (falls back to
49
+ # view-last.tsv ...view-last.tsv, and to empty output)
50
+ # dispatch-exit exit status for `gh workflow run`
51
+ #
52
+ # Each file holds TSV exactly as the script's own -q filters produce it:
53
+ # run list : <databaseId>\t<createdAt>
54
+ # run view : <status>\t<conclusion>\t<in-progress step>
55
+ # The script's own -q filters turn "no value" into "--" rather than leaving the
56
+ # field empty (tab is IFS whitespace, so `read` would collapse the run of tabs
57
+ # and an empty conclusion would swallow the step name).
58
+ # An empty response stands for "gh could not answer", which is what the
59
+ # script's `2>/dev/null` turns a failed call into.
60
+
61
+ STUB_DIR="$(mktemp -d)"
62
+ cleanup() { rm -rf "$STUB_DIR"; }
63
+ trap cleanup EXIT
64
+
65
+ cat > "$STUB_DIR/gh" <<'STUB'
66
+ #!/usr/bin/env bash
67
+ set -euo pipefail
68
+
69
+ dir="${FAKE_GH_DIR:?FAKE_GH_DIR is required}"
70
+ echo "$*" >> "$dir/calls.log"
71
+
72
+ counter_next() {
73
+ local file="$dir/$1.count" n=0
74
+ if [ -f "$file" ]; then n="$(cat "$file")"; fi
75
+ n=$((n + 1))
76
+ printf '%s' "$n" > "$file"
77
+ printf '%s' "$n"
78
+ }
79
+
80
+ emit() {
81
+ # $1 = base name, $2 = sequence number: numbered response, else the fallback
82
+ local numbered="$dir/$1-$2.tsv" fallback="$dir/$1-last.tsv"
83
+ if [ -f "$numbered" ]; then
84
+ cat "$numbered"
85
+ elif [ -f "$fallback" ]; then
86
+ cat "$fallback"
87
+ fi
88
+ return 0
89
+ }
90
+
91
+ limit=''
92
+ prev=''
93
+ for arg in "$@"; do
94
+ if [ "$prev" = '--limit' ]; then limit="$arg"; fi
95
+ prev="$arg"
96
+ done
97
+
98
+ case "${1:-} ${2:-}" in
99
+ 'run list')
100
+ if [ "$limit" = '50' ]; then
101
+ if [ -f "$dir/baseline-fail" ]; then exit 1; fi
102
+ if [ -f "$dir/baseline.tsv" ]; then cat "$dir/baseline.tsv"; fi
103
+ else
104
+ emit discover "$(counter_next list)"
105
+ fi
106
+ ;;
107
+ 'run view')
108
+ emit view "$(counter_next view)"
109
+ ;;
110
+ 'workflow run')
111
+ if [ -f "$dir/dispatch-exit" ]; then exit "$(cat "$dir/dispatch-exit")"; fi
112
+ ;;
113
+ *)
114
+ echo "fake gh: unhandled invocation: $*" >&2
115
+ exit 2
116
+ ;;
117
+ esac
118
+ STUB
119
+ chmod +x "$STUB_DIR/gh"
120
+
121
+ # ---- harness ----------------------------------------------------------
122
+
123
+ RUN_STATUS=0
124
+ RUN_OUTPUT=''
125
+
126
+ # reset_gh -- empty the stub's response directory (and the call log)
127
+ reset_gh() {
128
+ rm -rf "$STUB_DIR/gh-state"
129
+ mkdir -p "$STUB_DIR/gh-state"
130
+ export FAKE_GH_DIR="$STUB_DIR/gh-state"
131
+ }
132
+
133
+ # gh_respond <file> [<content>] -- write one stub response file
134
+ gh_respond() {
135
+ local file="$1"
136
+ shift
137
+ if [ $# -gt 0 ]; then
138
+ printf '%s\n' "$*" > "$FAKE_GH_DIR/$file"
139
+ else
140
+ : > "$FAKE_GH_DIR/$file"
141
+ fi
142
+ }
143
+
144
+ # run_wait [<extra args>...] -- run the script on a 4 s / 1 s budget, so a
145
+ # "never finishes" case costs ~4 s instead of the 2700 s default.
146
+ run_wait() {
147
+ RUN_STATUS=0
148
+ RUN_OUTPUT="$(
149
+ FAKE_GH_DIR="$FAKE_GH_DIR" \
150
+ PATH="$STUB_DIR:$PATH" \
151
+ bash "$SCRIPT_UNDER_TEST" \
152
+ --tag v9.9.9 --repo acme/browser4 \
153
+ --timeout 4 --interval 1 --discovery-timeout 2 "$@" 2>&1
154
+ )" || RUN_STATUS=$?
155
+ }
156
+
157
+ # ---- pre-flight -------------------------------------------------------
158
+
159
+ cyan "============================================"
160
+ cyan " wait-for-oss-sync.sh Test Suite"
161
+ cyan "============================================"
162
+ echo ""
163
+
164
+ dim "--- Pre-flight ---"
165
+
166
+ test "file exists" bash -c "[[ -f '$SCRIPT_UNDER_TEST' ]]"
167
+ test "starts with shebang" bash -c "head -c 20 '$SCRIPT_UNDER_TEST' | grep -q '^#!/bin/bash'"
168
+ test "bash syntax check passes" bash -c "bash -n '$SCRIPT_UNDER_TEST' 2>&1"
169
+ test "no non-ASCII bytes" bash -c "! grep -Pn '[^\\x00-\\x7F]' '$SCRIPT_UNDER_TEST' >/dev/null 2>&1"
170
+ test "--help exits 0 and prints usage" bash -c "bash '$SCRIPT_UNDER_TEST' --help 2>&1 | grep -q 'Usage:'"
171
+
172
+ echo ""
173
+
174
+ # ---- behaviour --------------------------------------------------------
175
+
176
+ # The happy path, and the shape of the success message the workflows rely on.
177
+ test_run_that_completes_successfully() {
178
+ reset_gh
179
+ gh_respond baseline.tsv "$(printf '111\t2026-09-20T15:20:51Z')"
180
+ gh_respond discover-last.tsv "$(printf '222\t2026-09-23T13:05:09Z')"
181
+ gh_respond view-1.tsv "$(printf 'in_progress\t--\tUpload to OSS')"
182
+ gh_respond view-last.tsv "$(printf 'completed\tsuccess\t--')"
183
+
184
+ run_wait
185
+ [ "$RUN_STATUS" -eq 0 ] || return 1
186
+ [[ "$RUN_OUTPUT" == *"Watching sync-to-oss.yml run 222"* ]] || return 1
187
+ [[ "$RUN_OUTPUT" == *"completed successfully"* ]] || return 1
188
+ [[ "$RUN_OUTPUT" == *"actions/runs/222"* ]] || return 1
189
+ grep -q 'workflow run sync-to-oss.yml' "$FAKE_GH_DIR/calls.log" || return 1
190
+ }
191
+
192
+ # The v4.13.17/v4.13.21 defect: the freshly dispatched run is not indexed yet,
193
+ # so `gh run list` answers with the PREVIOUS release's run -- which completed
194
+ # successfully. Adopting it (the old inline loop did: first non-empty answer
195
+ # won) reports the CDN as updated without waiting for anything. Ours must win
196
+ # even though the stale run is the more attractive answer.
197
+ test_never_adopts_the_previous_runs_success() {
198
+ reset_gh
199
+ gh_respond baseline.tsv "$(printf '111\t2026-09-20T15:20:51Z')"
200
+ # both runs visible: 111 (before we dispatched, already green) and 222 (ours)
201
+ gh_respond discover-last.tsv "$(printf '222\t2026-09-23T13:05:09Z\n111\t2026-09-20T15:20:51Z')"
202
+ gh_respond view-last.tsv "$(printf 'completed\tfailure\t--')"
203
+
204
+ run_wait
205
+ [ "$RUN_STATUS" -eq 1 ] || return 1
206
+ # ours is named as the verdict ...
207
+ [[ "$RUN_OUTPUT" == *"run 222 concluded 'failure'"* ]] || return 1
208
+ # ... and the stale run's success was never claimed
209
+ [[ "$RUN_OUTPUT" != *"completed successfully"* ]] || return 1
210
+ }
211
+
212
+ # The same guard while our run is still queued behind the stale one.
213
+ test_waits_for_our_run_when_only_stale_runs_are_listed() {
214
+ reset_gh
215
+ gh_respond baseline.tsv "$(printf '111\t2026-09-20T15:20:51Z')"
216
+ gh_respond discover-1.tsv "$(printf '111\t2026-09-20T15:20:51Z')"
217
+ gh_respond discover-2.tsv "$(printf '111\t2026-09-20T15:20:51Z')"
218
+ gh_respond discover-last.tsv "$(printf '222\t2026-09-23T13:05:09Z\n111\t2026-09-20T15:20:51Z')"
219
+ gh_respond view-last.tsv "$(printf 'completed\tsuccess\t--')"
220
+
221
+ # a generous discovery budget: the point is that it keeps looking, not
222
+ # that it finds the run on any particular poll
223
+ run_wait --discovery-timeout 20
224
+ [ "$RUN_STATUS" -eq 0 ] || return 1
225
+ [[ "$RUN_OUTPUT" == *"Watching sync-to-oss.yml run 222"* ]] || return 1
226
+ [[ "$RUN_OUTPUT" == *"Waiting for the sync-to-oss.yml run to appear"* ]] || return 1
227
+ }
228
+
229
+ # A dispatch that never becomes a run must be reported, not waited out.
230
+ test_fails_when_no_new_run_appears() {
231
+ reset_gh
232
+ gh_respond baseline.tsv "$(printf '111\t2026-09-20T15:20:51Z')"
233
+ gh_respond discover-last.tsv "$(printf '111\t2026-09-20T15:20:51Z')"
234
+
235
+ run_wait
236
+ [ "$RUN_STATUS" -eq 1 ] || return 1
237
+ [[ "$RUN_OUTPUT" == *"no new run appeared within 2s"* ]] || return 1
238
+ }
239
+
240
+ # The v4.13.21 failure itself: the sync stalls, the budget expires. The
241
+ # message has to name the run, the step it is stuck on and how to re-check it
242
+ # -- the old step timeout reported none of that.
243
+ test_reports_the_stuck_run_when_the_budget_expires() {
244
+ reset_gh
245
+ gh_respond baseline.tsv "$(printf '111\t2026-09-20T15:20:51Z')"
246
+ gh_respond discover-last.tsv "$(printf '222\t2026-09-23T13:05:09Z')"
247
+ gh_respond view-last.tsv "$(printf 'in_progress\t--\tUpload to OSS')"
248
+
249
+ run_wait
250
+ [ "$RUN_STATUS" -eq 1 ] || return 1
251
+ [[ "$RUN_OUTPUT" == *"was still in_progress (step: Upload to OSS) after 4s"* ]] || return 1
252
+ [[ "$RUN_OUTPUT" == *"actions/runs/222"* ]] || return 1
253
+ [[ "$RUN_OUTPUT" == *"gh run view 222 --repo acme/browser4"* ]] || return 1
254
+ [[ "$RUN_OUTPUT" == *"gh workflow run sync-to-oss.yml --repo acme/browser4 -f tag_name=v9.9.9"* ]] || return 1
255
+ # the progress line names the stuck step too, so the log shows where it sat
256
+ [[ "$RUN_OUTPUT" == *"status=in_progress, step=Upload to OSS"* ]] || return 1
257
+ }
258
+
259
+ test_reports_a_failed_run() {
260
+ reset_gh
261
+ gh_respond baseline.tsv ''
262
+ gh_respond discover-last.tsv "$(printf '222\t2026-09-23T13:05:09Z')"
263
+ gh_respond view-last.tsv "$(printf 'completed\tfailure\t--')"
264
+
265
+ run_wait
266
+ [ "$RUN_STATUS" -eq 1 ] || return 1
267
+ [[ "$RUN_OUTPUT" == *"concluded 'failure'"* ]] || return 1
268
+ [[ "$RUN_OUTPUT" == *"actions/runs/222"* ]] || return 1
269
+ }
270
+
271
+ test_reports_a_cancelled_run_as_a_failure() {
272
+ reset_gh
273
+ gh_respond baseline.tsv ''
274
+ gh_respond discover-last.tsv "$(printf '222\t2026-09-23T13:05:09Z')"
275
+ gh_respond view-last.tsv "$(printf 'completed\tcancelled\t--')"
276
+
277
+ run_wait
278
+ [ "$RUN_STATUS" -eq 1 ] || return 1
279
+ [[ "$RUN_OUTPUT" == *"concluded 'cancelled'"* ]] || return 1
280
+ }
281
+
282
+ # An unreadable poll (rate limit, network blip) must not abort the wait: the
283
+ # stub answers nothing for the first view call, then reports our run done.
284
+ test_survives_an_unreadable_poll() {
285
+ reset_gh
286
+ gh_respond baseline.tsv ''
287
+ gh_respond discover-last.tsv "$(printf '222\t2026-09-23T13:05:09Z')"
288
+ gh_respond view-1.tsv ''
289
+ gh_respond view-last.tsv "$(printf 'completed\tsuccess\t--')"
290
+
291
+ run_wait
292
+ [ "$RUN_STATUS" -eq 0 ] || return 1
293
+ [[ "$RUN_OUTPUT" == *"completed successfully"* ]] || return 1
294
+ }
295
+
296
+ test_fails_when_the_dispatch_itself_fails() {
297
+ reset_gh
298
+ gh_respond baseline.tsv ''
299
+ printf '1' > "$FAKE_GH_DIR/dispatch-exit"
300
+
301
+ run_wait
302
+ [ "$RUN_STATUS" -eq 1 ] || return 1
303
+ [[ "$RUN_OUTPUT" == *"Could not dispatch sync-to-oss.yml"* ]] || return 1
304
+ }
305
+
306
+ # With the baseline listing unavailable the run is identified by creation time
307
+ # alone -- a run created after the dispatch is still accepted ...
308
+ test_falls_back_to_creation_time_when_the_baseline_listing_fails() {
309
+ reset_gh
310
+ : > "$FAKE_GH_DIR/baseline-fail"
311
+ gh_respond discover-last.tsv "$(printf '222\t2099-01-01T00:00:00Z')"
312
+ gh_respond view-last.tsv "$(printf 'completed\tsuccess\t--')"
313
+
314
+ run_wait
315
+ [ "$RUN_STATUS" -eq 0 ] || return 1
316
+ [[ "$RUN_OUTPUT" == *"could not list existing sync-to-oss.yml runs"* ]] || return 1
317
+ [[ "$RUN_OUTPUT" == *"Watching sync-to-oss.yml run 222"* ]] || return 1
318
+ }
319
+
320
+ # ... and a run from before the dispatch is not, even with no id baseline.
321
+ test_creation_time_fallback_still_refuses_a_stale_run() {
322
+ reset_gh
323
+ : > "$FAKE_GH_DIR/baseline-fail"
324
+ gh_respond discover-last.tsv "$(printf '111\t2020-01-01T00:00:00Z')"
325
+ gh_respond view-last.tsv "$(printf 'completed\tsuccess\t--')"
326
+
327
+ run_wait
328
+ [ "$RUN_STATUS" -eq 1 ] || return 1
329
+ [[ "$RUN_OUTPUT" == *"no new run appeared"* ]] || return 1
330
+ }
331
+
332
+ test_requires_the_tag_argument() {
333
+ local output status
334
+ output="$(PATH="$STUB_DIR:$PATH" bash "$SCRIPT_UNDER_TEST" --repo acme/browser4 2>&1)" && status=0 || status=$?
335
+ [ "$status" -ne 0 ] || return 1
336
+ [[ "$output" == *"Usage:"* ]] || return 1
337
+ [[ "$output" == *"--tag is required"* ]] || return 1
338
+ }
339
+
340
+ test_rejects_an_unknown_argument() {
341
+ local output status
342
+ output="$(PATH="$STUB_DIR:$PATH" bash "$SCRIPT_UNDER_TEST" --tag v1 --nonsense 2>&1)" && status=0 || status=$?
343
+ [ "$status" -ne 0 ] || return 1
344
+ [[ "$output" == *"unknown argument '--nonsense'"* ]] || return 1
345
+ }
346
+
347
+ test_rejects_a_non_numeric_timeout() {
348
+ local output status
349
+ output="$(PATH="$STUB_DIR:$PATH" bash "$SCRIPT_UNDER_TEST" --tag v1 --timeout soon 2>&1)" && status=0 || status=$?
350
+ [ "$status" -ne 0 ] || return 1
351
+ [[ "$output" == *"--timeout must be a whole number"* ]] || return 1
352
+ }
353
+
354
+ dim "--- Behaviour ---"
355
+
356
+ test "run completes successfully" test_run_that_completes_successfully
357
+ test "never adopts the previous run's success" test_never_adopts_the_previous_runs_success
358
+ test "waits for our run when only stale runs are listed" test_waits_for_our_run_when_only_stale_runs_are_listed
359
+ test "fails when no new run appears" test_fails_when_no_new_run_appears
360
+ test "reports the stuck run when the budget expires" test_reports_the_stuck_run_when_the_budget_expires
361
+ test "reports a failed run" test_reports_a_failed_run
362
+ test "reports a cancelled run as a failure" test_reports_a_cancelled_run_as_a_failure
363
+ test "survives an unreadable poll" test_survives_an_unreadable_poll
364
+ test "fails when the dispatch itself fails" test_fails_when_the_dispatch_itself_fails
365
+ test "falls back to creation time when the baseline listing fails" test_falls_back_to_creation_time_when_the_baseline_listing_fails
366
+ test "creation-time fallback still refuses a stale run" test_creation_time_fallback_still_refuses_a_stale_run
367
+ test "requires the tag argument" test_requires_the_tag_argument
368
+ test "rejects an unknown argument" test_rejects_an_unknown_argument
369
+ test "rejects a non-numeric timeout" test_rejects_a_non_numeric_timeout
370
+
371
+ # ---- summary ----------------------------------------------------------
372
+
373
+ echo ""
374
+ cyan "============================================"
375
+ if [ "$FAIL" -eq 0 ]; then
376
+ green " All $PASS tests passed"
377
+ else
378
+ red " $FAIL of $((PASS + FAIL)) tests failed"
379
+ fi
380
+ cyan "============================================"
381
+
382
+ [ "$FAIL" -eq 0 ]
@@ -0,0 +1,89 @@
1
+ #!/bin/bash
2
+ # ---------------------------------------------------------------------------
3
+ # wait-for-npm-version.sh -- wait until a published version is visible on npm
4
+ # ---------------------------------------------------------------------------
5
+ # Usage:
6
+ # wait-for-npm-version.sh <package> <version> [timeout-seconds] [poll-interval-seconds]
7
+ #
8
+ # Defaults: timeout 600 s, poll interval 15 s.
9
+ #
10
+ # `npm publish` exiting 0 only means the registry ACCEPTED the upload. npm now
11
+ # processes publishes asynchronously and answers with
12
+ # "Your package is being processed and may take a few minutes to become available."
13
+ # after which the new version stays invisible to `npm view` for an unpredictable
14
+ # while (observed on v4.13.18: still missing 41 s after a successful publish,
15
+ # i.e. past the whole 5 x 10 s window this script replaces). Verification must
16
+ # therefore wait minutes, not seconds -- otherwise a successful publish is
17
+ # reported as a failed release job, and everything gated on that job (the
18
+ # GitHub release) is skipped.
19
+ #
20
+ # Every answer that is not the expected version is retried:
21
+ # - 404 / "No match found for version" -> not published yet (expected, quiet)
22
+ # - any other error -> transient registry/network problem
23
+ # (logged, still retried)
24
+ #
25
+ # Exit codes:
26
+ # 0 the exact version is visible on the registry
27
+ # 1 still not visible when the timeout expires, or bad usage
28
+ #
29
+ # Designed for CI (GitHub Actions) but also runs on developer machines.
30
+ # ---------------------------------------------------------------------------
31
+ set -euo pipefail
32
+
33
+ PACKAGE="${1:?Usage: $0 <package> <version> [timeout-seconds] [poll-interval-seconds]}"
34
+ VERSION="${2:?Usage: $0 <package> <version> [timeout-seconds] [poll-interval-seconds]}"
35
+ TIMEOUT_SECONDS="${3:-600}"
36
+ POLL_INTERVAL_SECONDS="${4:-15}"
37
+
38
+ STDERR_FILE="$(mktemp)"
39
+ trap 'rm -f "$STDERR_FILE"' EXIT
40
+
41
+ started=$SECONDS
42
+ attempt=0
43
+ published=''
44
+ npm_error=''
45
+
46
+ while :; do
47
+ attempt=$((attempt + 1))
48
+
49
+ set +e
50
+ published="$(npm view "$PACKAGE@$VERSION" version 2>"$STDERR_FILE")"
51
+ status=$?
52
+ set -e
53
+
54
+ published="$(printf '%s' "$published" | tr -d '[:space:]')"
55
+ npm_error="$(head -n 1 "$STDERR_FILE" 2>/dev/null || true)"
56
+ elapsed=$((SECONDS - started))
57
+
58
+ if [ "$status" -eq 0 ] && [ "$published" = "$VERSION" ]; then
59
+ echo "Verified $PACKAGE@$VERSION on npm after ${elapsed}s (attempt $attempt)"
60
+ exit 0
61
+ fi
62
+
63
+ remaining=$((TIMEOUT_SECONDS - elapsed))
64
+ if [ "$remaining" -le 0 ]; then
65
+ break
66
+ fi
67
+
68
+ if [ "$status" -eq 0 ]; then
69
+ echo "WARN: npm reports '${published:-<empty>}' for $PACKAGE@$VERSION (attempt $attempt, ${elapsed}s elapsed, ${remaining}s left)"
70
+ elif printf '%s' "$npm_error" | grep -qE 'E404|404'; then
71
+ echo "Waiting for $PACKAGE@$VERSION to appear on npm (attempt $attempt, ${elapsed}s elapsed, ${remaining}s left) -- the registry may still be processing the publish"
72
+ else
73
+ echo "WARN: npm view failed (attempt $attempt, ${elapsed}s elapsed, ${remaining}s left): ${npm_error:-<no output>}"
74
+ fi
75
+
76
+ # never sleep past the deadline: the total wait stays within the budget
77
+ if [ "$POLL_INTERVAL_SECONDS" -gt "$remaining" ]; then
78
+ sleep "$remaining"
79
+ else
80
+ sleep "$POLL_INTERVAL_SECONDS"
81
+ fi
82
+ done
83
+
84
+ echo "ERROR: unable to verify $PACKAGE@$VERSION on npm within ${TIMEOUT_SECONDS}s after publish" >&2
85
+ echo "ERROR: last registry answer: ${npm_error:-<no output>}" >&2
86
+ echo "ERROR: the version may still be processing on the registry side; re-check with:" >&2
87
+ echo "ERROR: npm view $PACKAGE@$VERSION version" >&2
88
+ echo "ERROR: https://www.npmjs.com/package/$PACKAGE/v/$VERSION" >&2
89
+ exit 1