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.
- package/README.md +38 -21
- package/bin/browser4-cli-darwin-arm64 +0 -0
- package/bin/browser4-cli-darwin-x64 +0 -0
- package/bin/browser4-cli-linux-arm64 +0 -0
- package/bin/browser4-cli-linux-musl-arm64 +0 -0
- package/bin/browser4-cli-linux-musl-x64 +0 -0
- package/bin/browser4-cli-linux-x64 +0 -0
- package/bin/browser4-cli-win32-x64.exe +0 -0
- package/package.json +1 -1
- package/scripts/README.md +86 -1
- package/scripts/install-browser4-cli.ps1 +174 -46
- package/scripts/install-browser4-cli.sh +98 -28
- package/scripts/reconcile-release-assets.sh +293 -0
- package/scripts/smoke-test-runtime-bundle.sh +18 -3
- package/scripts/tests/install-browser4-cli.tests.ps1 +150 -15
- package/scripts/tests/install-browser4-cli.tests.sh +157 -10
- package/scripts/tests/reconcile-release-assets.tests.sh +421 -0
- package/scripts/tests/wait-for-npm-version.tests.sh +219 -0
- package/scripts/tests/wait-for-oss-sync.tests.sh +382 -0
- package/scripts/wait-for-npm-version.sh +89 -0
- package/scripts/wait-for-oss-sync.sh +314 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# ---------------------------------------------------------------------------
|
|
3
|
+
# reconcile-release-assets.sh -- make a GitHub release carry exactly the
|
|
4
|
+
# assets the build produced, retrying GitHub's transient upload failures
|
|
5
|
+
# ---------------------------------------------------------------------------
|
|
6
|
+
# Usage:
|
|
7
|
+
# reconcile-release-assets.sh --tag <tag> [--repo <owner/name>] \
|
|
8
|
+
# [--timeout <seconds>] [--interval <seconds>] [--settle <seconds>] \
|
|
9
|
+
# <file>...
|
|
10
|
+
# reconcile-release-assets.sh --tag <tag> [options] --files-from <path>
|
|
11
|
+
#
|
|
12
|
+
# Defaults: timeout 600 s, interval 30 s, settle 5 s.
|
|
13
|
+
#
|
|
14
|
+
# GitHub's release upload endpoint answers with a transient 5xx a few times a
|
|
15
|
+
# month: "Error creating asset temp dir", "Error saving asset", "Unicorn!".
|
|
16
|
+
# v4.14.0-rc.6 (release.yml run 35265014949) failed exactly this way -- the
|
|
17
|
+
# "Publish GitHub release" job uploaded 6 of 11 assets and then died on
|
|
18
|
+
# ##[error]Error creating asset temp dir
|
|
19
|
+
# which skipped the artifact attestation, the release verification and the
|
|
20
|
+
# OSS sync: the whole release pipeline was lost to a server-side blip, with a
|
|
21
|
+
# half-populated GitHub release left behind.
|
|
22
|
+
#
|
|
23
|
+
# GitHub does not say which upload failed, and a failed upload can leave a
|
|
24
|
+
# partial asset behind, so re-running the whole upload is both slow and not
|
|
25
|
+
# obviously correct. This script instead treats the release as the source of
|
|
26
|
+
# truth and converges on it:
|
|
27
|
+
#
|
|
28
|
+
# 1. list the assets the release already carries (name and size),
|
|
29
|
+
# 2. upload every local file that is missing or whose size differs, using
|
|
30
|
+
# `gh release upload --clobber` (which also replaces a partial asset),
|
|
31
|
+
# 3. re-check, and repeat until the release matches the local files or the
|
|
32
|
+
# time budget runs out.
|
|
33
|
+
#
|
|
34
|
+
# It is idempotent: a release that already matches its files costs one API
|
|
35
|
+
# call and no upload. Assets are matched by file name, which is the name the
|
|
36
|
+
# publishing action uses for them. A release that does not exist is reported
|
|
37
|
+
# as a failure rather than recreated here -- that is the release-creation
|
|
38
|
+
# path, which the publishing action creates (and retries) with the workflow's
|
|
39
|
+
# metadata.
|
|
40
|
+
#
|
|
41
|
+
# Exit codes:
|
|
42
|
+
# 0 the release carries every given file with the local byte size
|
|
43
|
+
# 1 still missing/mismatched when the budget expired, the release does not
|
|
44
|
+
# exist, a given file does not exist, or bad usage
|
|
45
|
+
#
|
|
46
|
+
# Designed for CI (GitHub Actions) but also runs on developer machines.
|
|
47
|
+
# Requires `gh`, authenticated via GH_TOKEN/GITHUB_TOKEN or `gh auth login`.
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
set -euo pipefail
|
|
50
|
+
|
|
51
|
+
TAG=''
|
|
52
|
+
REPO=''
|
|
53
|
+
TIMEOUT_SECONDS=600
|
|
54
|
+
POLL_INTERVAL_SECONDS=30
|
|
55
|
+
SETTLE_SECONDS=5
|
|
56
|
+
FILES_FROM=''
|
|
57
|
+
FILES=()
|
|
58
|
+
|
|
59
|
+
usage() {
|
|
60
|
+
local code="${1:-1}"
|
|
61
|
+
{
|
|
62
|
+
echo "Usage: $0 --tag <tag> [--repo <owner/name>] [--timeout <seconds>]"
|
|
63
|
+
echo " [--interval <seconds>] [--settle <seconds>]"
|
|
64
|
+
echo " [--files-from <path>] <file>..."
|
|
65
|
+
} >&2
|
|
66
|
+
exit "$code"
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
while [ $# -gt 0 ]; do
|
|
70
|
+
case "${1:-}" in
|
|
71
|
+
--tag)
|
|
72
|
+
TAG="${2:-}"
|
|
73
|
+
shift 2 || usage
|
|
74
|
+
;;
|
|
75
|
+
--repo)
|
|
76
|
+
REPO="${2:-}"
|
|
77
|
+
shift 2 || usage
|
|
78
|
+
;;
|
|
79
|
+
--timeout)
|
|
80
|
+
TIMEOUT_SECONDS="${2:-}"
|
|
81
|
+
shift 2 || usage
|
|
82
|
+
;;
|
|
83
|
+
--interval)
|
|
84
|
+
POLL_INTERVAL_SECONDS="${2:-}"
|
|
85
|
+
shift 2 || usage
|
|
86
|
+
;;
|
|
87
|
+
--settle)
|
|
88
|
+
SETTLE_SECONDS="${2:-}"
|
|
89
|
+
shift 2 || usage
|
|
90
|
+
;;
|
|
91
|
+
--files-from)
|
|
92
|
+
FILES_FROM="${2:-}"
|
|
93
|
+
shift 2 || usage
|
|
94
|
+
;;
|
|
95
|
+
-h|--help)
|
|
96
|
+
usage 0
|
|
97
|
+
;;
|
|
98
|
+
--)
|
|
99
|
+
shift
|
|
100
|
+
while [ $# -gt 0 ]; do
|
|
101
|
+
FILES+=("$1")
|
|
102
|
+
shift
|
|
103
|
+
done
|
|
104
|
+
;;
|
|
105
|
+
-*)
|
|
106
|
+
echo "ERROR: unknown option '$1'" >&2
|
|
107
|
+
usage
|
|
108
|
+
;;
|
|
109
|
+
*)
|
|
110
|
+
FILES+=("$1")
|
|
111
|
+
shift
|
|
112
|
+
;;
|
|
113
|
+
esac
|
|
114
|
+
done
|
|
115
|
+
|
|
116
|
+
[ -n "$TAG" ] || { echo "ERROR: --tag is required" >&2; usage; }
|
|
117
|
+
case "$TIMEOUT_SECONDS" in ''|*[!0-9]*) echo "ERROR: --timeout must be a whole number of seconds" >&2; usage ;; esac
|
|
118
|
+
case "$POLL_INTERVAL_SECONDS" in ''|*[!0-9]*) echo "ERROR: --interval must be a whole number of seconds" >&2; usage ;; esac
|
|
119
|
+
case "$SETTLE_SECONDS" in ''|*[!0-9]*) echo "ERROR: --settle must be a whole number of seconds" >&2; usage ;; esac
|
|
120
|
+
[ "$POLL_INTERVAL_SECONDS" -gt 0 ] || { echo "ERROR: --interval must be at least 1 s" >&2; usage; }
|
|
121
|
+
|
|
122
|
+
if [ -n "$FILES_FROM" ]; then
|
|
123
|
+
if [ ! -f "$FILES_FROM" ]; then
|
|
124
|
+
echo "ERROR: --files-from '$FILES_FROM' does not exist" >&2
|
|
125
|
+
exit 1
|
|
126
|
+
fi
|
|
127
|
+
# Newline-separated paths: the shape the publishing action's `files` input
|
|
128
|
+
# and this repository's release asset manifest already use.
|
|
129
|
+
while IFS= read -r manifest_line || [ -n "$manifest_line" ]; do
|
|
130
|
+
manifest_line="${manifest_line%$'\r'}"
|
|
131
|
+
[ -n "$manifest_line" ] || continue
|
|
132
|
+
FILES+=("$manifest_line")
|
|
133
|
+
done < "$FILES_FROM"
|
|
134
|
+
fi
|
|
135
|
+
|
|
136
|
+
if [ "${#FILES[@]}" -eq 0 ]; then
|
|
137
|
+
echo "ERROR: no asset files given" >&2
|
|
138
|
+
usage
|
|
139
|
+
fi
|
|
140
|
+
|
|
141
|
+
for f in "${FILES[@]}"; do
|
|
142
|
+
if [ -z "$f" ]; then
|
|
143
|
+
echo "ERROR: empty asset file argument (is the asset list a real path list?)" >&2
|
|
144
|
+
exit 1
|
|
145
|
+
fi
|
|
146
|
+
if [ ! -f "$f" ]; then
|
|
147
|
+
echo "ERROR: asset file '$f' does not exist" >&2
|
|
148
|
+
exit 1
|
|
149
|
+
fi
|
|
150
|
+
done
|
|
151
|
+
|
|
152
|
+
if ! command -v gh >/dev/null 2>&1; then
|
|
153
|
+
echo "ERROR: gh is not installed -- https://cli.github.com" >&2
|
|
154
|
+
exit 1
|
|
155
|
+
fi
|
|
156
|
+
|
|
157
|
+
STDERR_FILE="$(mktemp)"
|
|
158
|
+
trap 'rm -f "$STDERR_FILE"' EXIT
|
|
159
|
+
|
|
160
|
+
# --repo is appended to the subcommand (gh declares it per command), so the two
|
|
161
|
+
# call sites below branch instead of building an argument array that `set -u`
|
|
162
|
+
# and old bash versions disagree about while it is empty.
|
|
163
|
+
list_release_assets() {
|
|
164
|
+
if [ -n "$REPO" ]; then
|
|
165
|
+
gh release view "$TAG" --repo "$REPO" --json assets --jq '.assets[] | "\(.name) \(.size)"'
|
|
166
|
+
else
|
|
167
|
+
gh release view "$TAG" --json assets --jq '.assets[] | "\(.name) \(.size)"'
|
|
168
|
+
fi
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
upload_assets() {
|
|
172
|
+
if [ -n "$REPO" ]; then
|
|
173
|
+
gh release upload "$TAG" "$@" --repo "$REPO" --clobber
|
|
174
|
+
else
|
|
175
|
+
gh release upload "$TAG" "$@" --clobber
|
|
176
|
+
fi
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
local_size_of() {
|
|
180
|
+
wc -c < "$1" | tr -d '[:space:]'
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
# remote_size_of <name> -- size the release reports for <name>; returns 1 when
|
|
184
|
+
# the release does not carry that asset. Asset names may contain spaces, so the
|
|
185
|
+
# size is read as the last field of "name size".
|
|
186
|
+
remote_size_of() {
|
|
187
|
+
local wanted="$1" line name size
|
|
188
|
+
while IFS= read -r line; do
|
|
189
|
+
[ -n "$line" ] || continue
|
|
190
|
+
name="${line% *}"
|
|
191
|
+
size="${line##* }"
|
|
192
|
+
if [ "$name" = "$wanted" ]; then
|
|
193
|
+
printf '%s' "$size"
|
|
194
|
+
return 0
|
|
195
|
+
fi
|
|
196
|
+
done <<< "$remote_listing"
|
|
197
|
+
return 1
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
started=$SECONDS
|
|
201
|
+
attempt=0
|
|
202
|
+
remote_listing=''
|
|
203
|
+
gh_error=''
|
|
204
|
+
|
|
205
|
+
while :; do
|
|
206
|
+
attempt=$((attempt + 1))
|
|
207
|
+
upload_status=1
|
|
208
|
+
|
|
209
|
+
set +e
|
|
210
|
+
remote_listing="$(list_release_assets 2>"$STDERR_FILE")"
|
|
211
|
+
status=$?
|
|
212
|
+
set -e
|
|
213
|
+
|
|
214
|
+
gh_error="$(head -n 1 "$STDERR_FILE" 2>/dev/null || true)"
|
|
215
|
+
elapsed=$((SECONDS - started))
|
|
216
|
+
|
|
217
|
+
if [ "$status" -eq 0 ]; then
|
|
218
|
+
NEEDED=()
|
|
219
|
+
REASONS=()
|
|
220
|
+
for f in "${FILES[@]}"; do
|
|
221
|
+
name="${f##*/}"
|
|
222
|
+
local_size="$(local_size_of "$f")"
|
|
223
|
+
if remote_size="$(remote_size_of "$name")"; then
|
|
224
|
+
if [ "$remote_size" = "$local_size" ]; then
|
|
225
|
+
continue
|
|
226
|
+
fi
|
|
227
|
+
reason="size mismatch: the release has $remote_size bytes, the file has $local_size"
|
|
228
|
+
else
|
|
229
|
+
reason="missing from the release"
|
|
230
|
+
fi
|
|
231
|
+
NEEDED+=("$f")
|
|
232
|
+
REASONS+=("$reason")
|
|
233
|
+
done
|
|
234
|
+
|
|
235
|
+
if [ "${#NEEDED[@]}" -eq 0 ]; then
|
|
236
|
+
echo "Verified ${#FILES[@]} asset(s) on release $TAG after ${elapsed}s (attempt $attempt)"
|
|
237
|
+
exit 0
|
|
238
|
+
fi
|
|
239
|
+
|
|
240
|
+
echo "Uploading ${#NEEDED[@]} of ${#FILES[@]} asset(s) to release $TAG (attempt $attempt, ${elapsed}s elapsed):"
|
|
241
|
+
for i in "${!NEEDED[@]}"; do
|
|
242
|
+
echo " - ${NEEDED[$i]##*/}: ${REASONS[$i]}"
|
|
243
|
+
done
|
|
244
|
+
|
|
245
|
+
set +e
|
|
246
|
+
upload_assets "${NEEDED[@]}" 2>&1 | sed 's/^/ /'
|
|
247
|
+
upload_status="${PIPESTATUS[0]}"
|
|
248
|
+
set -e
|
|
249
|
+
|
|
250
|
+
if [ "$upload_status" -ne 0 ]; then
|
|
251
|
+
echo "WARN: gh release upload failed (exit $upload_status) -- GitHub's upload endpoint returns a transient 5xx a few times a month ('Error creating asset temp dir', 'Error saving asset', 'Unicorn!'); retrying"
|
|
252
|
+
fi
|
|
253
|
+
else
|
|
254
|
+
if printf '%s' "$gh_error" | grep -qiE 'not found|HTTP 404'; then
|
|
255
|
+
echo "ERROR: release $TAG does not exist -- the publishing action failed before it could create the release" >&2
|
|
256
|
+
echo "ERROR: last GitHub answer: ${gh_error:-<no output>}" >&2
|
|
257
|
+
exit 1
|
|
258
|
+
fi
|
|
259
|
+
echo "WARN: could not list the assets of release $TAG (attempt $attempt, ${elapsed}s elapsed): ${gh_error:-<no output>}"
|
|
260
|
+
fi
|
|
261
|
+
|
|
262
|
+
remaining=$((TIMEOUT_SECONDS - elapsed))
|
|
263
|
+
if [ "$remaining" -le 0 ]; then
|
|
264
|
+
break
|
|
265
|
+
fi
|
|
266
|
+
|
|
267
|
+
# A finished upload round can stay invisible to the listing API for a
|
|
268
|
+
# moment; a short settle avoids re-uploading a file the release already
|
|
269
|
+
# has. A failed round waits the full interval before trying again.
|
|
270
|
+
if [ "$status" -eq 0 ] && [ "$upload_status" -eq 0 ]; then
|
|
271
|
+
delay="$SETTLE_SECONDS"
|
|
272
|
+
else
|
|
273
|
+
delay="$POLL_INTERVAL_SECONDS"
|
|
274
|
+
fi
|
|
275
|
+
# never sleep past the deadline: the total wait stays within the budget
|
|
276
|
+
if [ "$delay" -gt "$remaining" ]; then
|
|
277
|
+
sleep "$remaining"
|
|
278
|
+
elif [ "$delay" -gt 0 ]; then
|
|
279
|
+
sleep "$delay"
|
|
280
|
+
fi
|
|
281
|
+
done
|
|
282
|
+
|
|
283
|
+
echo "ERROR: release $TAG still does not match the build output after ${TIMEOUT_SECONDS}s" >&2
|
|
284
|
+
for i in "${!NEEDED[@]}"; do
|
|
285
|
+
echo "ERROR: - ${NEEDED[$i]##*/}: ${REASONS[$i]}" >&2
|
|
286
|
+
done
|
|
287
|
+
if [ -n "$gh_error" ]; then
|
|
288
|
+
echo "ERROR: last GitHub answer: ${gh_error}" >&2
|
|
289
|
+
fi
|
|
290
|
+
echo "ERROR: GitHub's release upload endpoint returns transient 5xx responses" >&2
|
|
291
|
+
echo "ERROR: ('Error creating asset temp dir', 'Error saving asset', 'Unicorn!')." >&2
|
|
292
|
+
echo "ERROR: Re-run the failed jobs of this workflow to retry the upload." >&2
|
|
293
|
+
exit 1
|
|
@@ -174,17 +174,32 @@ cat > "$TEST_PAGE" <<'HTML'
|
|
|
174
174
|
</body></html>
|
|
175
175
|
HTML
|
|
176
176
|
|
|
177
|
-
|
|
177
|
+
# Serve the fixture from $TEMP_DIR: without --directory, python http.server
|
|
178
|
+
# serves the *current working directory* (the CI workspace / repo checkout),
|
|
179
|
+
# so every run browsed Python's 404 "Error response" page instead of the
|
|
180
|
+
# fixture. Backends that tolerate type/click on a missing element let that
|
|
181
|
+
# pass silently; the loud "no element found for selector [#input1]" failure
|
|
182
|
+
# exposed the harness bug on all three platforms.
|
|
183
|
+
python3 -m http.server "$TEST_PORT" --bind 127.0.0.1 --directory "$TEMP_DIR" > /dev/null 2>&1 &
|
|
178
184
|
HTTP_PID=$!
|
|
179
185
|
TEST_URL="http://127.0.0.1:$TEST_PORT/test.html"
|
|
180
186
|
|
|
181
|
-
# Wait for HTTP server to
|
|
187
|
+
# Wait for the HTTP server to actually serve the fixture. curl -f fails on
|
|
188
|
+
# 4xx/5xx, so a server serving the wrong directory (or not yet ready) cannot
|
|
189
|
+
# silently hand the browser an error page — earlier runs typed into a 404
|
|
190
|
+
# page whenever the interactive commands tolerated a missing element.
|
|
191
|
+
SERVING_OK=""
|
|
182
192
|
for i in $(seq 1 10); do
|
|
183
|
-
if curl -
|
|
193
|
+
if curl -sf -o /dev/null "$TEST_URL" 2>/dev/null; then
|
|
194
|
+
SERVING_OK=1
|
|
184
195
|
break
|
|
185
196
|
fi
|
|
186
197
|
sleep 0.5
|
|
187
198
|
done
|
|
199
|
+
if [ -z "$SERVING_OK" ]; then
|
|
200
|
+
info "FATAL: test page not served at $TEST_URL (python http.server not serving $TEMP_DIR?)"
|
|
201
|
+
exit 1
|
|
202
|
+
fi
|
|
188
203
|
|
|
189
204
|
# ---------------------------------------------------------------------------
|
|
190
205
|
# step 3: determine server URL (let CLI auto-start the server)
|
|
@@ -37,6 +37,21 @@ function Test($name, [ScriptBlock]$block) {
|
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
# ── Harness self-test ──
|
|
41
|
+
# An assertion that throws MUST be counted as a failure. Without this check a
|
|
42
|
+
# harness that silently swallows failures looks perfectly green, which is
|
|
43
|
+
# exactly how this suite's PowerShell sibling (the bash one) stayed green while
|
|
44
|
+
# running no assertions at all.
|
|
45
|
+
$selfTestFail = $script:fail
|
|
46
|
+
Write-Host " (one deliberate FAIL follows -- the harness self-test)" -ForegroundColor DarkGray
|
|
47
|
+
Test "__harness self-test (must be reported as a failure)" { throw "expected failure" }
|
|
48
|
+
if ($script:fail -ne ($selfTestFail + 1)) {
|
|
49
|
+
Write-Host " FATAL the harness does not record failures -- aborting" -ForegroundColor Red
|
|
50
|
+
exit 1
|
|
51
|
+
}
|
|
52
|
+
$script:fail = $selfTestFail
|
|
53
|
+
Write-Host " OK harness self-test (a throwing assertion is counted as a failure)" -ForegroundColor DarkGray
|
|
54
|
+
|
|
40
55
|
function RunScript([string]$scriptArgs, [ref]$exitCode) {
|
|
41
56
|
# Use -Command to control stderr redirect (2> path must come before script args,
|
|
42
57
|
# which -File would pass as literal arguments to the script).
|
|
@@ -52,7 +67,15 @@ function RunScript([string]$scriptArgs, [ref]$exitCode) {
|
|
|
52
67
|
$out = $proc.StandardOutput.ReadToEnd()
|
|
53
68
|
$proc.WaitForExit()
|
|
54
69
|
$exitCode.Value = $proc.ExitCode
|
|
55
|
-
|
|
70
|
+
# Never leave $err as AutomationNull: `$null -eq $err` is true for it, but
|
|
71
|
+
# `$err -notmatch 'x'` returns an empty array, which is falsey -- an
|
|
72
|
+
# assertion written that way can never fail.
|
|
73
|
+
$err = ""
|
|
74
|
+
if (Test-Path $tmpErr) {
|
|
75
|
+
$err = Get-Content $tmpErr -Raw -ErrorAction SilentlyContinue
|
|
76
|
+
Remove-Item $tmpErr -Force -ErrorAction SilentlyContinue
|
|
77
|
+
}
|
|
78
|
+
if ($null -eq $err) { $err = "" }
|
|
56
79
|
return [PSCustomObject]@{ Output = $out; Error = $err; ExitCode = $proc.ExitCode }
|
|
57
80
|
}
|
|
58
81
|
|
|
@@ -217,10 +240,24 @@ Test "-SkipLocal flag accepted" {
|
|
|
217
240
|
if ($ec4 -ne 0) { throw "Exit code: $ec4, output: $($r4.Output)" }
|
|
218
241
|
}
|
|
219
242
|
|
|
220
|
-
Test "-Force
|
|
221
|
-
$
|
|
222
|
-
|
|
223
|
-
|
|
243
|
+
Test "-Force overrides -SkipIfInstalled, and is a live parameter" {
|
|
244
|
+
$tmp = Join-Path ([System.IO.Path]::GetTempPath()) "b4-force-$([System.IO.Path]::GetRandomFileName())"
|
|
245
|
+
New-Item -ItemType Directory -Path $tmp -Force | Out-Null
|
|
246
|
+
foreach ($n in @('browser4-cli-win32-x64.exe', 'browser4-cli-linux-x64', 'browser4-cli-darwin-arm64')) {
|
|
247
|
+
Set-Content -Path (Join-Path $tmp $n) -Value "dummy" -Encoding Ascii
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
$ecA = 0; $rA = RunScript -scriptArgs "-SkipIfInstalled -DryRun -SkipBackend -AddToPath:`$false -InstallDir `"$tmp`"" -exitCode ([ref]$ecA)
|
|
251
|
+
if ($rA.Output -notmatch 'Binary already installed') {
|
|
252
|
+
throw "Expected the -SkipIfInstalled skip path, got: $($rA.Output)"
|
|
253
|
+
}
|
|
254
|
+
$ecB = 0; $rB = RunScript -scriptArgs "-SkipIfInstalled -Force -DryRun -SkipBackend -AddToPath:`$false -InstallDir `"$tmp`"" -exitCode ([ref]$ecB)
|
|
255
|
+
if ($ecB -ne 0) { throw "-Force must be accepted, exit code: $ecB" }
|
|
256
|
+
if ($rB.Output -match 'Binary already installed') {
|
|
257
|
+
throw "-Force must override -SkipIfInstalled, got: $($rB.Output)"
|
|
258
|
+
}
|
|
259
|
+
} finally {
|
|
260
|
+
Remove-Item $tmp -Recurse -Force -ErrorAction SilentlyContinue
|
|
224
261
|
}
|
|
225
262
|
}
|
|
226
263
|
|
|
@@ -236,8 +273,8 @@ Test "-Source github accepted" {
|
|
|
236
273
|
|
|
237
274
|
Test "-Source invalid rejected" {
|
|
238
275
|
$ec8 = 0; $r8 = RunScript -scriptArgs "-Source invalid -DryRun" -exitCode ([ref]$ec8)
|
|
239
|
-
if ($
|
|
240
|
-
throw "-Source invalid
|
|
276
|
+
if ($ec8 -eq 0) {
|
|
277
|
+
throw "-Source invalid must fail, output: $($r8.Output)"
|
|
241
278
|
}
|
|
242
279
|
}
|
|
243
280
|
|
|
@@ -246,9 +283,33 @@ Test "-Silent flag accepted" {
|
|
|
246
283
|
if ($ec9 -ne 0) { throw "Exit code: $ec9, output: $($r9.Output)" }
|
|
247
284
|
}
|
|
248
285
|
|
|
249
|
-
Test "-Version
|
|
250
|
-
$ec10 = 0; $r10 = RunScript -scriptArgs "-Version v4.11.0 -DryRun" -exitCode ([ref]$ec10)
|
|
286
|
+
Test "-Version reaches the download URLs" {
|
|
287
|
+
$ec10 = 0; $r10 = RunScript -scriptArgs "-Version v4.11.0 -DryRun -AddToPath:`$false" -exitCode ([ref]$ec10)
|
|
251
288
|
if ($ec10 -ne 0) { throw "Exit code: $ec10, output: $($r10.Output)" }
|
|
289
|
+
if ($r10.Output -notmatch 'releases/download/v4\.11\.0/') {
|
|
290
|
+
throw "The tag never reached the URLs: $($r10.Output)"
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
Test "-Version accepts a bare semver and prefixes it with v" {
|
|
295
|
+
$ec11 = 0; $r11 = RunScript -scriptArgs "-Version 4.11.0 -DryRun -AddToPath:`$false" -exitCode ([ref]$ec11)
|
|
296
|
+
if ($ec11 -ne 0) { throw "Exit code: $ec11, output: $($r11.Output)" }
|
|
297
|
+
if ($r11.Output -notmatch 'releases/download/v4\.11\.0/') {
|
|
298
|
+
throw "Bare semver was not normalised to v4.11.0: $($r11.Output)"
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
Test "-Version rejects a path-traversal tag" {
|
|
303
|
+
$ec12 = 0; $r12 = RunScript -scriptArgs "-Version ../../evil -DryRun" -exitCode ([ref]$ec12)
|
|
304
|
+
if ($ec12 -eq 0) { throw "Traversal tag must be rejected, output: $($r12.Output)" }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
Test "-DryRun does not probe the installed CLI" {
|
|
308
|
+
$ec13 = 0; $r13 = RunScript -scriptArgs "-DryRun -AddToPath:`$false" -exitCode ([ref]$ec13)
|
|
309
|
+
if ($ec13 -ne 0) { throw "Exit code: $ec13, output: $($r13.Output)" }
|
|
310
|
+
if ($r13.Output -match 'Backend already installed') {
|
|
311
|
+
throw "-DryRun ran 'status' on the installed CLI: $($r13.Output)"
|
|
312
|
+
}
|
|
252
313
|
}
|
|
253
314
|
|
|
254
315
|
Write-Host ""
|
|
@@ -256,10 +317,25 @@ Write-Host ""
|
|
|
256
317
|
# ── Functions via dot-source ──
|
|
257
318
|
Write-Host "--- Functions ---" -ForegroundColor Cyan
|
|
258
319
|
|
|
259
|
-
# Strip trailing Main call and dot-source for function-level tests
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
320
|
+
# Strip the trailing `Main` call and dot-source for function-level tests.
|
|
321
|
+
# Strip it by AST extent, never by regex: if a line is ever appended after
|
|
322
|
+
# `Main`, a regex silently stops matching and this suite would perform a REAL
|
|
323
|
+
# install and edit the user PATH.
|
|
324
|
+
$installAst = [System.Management.Automation.Language.Parser]::ParseFile($installScript, [ref]$null, [ref]$null)
|
|
325
|
+
$mainCall = @($installAst.FindAll({
|
|
326
|
+
param($node)
|
|
327
|
+
$node -is [System.Management.Automation.Language.CommandAst] -and
|
|
328
|
+
$node.GetCommandName() -eq 'Main' -and
|
|
329
|
+
$node.Extent.Text.Trim() -eq 'Main'
|
|
330
|
+
}, $true)) | Select-Object -Last 1
|
|
331
|
+
if (-not $mainCall) {
|
|
332
|
+
throw "Could not locate the top-level 'Main' invocation in $installScript -- refusing to dot-source the installer"
|
|
333
|
+
}
|
|
334
|
+
$allLines = Get-Content $installScript
|
|
335
|
+
$scriptContent = ($allLines[0..($mainCall.Extent.StartLineNumber - 2)] -join "`n") -replace '\$ErrorActionPreference\s*=\s*"Stop"', ''
|
|
336
|
+
if ($scriptContent -match '(?m)^\s*Main\s*$') {
|
|
337
|
+
throw "The stripped copy still invokes Main -- refusing to dot-source the installer"
|
|
338
|
+
}
|
|
263
339
|
$sb = [ScriptBlock]::Create($scriptContent)
|
|
264
340
|
|
|
265
341
|
& {
|
|
@@ -313,6 +389,31 @@ $sb = [ScriptBlock]::Create($scriptContent)
|
|
|
313
389
|
if ($result -ne $null) { throw "Expected null, got: $result" }
|
|
314
390
|
}
|
|
315
391
|
|
|
392
|
+
Test "Find-LocalBinary finds a bundled binary and enforces the 100 KB gate" {
|
|
393
|
+
# The bundled-binary path is what a sideload install uses; it used to be
|
|
394
|
+
# untestable because the dot-sourced copy never had a usable ScriptDir.
|
|
395
|
+
$tmp = Join-Path ([System.IO.Path]::GetTempPath()) "b4-local-$([System.IO.Path]::GetRandomFileName())"
|
|
396
|
+
New-Item -ItemType Directory -Path $tmp -Force | Out-Null
|
|
397
|
+
try {
|
|
398
|
+
$name = Get-BinaryName -PlatformKey (Get-PlatformKey)
|
|
399
|
+
[System.IO.File]::WriteAllBytes((Join-Path $tmp $name), (New-Object byte[] 204800))
|
|
400
|
+
$savedScriptDir = $ScriptDir
|
|
401
|
+
try {
|
|
402
|
+
$ScriptDir = $tmp
|
|
403
|
+
$found = Find-LocalBinary -BinaryName $name
|
|
404
|
+
if (-not $found) { throw "A 200 KB bundled binary was not found in $tmp" }
|
|
405
|
+
[System.IO.File]::WriteAllBytes((Join-Path $tmp 'browser4-cli-too-small'), (New-Object byte[] 10))
|
|
406
|
+
if (Find-LocalBinary -BinaryName 'browser4-cli-too-small') {
|
|
407
|
+
throw "A 10-byte file passed the 100 KB gate"
|
|
408
|
+
}
|
|
409
|
+
} finally {
|
|
410
|
+
$ScriptDir = $savedScriptDir
|
|
411
|
+
}
|
|
412
|
+
} finally {
|
|
413
|
+
Remove-Item $tmp -Recurse -Force -ErrorAction SilentlyContinue
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
316
417
|
Test "Test-LocalBinary returns false for empty string" {
|
|
317
418
|
if (Test-LocalBinary -Path "") { throw "Should be false" }
|
|
318
419
|
}
|
|
@@ -331,9 +432,43 @@ $sb = [ScriptBlock]::Create($scriptContent)
|
|
|
331
432
|
if ($action -ne "upgrade") { throw "Expected 'upgrade', got: $action" }
|
|
332
433
|
}
|
|
333
434
|
|
|
334
|
-
Test "Get-BackendAction defaults to
|
|
435
|
+
Test "Get-BackendAction defaults to install on empty status" {
|
|
436
|
+
# An empty status means "no CLI answer" (fresh machine, or `status`
|
|
437
|
+
# failed). Defaulting to upgrade would run `upgrade` on a machine that
|
|
438
|
+
# has nothing to upgrade.
|
|
335
439
|
$action = Get-BackendAction -StatusOutput ""
|
|
336
|
-
if ($action -ne "
|
|
440
|
+
if ($action -ne "install") { throw "Expected 'install', got: $action" }
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
Test "Test-BinaryMagic accepts ELF, PE and Mach-O" {
|
|
444
|
+
$tmp = [System.IO.Path]::GetTempFileName()
|
|
445
|
+
try {
|
|
446
|
+
$cases = @{
|
|
447
|
+
'elf' = [byte[]]@(0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01)
|
|
448
|
+
'pe' = [byte[]]@(0x4d, 0x5a, 0x90, 0x00, 0x03)
|
|
449
|
+
'macho' = [byte[]]@(0xcf, 0xfa, 0xed, 0xfe, 0x07)
|
|
450
|
+
}
|
|
451
|
+
foreach ($name in $cases.Keys) {
|
|
452
|
+
[System.IO.File]::WriteAllBytes($tmp, $cases[$name])
|
|
453
|
+
if (-not (Test-BinaryMagic -Path $tmp)) { throw "Rejected a valid $name header" }
|
|
454
|
+
}
|
|
455
|
+
} finally {
|
|
456
|
+
Remove-Item $tmp -Force -ErrorAction SilentlyContinue
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
Test "Test-BinaryMagic rejects an HTML error page" {
|
|
461
|
+
$tmp = [System.IO.Path]::GetTempFileName()
|
|
462
|
+
try {
|
|
463
|
+
[System.IO.File]::WriteAllText($tmp, "<html><body>404 Not Found</body></html>")
|
|
464
|
+
if (Test-BinaryMagic -Path $tmp) { throw "An HTML body was accepted as a binary" }
|
|
465
|
+
} finally {
|
|
466
|
+
Remove-Item $tmp -Force -ErrorAction SilentlyContinue
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
Test "Test-BinaryMagic rejects a missing file" {
|
|
471
|
+
if (Test-BinaryMagic -Path "/nonexistent/binary-xyz") { throw "A missing file was accepted" }
|
|
337
472
|
}
|
|
338
473
|
}
|
|
339
474
|
|