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,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
- python3 -m http.server "$TEST_PORT" --bind 127.0.0.1 > /dev/null 2>&1 &
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 be ready
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 -s -o /dev/null "$TEST_URL" 2>/dev/null; then
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
- $err = if (Test-Path $tmpErr) { Get-Content $tmpErr -Raw -ErrorAction SilentlyContinue; Remove-Item $tmpErr -Force -ErrorAction SilentlyContinue } else { "" }
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 rejected (replaced by -SkipIfInstalled)" {
221
- $ec5 = 0; $r5 = RunScript -scriptArgs "-Force -DryRun" -exitCode ([ref]$ec5)
222
- if ($r5.Error -notmatch 'Force') {
223
- throw "-Force should be rejected, got: $($r5.Error)"
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 ($r8.Error -notmatch 'Source|invalid|parameter') {
240
- throw "-Source invalid should be rejected, got: $($r8.Error)"
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 flag accepted" {
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
- $scriptContent = Get-Content $installScript -Raw
261
- $scriptContent = $scriptContent -replace '\r?\nMain\s*$', ''
262
- $scriptContent = $scriptContent -replace '\$ErrorActionPreference\s*=\s*"Stop"', ''
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 upgrade on empty status" {
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 "upgrade") { throw "Expected 'upgrade', got: $action" }
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