browser4-cli 4.12.0 → 4.12.2

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.
@@ -1,462 +1,476 @@
1
- #!/usr/bin/env pwsh
2
-
3
- # ═══════════════════════════════════════════════════════════════════
4
- # CROSS-PLATFORM: This script must run on Linux, macOS, and Windows.
5
- # - Use $IsWindows / $IsLinux / $IsMacOS for platform detection.
6
- # - Use "($IsWindows -or $env:OS -eq 'Windows_NT')" for PS 5.1 compat.
7
- # - Avoid Windows-only env vars ($env:TEMP) — use $env:TMPDIR fallback.
8
- # - Guard "chcp" and other Windows-only commands behind platform checks.
9
- # - Paths: use Join-Path / Split-Path; never bake \ or / as literal.
10
- # - [System.IO.Path]::IsPathRooted is platform-aware — C:\foo is NOT
11
- # rooted on Linux; test with platform-appropriate absolute paths.
12
- # ═══════════════════════════════════════════════════════════════════
13
-
14
- <#
15
- .SYNOPSIS
16
- Tests for install-browser4-cli.ps1
17
- PowerShell 5.1+ only — zero external dependencies.
18
- Run: powershell -NoProfile -ExecutionPolicy Bypass -File install-browser4-cli.tests.ps1
19
- #>
20
-
21
- $ErrorActionPreference = "Stop"
22
- $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
23
- $installScript = Join-Path (Split-Path $scriptDir -Parent) "install-browser4-cli.ps1"
24
-
25
- $pass = 0
26
- $fail = 0
27
-
28
- function Test($name, [ScriptBlock]$block) {
29
- try {
30
- $null = & $block
31
- $script:pass++
32
- Write-Host " PASS $name" -ForegroundColor Green
33
- } catch {
34
- $script:fail++
35
- Write-Host " FAIL $name" -ForegroundColor Red
36
- Write-Host " $($_.Exception.Message)" -ForegroundColor Red
37
- }
38
- }
39
-
40
- function RunScript([string]$scriptArgs, [ref]$exitCode) {
41
- # Use -Command to control stderr redirect (2> path must come before script args,
42
- # which -File would pass as literal arguments to the script).
43
- $tmpErr = [System.IO.Path]::GetTempFileName()
44
- $cmd = "& '$installScript' $scriptArgs 2>'$tmpErr'"
45
- $psi = New-Object System.Diagnostics.ProcessStartInfo
46
- $psi.FileName = if ($IsWindows -or ($env:OS -eq 'Windows_NT')) { "powershell.exe" } else { "pwsh" }
47
- $psi.Arguments = "-NoProfile -ExecutionPolicy Bypass -Command `"$cmd`""
48
- $psi.RedirectStandardOutput = $true
49
- $psi.UseShellExecute = $false
50
- $psi.CreateNoWindow = $true
51
- $proc = [System.Diagnostics.Process]::Start($psi)
52
- $out = $proc.StandardOutput.ReadToEnd()
53
- $proc.WaitForExit()
54
- $exitCode.Value = $proc.ExitCode
55
- $err = if (Test-Path $tmpErr) { Get-Content $tmpErr -Raw -ErrorAction SilentlyContinue; Remove-Item $tmpErr -Force -ErrorAction SilentlyContinue } else { "" }
56
- return [PSCustomObject]@{ Output = $out; Error = $err; ExitCode = $proc.ExitCode }
57
- }
58
-
59
- Write-Host "============================================" -ForegroundColor Cyan
60
- Write-Host " install-browser4-cli.ps1 Test Suite" -ForegroundColor Cyan
61
- Write-Host "============================================" -ForegroundColor Cyan
62
- Write-Host ""
63
-
64
- # ── Pre-flight ──
65
- Write-Host "--- Pre-flight ---" -ForegroundColor Cyan
66
-
67
- Test "file exists" {
68
- if (-not (Test-Path $installScript)) { throw "Not found: $installScript" }
69
- }
70
-
71
- Test "file is readable" {
72
- $null = Get-Content $installScript -Raw -ErrorAction Stop
73
- }
74
-
75
- Test "no non-ASCII bytes" {
76
- $bytes = [System.IO.File]::ReadAllBytes($installScript)
77
- $nonAscii = @($bytes | Where-Object { $_ -gt 127 })
78
- if ($nonAscii.Count -gt 0) {
79
- throw "Found $($nonAscii.Count) non-ASCII bytes"
80
- }
81
- }
82
-
83
- Test "AST parses without errors" {
84
- $parseErrors = $null
85
- $ast = [System.Management.Automation.Language.Parser]::ParseFile(
86
- $installScript, [ref]$null, [ref]$parseErrors
87
- )
88
- if ($parseErrors.Count -gt 0) {
89
- $msg = ($parseErrors | ForEach-Object { "L$($_.Extent.StartLineNumber): $($_.Message)" }) -join "; "
90
- throw $msg
91
- }
92
- }
93
-
94
- Write-Host ""
95
-
96
- # ── Param block (via AST) ──
97
- Write-Host "--- Param block ---" -ForegroundColor Cyan
98
-
99
- $content = Get-Content $installScript -Raw
100
- $parseErrors = $null
101
- $ast = [System.Management.Automation.Language.Parser]::ParseInput($content, [ref]$null, [ref]$parseErrors)
102
-
103
- Test "param block defines all expected parameters" {
104
- $expected = @('Version', 'InstallDir', 'Source', 'AddToPath',
105
- 'Silent', 'DryRun', 'SkipIfInstalled', 'SkipLocal', 'Locate')
106
- # Extract param block text via AST extents
107
- $paramAst = $ast.ParamBlock
108
- if (-not $paramAst) { throw "Could not find param block AST" }
109
- $txt = $content.Substring($paramAst.Extent.StartOffset, $paramAst.Extent.EndOffset - $paramAst.Extent.StartOffset)
110
- foreach ($p in $expected) {
111
- if ($txt -notmatch ('\$' + $p + '\b')) {
112
- throw "Missing parameter: $p"
113
- }
114
- }
115
- }
116
-
117
- Test "Source has ValidateSet with empty string" {
118
- if ($content -notmatch 'ValidateSet\("",\s*"github",\s*"oss"\)') {
119
- throw "ValidateSet must include empty string for iex compatibility"
120
- }
121
- }
122
-
123
- Write-Host ""
124
-
125
- # ── Locate mode ──
126
- Write-Host "--- Locate mode ---" -ForegroundColor Cyan
127
-
128
- $ec = 0
129
- $r = RunScript -scriptArgs "-Locate" -exitCode ([ref]$ec)
130
-
131
- Test "-Locate exits with code 0" {
132
- if ($ec -ne 0) { throw "Exit code: $ec" }
133
- }
134
-
135
- Test "-Locate shows platform key" {
136
- if ($r.Output -notmatch 'Platform key') {
137
- throw "Missing 'Platform key' in: $($r.Output.Substring(0, [Math]::Min(500, $r.Output.Length)))"
138
- }
139
- }
140
-
141
- Test "-Locate shows binary name" {
142
- if ($r.Output -notmatch 'Binary name') {
143
- throw "Missing 'Binary name' in output (len=$($r.Output.Length))"
144
- }
145
- }
146
-
147
- Test "-Locate shows download order" {
148
- if ($r.Output -notmatch 'Download order') {
149
- throw "Missing 'Download order' in output"
150
- }
151
- }
152
-
153
- Write-Host ""
154
-
155
- # ── Download URLs (via -Locate output) ──
156
- Write-Host "--- Download URLs ---" -ForegroundColor Cyan
157
-
158
- Test "locate shows correct GitHub latest/download URL" {
159
- if ($r.Output -notmatch 'github\.com/platonai/Browser4/releases/latest/download/') {
160
- $lines = ($r.Output -split '\n' | Where-Object { $_ -match 'ownload' }) -join '; '
161
- throw "GitHub latest URL not found. Download lines: $lines"
162
- }
163
- }
164
-
165
- Test "locate shows correct OSS download/latest URL" {
166
- if ($r.Output -notmatch 'oss-cn-beijing.*?releases/download/latest/') {
167
- $lines = ($r.Output -split '\n' | Where-Object { $_ -match 'ownload' }) -join '; '
168
- throw "OSS latest URL not found. Download lines: $lines"
169
- }
170
- }
171
-
172
- # Test versioned URLs
173
- $ec2 = 0
174
- $r2 = RunScript -scriptArgs "-Version v4.11.0 -Locate" -exitCode ([ref]$ec2)
175
-
176
- Test "-Version shows tag-based URLs" {
177
- if ($r2.Output -notmatch 'releases/download/v4\.11\.0/') {
178
- throw "Versioned URL not in output"
179
- }
180
- }
181
-
182
- Write-Host ""
183
-
184
- # ── Parameter acceptance ──
185
- Write-Host "--- Parameter acceptance ---" -ForegroundColor Cyan
186
-
187
- Test "-SkipIfInstalled flag accepted" {
188
- $ec3 = 0; $r3 = RunScript -scriptArgs "-SkipIfInstalled -DryRun" -exitCode ([ref]$ec3)
189
- if ($ec3 -ne 0) { throw "Exit code: $ec3, output: $($r3.Output)" }
190
- }
191
-
192
- Test "-SkipLocal flag accepted" {
193
- $ec4 = 0; $r4 = RunScript -scriptArgs "-SkipLocal -DryRun" -exitCode ([ref]$ec4)
194
- if ($ec4 -ne 0) { throw "Exit code: $ec4, output: $($r4.Output)" }
195
- }
196
-
197
- Test "-Force rejected (replaced by -SkipIfInstalled)" {
198
- $ec5 = 0; $r5 = RunScript -scriptArgs "-Force -DryRun" -exitCode ([ref]$ec5)
199
- if ($r5.Error -notmatch 'Force') {
200
- throw "-Force should be rejected, got: $($r5.Error)"
201
- }
202
- }
203
-
204
- Test "-Source oss accepted" {
205
- $ec6 = 0; $r6 = RunScript -scriptArgs "-Source oss -DryRun" -exitCode ([ref]$ec6)
206
- if ($ec6 -ne 0) { throw "Exit code: $ec6, output: $($r6.Output)" }
207
- }
208
-
209
- Test "-Source github accepted" {
210
- $ec7 = 0; $r7 = RunScript -scriptArgs "-Source github -DryRun" -exitCode ([ref]$ec7)
211
- if ($ec7 -ne 0) { throw "Exit code: $ec7, output: $($r7.Output)" }
212
- }
213
-
214
- Test "-Source invalid rejected" {
215
- $ec8 = 0; $r8 = RunScript -scriptArgs "-Source invalid -DryRun" -exitCode ([ref]$ec8)
216
- if ($r8.Error -notmatch 'Source|invalid|parameter') {
217
- throw "-Source invalid should be rejected, got: $($r8.Error)"
218
- }
219
- }
220
-
221
- Test "-Silent flag accepted" {
222
- $ec9 = 0; $r9 = RunScript -scriptArgs "-Silent -DryRun" -exitCode ([ref]$ec9)
223
- if ($ec9 -ne 0) { throw "Exit code: $ec9, output: $($r9.Output)" }
224
- }
225
-
226
- Test "-Version flag accepted" {
227
- $ec10 = 0; $r10 = RunScript -scriptArgs "-Version v4.11.0 -DryRun" -exitCode ([ref]$ec10)
228
- if ($ec10 -ne 0) { throw "Exit code: $ec10, output: $($r10.Output)" }
229
- }
230
-
231
- Write-Host ""
232
-
233
- # ── Functions via dot-source ──
234
- Write-Host "--- Functions ---" -ForegroundColor Cyan
235
-
236
- # Strip trailing Main call and dot-source for function-level tests
237
- $scriptContent = Get-Content $installScript -Raw
238
- $scriptContent = $scriptContent -replace '\r?\nMain\s*$', ''
239
- $scriptContent = $scriptContent -replace '\$ErrorActionPreference\s*=\s*"Stop"', ''
240
- $sb = [ScriptBlock]::Create($scriptContent)
241
-
242
- & {
243
- # Suppress output
244
- $Silent = $true
245
- $DryRun = $false
246
- $SkipLocal = $false
247
- $Locate = $false
248
- $Source = ""
249
- $Version = ""
250
- $InstallDir = ""
251
- $AddToPath = $true
252
-
253
- . $sb
254
-
255
- Test "Get-PlatformKey returns valid format" {
256
- $key = Get-PlatformKey
257
- if ($key -notmatch '^(win32|linux|darwin)-(x64|arm64)$' -and
258
- $key -notmatch '^linux-musl-(x64|arm64)$') {
259
- throw "Unexpected platform key: $key"
260
- }
261
- }
262
-
263
- Test "Get-BinaryName includes .exe on win32" {
264
- $name = Get-BinaryName -PlatformKey "win32-x64"
265
- if ($name -ne "browser4-cli-win32-x64.exe") { throw "Got: $name" }
266
- }
267
-
268
- Test "Get-BinaryName excludes .exe on linux" {
269
- $name = Get-BinaryName -PlatformKey "linux-x64"
270
- if ($name -ne "browser4-cli-linux-x64") { throw "Got: $name" }
271
- }
272
-
273
- Test "Get-BinaryName excludes .exe on darwin" {
274
- $name = Get-BinaryName -PlatformKey "darwin-arm64"
275
- if ($name -ne "browser4-cli-darwin-arm64") { throw "Got: $name" }
276
- }
277
-
278
- Test "Get-DefaultInstallDir returns non-empty" {
279
- $dir = Get-DefaultInstallDir
280
- if ([string]::IsNullOrEmpty($dir)) { throw "Empty install dir" }
281
- }
282
-
283
- Test "Test-ChinaLocale returns [bool]" {
284
- $result = Test-ChinaLocale
285
- if ($result -isnot [bool]) { throw "Expected [bool], got $($result.GetType())" }
286
- }
287
-
288
- Test "Find-LocalBinary returns null for non-existent" {
289
- $result = Find-LocalBinary -BinaryName "nonexistent-file-xyz.exe"
290
- if ($result -ne $null) { throw "Expected null, got: $result" }
291
- }
292
-
293
- Test "Test-LocalBinary returns false for empty string" {
294
- if (Test-LocalBinary -Path "") { throw "Should be false" }
295
- }
296
-
297
- Test "Test-LocalBinary returns false for null" {
298
- if (Test-LocalBinary -Path $null) { throw "Should be false" }
299
- }
300
- }
301
-
302
- Write-Host ""
303
-
304
- # ── New-Symlinks (b4 link logic) ──
305
- Write-Host "--- New-Symlinks ---" -ForegroundColor Cyan
306
-
307
- & {
308
- $Silent = $true
309
- $DryRun = $false
310
- $SkipLocal = $false
311
- $Locate = $false
312
- $Source = ""
313
- $Version = ""
314
- $InstallDir = ""
315
- $AddToPath = $true
316
-
317
- . $sb
318
-
319
- $testPlatformKey = if ($script:OSWin) { "win32-x64" } else { "linux-x64" }
320
- $testExt = if ($testPlatformKey.StartsWith("win32")) { ".exe" } else { "" }
321
- $testBinaryName = "browser4-cli-$testPlatformKey$testExt"
322
- $testShortName = "b4$testExt"
323
-
324
- $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) "b4-install-test-$(Get-Random)"
325
- New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
326
-
327
- try {
328
- # Create the dummy platform binary that New-PlatformLink needs
329
- $dummyBinary = Join-Path $tempDir $testBinaryName
330
- "dummy" | Out-File $dummyBinary
331
-
332
- $testShortPath = Join-Path $tempDir $testShortName
333
- $testCmdPath = Join-Path $tempDir "b4.cmd"
334
-
335
- function Clear-B4Links {
336
- if (Test-Path $testShortPath) { Remove-Item $testShortPath -Force -ErrorAction SilentlyContinue }
337
- if (Test-Path $testCmdPath) { Remove-Item $testCmdPath -Force -ErrorAction SilentlyContinue }
338
- }
339
-
340
- # ── Scenario 1: b4 does not exist → should create ──
341
- Clear-B4Links
342
-
343
- New-Symlinks -BinaryName $testBinaryName -InstallDir $tempDir -PlatformKey $testPlatformKey
344
-
345
- Test "New-Symlinks creates b4 when it does not exist" {
346
- if (-not ((Test-Path $testShortPath) -or (Test-Path $testCmdPath))) {
347
- throw "b4 link was not created (neither symlink nor .cmd wrapper found in $tempDir)"
348
- }
349
- }
350
-
351
- # ── Scenario 2: b4 exists in install dir → should update ──
352
- Clear-B4Links
353
-
354
- # Create initial link
355
- New-Symlinks -BinaryName $testBinaryName -InstallDir $tempDir -PlatformKey $testPlatformKey
356
-
357
- $existingPath = if (Test-Path $testShortPath) { $testShortPath }
358
- elseif (Test-Path $testCmdPath) { $testCmdPath }
359
- else { $null }
360
-
361
- if ($existingPath) {
362
- $beforeTime = (Get-Item $existingPath).LastWriteTime
363
- Start-Sleep -Milliseconds 200
364
-
365
- New-Symlinks -BinaryName $testBinaryName -InstallDir $tempDir -PlatformKey $testPlatformKey
366
-
367
- $afterPath = if (Test-Path $testShortPath) { $testShortPath }
368
- elseif (Test-Path $testCmdPath) { $testCmdPath }
369
- else { $null }
370
-
371
- Test "New-Symlinks updates b4 when it already exists in install dir" {
372
- if (-not $afterPath) {
373
- throw "b4 link disappeared after update"
374
- }
375
- if ((Get-Item $afterPath).LastWriteTime -le $beforeTime) {
376
- throw "b4 link was not updated (timestamp unchanged)"
377
- }
378
- }
379
- } else {
380
- Write-Host " SKIP New-Symlinks updates b4 (precondition: initial link creation failed)" -ForegroundColor Yellow
381
- }
382
-
383
- # ── Scenario 3: foreign b4 on PATH → should NOT create b4 in install dir ──
384
- Clear-B4Links
385
-
386
- $foreignDir = Join-Path ([System.IO.Path]::GetTempPath()) "b4-foreign-test-$(Get-Random)"
387
- New-Item -ItemType Directory -Path $foreignDir -Force | Out-Null
388
- try {
389
- # Create fake b4 that is NOT browser4-cli
390
- $foreignB4 = Join-Path $foreignDir $testShortName
391
- if ($script:OSWin) {
392
- # Use .cmd so Get-Command finds it via PATHEXT
393
- $foreignCmd = Join-Path $foreignDir "b4.cmd"
394
- "@echo off`r`necho NotBrowser4" | Out-File $foreignCmd -Encoding ASCII
395
- } else {
396
- "#!/bin/sh`necho NotBrowser4" | Out-File $foreignB4 -Encoding ASCII
397
- & { chmod +x $foreignB4 2>$null } | Out-Null
398
- }
399
-
400
- $oldPath = $env:Path
401
- $env:Path = "$foreignDir$([System.IO.Path]::PathSeparator)$oldPath"
402
- try {
403
- New-Symlinks -BinaryName $testBinaryName -InstallDir $tempDir -PlatformKey $testPlatformKey
404
-
405
- Test "New-Symlinks skips b4 when foreign b4 is on PATH" {
406
- if ((Test-Path $testShortPath) -or (Test-Path $testCmdPath)) {
407
- throw "b4 link was created even though a non-browser4-cli b4 is on PATH"
408
- }
409
- }
410
- } finally {
411
- $env:Path = $oldPath
412
- }
413
- } finally {
414
- Remove-Item $foreignDir -Recurse -Force -ErrorAction SilentlyContinue
415
- }
416
-
417
- # ── Scenario 4: b4 on PATH is browser4-cli → should create b4 link ──
418
- Clear-B4Links
419
-
420
- $oursDir = Join-Path ([System.IO.Path]::GetTempPath()) "b4-ours-test-$(Get-Random)"
421
- New-Item -ItemType Directory -Path $oursDir -Force | Out-Null
422
- try {
423
- $oursB4 = Join-Path $oursDir $testShortName
424
- if ($script:OSWin) {
425
- $oursCmd = Join-Path $oursDir "b4.cmd"
426
- "@echo off`r`necho browser4-cli v4.12.0" | Out-File $oursCmd -Encoding ASCII
427
- } else {
428
- "#!/bin/sh`necho browser4-cli v4.12.0" | Out-File $oursB4 -Encoding ASCII
429
- & { chmod +x $oursB4 2>$null } | Out-Null
430
- }
431
-
432
- $oldPath = $env:Path
433
- $env:Path = "$oursDir$([System.IO.Path]::PathSeparator)$oldPath"
434
- try {
435
- New-Symlinks -BinaryName $testBinaryName -InstallDir $tempDir -PlatformKey $testPlatformKey
436
-
437
- Test "New-Symlinks creates b4 when b4 on PATH is browser4-cli" {
438
- if (-not ((Test-Path $testShortPath) -or (Test-Path $testCmdPath))) {
439
- throw "b4 link was not created even though PATH b4 is browser4-cli"
440
- }
441
- }
442
- } finally {
443
- $env:Path = $oldPath
444
- }
445
- } finally {
446
- Remove-Item $oursDir -Recurse -Force -ErrorAction SilentlyContinue
447
- }
448
- } finally {
449
- Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue
450
- }
451
- }
452
-
453
- Write-Host ""
454
-
455
- # ── Summary ──
456
- Write-Host "============================================" -ForegroundColor Cyan
457
- $total = $pass + $fail
458
- Write-Host " Results: $pass / $total passed" -ForegroundColor $(if ($fail -eq 0) { "Green" } else { "Red" })
459
- Write-Host "============================================" -ForegroundColor Cyan
460
-
461
- if ($fail -gt 0) { exit 1 }
462
- exit 0
1
+ #!/usr/bin/env pwsh
2
+
3
+ # ═══════════════════════════════════════════════════════════════════
4
+ # CROSS-PLATFORM: This script must run on Linux, macOS, and Windows.
5
+ # - Use $IsWindows / $IsLinux / $IsMacOS for platform detection.
6
+ # - Use "($IsWindows -or $env:OS -eq 'Windows_NT')" for PS 5.1 compat.
7
+ # - Avoid Windows-only env vars ($env:TEMP) — use $env:TMPDIR fallback.
8
+ # - Guard "chcp" and other Windows-only commands behind platform checks.
9
+ # - Paths: use Join-Path / Split-Path; never bake \ or / as literal.
10
+ # - [System.IO.Path]::IsPathRooted is platform-aware — C:\foo is NOT
11
+ # rooted on Linux; test with platform-appropriate absolute paths.
12
+ # ═══════════════════════════════════════════════════════════════════
13
+
14
+ <#
15
+ .SYNOPSIS
16
+ Tests for install-browser4-cli.ps1
17
+ PowerShell 5.1+ only — zero external dependencies.
18
+ Run: powershell -NoProfile -ExecutionPolicy Bypass -File install-browser4-cli.tests.ps1
19
+ #>
20
+
21
+ $ErrorActionPreference = "Stop"
22
+ $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
23
+ $installScript = Join-Path (Split-Path $scriptDir -Parent) "install-browser4-cli.ps1"
24
+
25
+ $pass = 0
26
+ $fail = 0
27
+
28
+ function Test($name, [ScriptBlock]$block) {
29
+ try {
30
+ $null = & $block
31
+ $script:pass++
32
+ Write-Host " PASS $name" -ForegroundColor Green
33
+ } catch {
34
+ $script:fail++
35
+ Write-Host " FAIL $name" -ForegroundColor Red
36
+ Write-Host " $($_.Exception.Message)" -ForegroundColor Red
37
+ }
38
+ }
39
+
40
+ function RunScript([string]$scriptArgs, [ref]$exitCode) {
41
+ # Use -Command to control stderr redirect (2> path must come before script args,
42
+ # which -File would pass as literal arguments to the script).
43
+ $tmpErr = [System.IO.Path]::GetTempFileName()
44
+ $cmd = "& '$installScript' $scriptArgs 2>'$tmpErr'"
45
+ $psi = New-Object System.Diagnostics.ProcessStartInfo
46
+ $psi.FileName = if ($IsWindows -or ($env:OS -eq 'Windows_NT')) { "powershell.exe" } else { "pwsh" }
47
+ $psi.Arguments = "-NoProfile -ExecutionPolicy Bypass -Command `"$cmd`""
48
+ $psi.RedirectStandardOutput = $true
49
+ $psi.UseShellExecute = $false
50
+ $psi.CreateNoWindow = $true
51
+ $proc = [System.Diagnostics.Process]::Start($psi)
52
+ $out = $proc.StandardOutput.ReadToEnd()
53
+ $proc.WaitForExit()
54
+ $exitCode.Value = $proc.ExitCode
55
+ $err = if (Test-Path $tmpErr) { Get-Content $tmpErr -Raw -ErrorAction SilentlyContinue; Remove-Item $tmpErr -Force -ErrorAction SilentlyContinue } else { "" }
56
+ return [PSCustomObject]@{ Output = $out; Error = $err; ExitCode = $proc.ExitCode }
57
+ }
58
+
59
+ Write-Host "============================================" -ForegroundColor Cyan
60
+ Write-Host " install-browser4-cli.ps1 Test Suite" -ForegroundColor Cyan
61
+ Write-Host "============================================" -ForegroundColor Cyan
62
+ Write-Host ""
63
+
64
+ # ── Pre-flight ──
65
+ Write-Host "--- Pre-flight ---" -ForegroundColor Cyan
66
+
67
+ Test "file exists" {
68
+ if (-not (Test-Path $installScript)) { throw "Not found: $installScript" }
69
+ }
70
+
71
+ Test "file is readable" {
72
+ $null = Get-Content $installScript -Raw -ErrorAction Stop
73
+ }
74
+
75
+ Test "no non-ASCII bytes" {
76
+ $bytes = [System.IO.File]::ReadAllBytes($installScript)
77
+ $nonAscii = @($bytes | Where-Object { $_ -gt 127 })
78
+ if ($nonAscii.Count -gt 0) {
79
+ throw "Found $($nonAscii.Count) non-ASCII bytes"
80
+ }
81
+ }
82
+
83
+ Test "AST parses without errors" {
84
+ $parseErrors = $null
85
+ $ast = [System.Management.Automation.Language.Parser]::ParseFile(
86
+ $installScript, [ref]$null, [ref]$parseErrors
87
+ )
88
+ if ($parseErrors.Count -gt 0) {
89
+ $msg = ($parseErrors | ForEach-Object { "L$($_.Extent.StartLineNumber): $($_.Message)" }) -join "; "
90
+ throw $msg
91
+ }
92
+ }
93
+
94
+ Write-Host ""
95
+
96
+ # ── Param block (via AST) ──
97
+ Write-Host "--- Param block ---" -ForegroundColor Cyan
98
+
99
+ $content = Get-Content $installScript -Raw
100
+ $parseErrors = $null
101
+ $ast = [System.Management.Automation.Language.Parser]::ParseInput($content, [ref]$null, [ref]$parseErrors)
102
+
103
+ Test "param block defines all expected parameters" {
104
+ $expected = @('Version', 'InstallDir', 'Source', 'AddToPath',
105
+ 'Silent', 'DryRun', 'SkipIfInstalled', 'SkipLocal', 'Locate')
106
+ # Extract param block text via AST extents
107
+ $paramAst = $ast.ParamBlock
108
+ if (-not $paramAst) { throw "Could not find param block AST" }
109
+ $txt = $content.Substring($paramAst.Extent.StartOffset, $paramAst.Extent.EndOffset - $paramAst.Extent.StartOffset)
110
+ foreach ($p in $expected) {
111
+ if ($txt -notmatch ('\$' + $p + '\b')) {
112
+ throw "Missing parameter: $p"
113
+ }
114
+ }
115
+ }
116
+
117
+ Test "Source has ValidateSet with empty string" {
118
+ if ($content -notmatch 'ValidateSet\("",\s*"github",\s*"oss"\)') {
119
+ throw "ValidateSet must include empty string for iex compatibility"
120
+ }
121
+ }
122
+
123
+ Write-Host ""
124
+
125
+ # ── Locate mode ──
126
+ Write-Host "--- Locate mode ---" -ForegroundColor Cyan
127
+
128
+ $ec = 0
129
+ $r = RunScript -scriptArgs "-Locate" -exitCode ([ref]$ec)
130
+
131
+ Test "-Locate exits with code 0" {
132
+ if ($ec -ne 0) { throw "Exit code: $ec" }
133
+ }
134
+
135
+ Test "-Locate shows platform key" {
136
+ if ($r.Output -notmatch 'Platform key') {
137
+ throw "Missing 'Platform key' in: $($r.Output.Substring(0, [Math]::Min(500, $r.Output.Length)))"
138
+ }
139
+ }
140
+
141
+ Test "-Locate shows binary name" {
142
+ if ($r.Output -notmatch 'Binary name') {
143
+ throw "Missing 'Binary name' in output (len=$($r.Output.Length))"
144
+ }
145
+ }
146
+
147
+ Test "-Locate shows download order" {
148
+ if ($r.Output -notmatch 'Download order') {
149
+ throw "Missing 'Download order' in output"
150
+ }
151
+ }
152
+
153
+ Write-Host ""
154
+
155
+ # ── Download URLs (via -Locate output) ──
156
+ Write-Host "--- Download URLs ---" -ForegroundColor Cyan
157
+
158
+ Test "locate shows correct GitHub latest/download URL" {
159
+ if ($r.Output -notmatch 'github\.com/platonai/Browser4/releases/latest/download/') {
160
+ $lines = ($r.Output -split '\n' | Where-Object { $_ -match 'ownload' }) -join '; '
161
+ throw "GitHub latest URL not found. Download lines: $lines"
162
+ }
163
+ }
164
+
165
+ Test "locate shows correct OSS download/latest URL" {
166
+ if ($r.Output -notmatch 'oss-cn-beijing.*?releases/download/latest/') {
167
+ $lines = ($r.Output -split '\n' | Where-Object { $_ -match 'ownload' }) -join '; '
168
+ throw "OSS latest URL not found. Download lines: $lines"
169
+ }
170
+ }
171
+
172
+ # Test versioned URLs
173
+ $ec2 = 0
174
+ $r2 = RunScript -scriptArgs "-Version v4.11.0 -Locate" -exitCode ([ref]$ec2)
175
+
176
+ Test "-Version shows tag-based URLs" {
177
+ if ($r2.Output -notmatch 'releases/download/v4\.11\.0/') {
178
+ throw "Versioned URL not in output"
179
+ }
180
+ }
181
+
182
+ Write-Host ""
183
+
184
+ # ── Parameter acceptance ──
185
+ Write-Host "--- Parameter acceptance ---" -ForegroundColor Cyan
186
+
187
+ Test "-SkipIfInstalled flag accepted" {
188
+ $ec3 = 0; $r3 = RunScript -scriptArgs "-SkipIfInstalled -DryRun" -exitCode ([ref]$ec3)
189
+ if ($ec3 -ne 0) { throw "Exit code: $ec3, output: $($r3.Output)" }
190
+ }
191
+
192
+ Test "-SkipLocal flag accepted" {
193
+ $ec4 = 0; $r4 = RunScript -scriptArgs "-SkipLocal -DryRun" -exitCode ([ref]$ec4)
194
+ if ($ec4 -ne 0) { throw "Exit code: $ec4, output: $($r4.Output)" }
195
+ }
196
+
197
+ Test "-Force rejected (replaced by -SkipIfInstalled)" {
198
+ $ec5 = 0; $r5 = RunScript -scriptArgs "-Force -DryRun" -exitCode ([ref]$ec5)
199
+ if ($r5.Error -notmatch 'Force') {
200
+ throw "-Force should be rejected, got: $($r5.Error)"
201
+ }
202
+ }
203
+
204
+ Test "-Source oss accepted" {
205
+ $ec6 = 0; $r6 = RunScript -scriptArgs "-Source oss -DryRun" -exitCode ([ref]$ec6)
206
+ if ($ec6 -ne 0) { throw "Exit code: $ec6, output: $($r6.Output)" }
207
+ }
208
+
209
+ Test "-Source github accepted" {
210
+ $ec7 = 0; $r7 = RunScript -scriptArgs "-Source github -DryRun" -exitCode ([ref]$ec7)
211
+ if ($ec7 -ne 0) { throw "Exit code: $ec7, output: $($r7.Output)" }
212
+ }
213
+
214
+ Test "-Source invalid rejected" {
215
+ $ec8 = 0; $r8 = RunScript -scriptArgs "-Source invalid -DryRun" -exitCode ([ref]$ec8)
216
+ if ($r8.Error -notmatch 'Source|invalid|parameter') {
217
+ throw "-Source invalid should be rejected, got: $($r8.Error)"
218
+ }
219
+ }
220
+
221
+ Test "-Silent flag accepted" {
222
+ $ec9 = 0; $r9 = RunScript -scriptArgs "-Silent -DryRun" -exitCode ([ref]$ec9)
223
+ if ($ec9 -ne 0) { throw "Exit code: $ec9, output: $($r9.Output)" }
224
+ }
225
+
226
+ Test "-Version flag accepted" {
227
+ $ec10 = 0; $r10 = RunScript -scriptArgs "-Version v4.11.0 -DryRun" -exitCode ([ref]$ec10)
228
+ if ($ec10 -ne 0) { throw "Exit code: $ec10, output: $($r10.Output)" }
229
+ }
230
+
231
+ Write-Host ""
232
+
233
+ # ── Functions via dot-source ──
234
+ Write-Host "--- Functions ---" -ForegroundColor Cyan
235
+
236
+ # Strip trailing Main call and dot-source for function-level tests
237
+ $scriptContent = Get-Content $installScript -Raw
238
+ $scriptContent = $scriptContent -replace '\r?\nMain\s*$', ''
239
+ $scriptContent = $scriptContent -replace '\$ErrorActionPreference\s*=\s*"Stop"', ''
240
+ $sb = [ScriptBlock]::Create($scriptContent)
241
+
242
+ & {
243
+ # Suppress output
244
+ $Silent = $true
245
+ $DryRun = $false
246
+ $SkipLocal = $false
247
+ $Locate = $false
248
+ $Source = ""
249
+ $Version = ""
250
+ $InstallDir = ""
251
+ $AddToPath = $true
252
+
253
+ . $sb
254
+
255
+ Test "Get-PlatformKey returns valid format" {
256
+ $key = Get-PlatformKey
257
+ if ($key -notmatch '^(win32|linux|darwin)-(x64|arm64)$' -and
258
+ $key -notmatch '^linux-musl-(x64|arm64)$') {
259
+ throw "Unexpected platform key: $key"
260
+ }
261
+ }
262
+
263
+ Test "Get-BinaryName includes .exe on win32" {
264
+ $name = Get-BinaryName -PlatformKey "win32-x64"
265
+ if ($name -ne "browser4-cli-win32-x64.exe") { throw "Got: $name" }
266
+ }
267
+
268
+ Test "Get-BinaryName excludes .exe on linux" {
269
+ $name = Get-BinaryName -PlatformKey "linux-x64"
270
+ if ($name -ne "browser4-cli-linux-x64") { throw "Got: $name" }
271
+ }
272
+
273
+ Test "Get-BinaryName excludes .exe on darwin" {
274
+ $name = Get-BinaryName -PlatformKey "darwin-arm64"
275
+ if ($name -ne "browser4-cli-darwin-arm64") { throw "Got: $name" }
276
+ }
277
+
278
+ Test "Get-DefaultInstallDir returns non-empty" {
279
+ $dir = Get-DefaultInstallDir
280
+ if ([string]::IsNullOrEmpty($dir)) { throw "Empty install dir" }
281
+ }
282
+
283
+ Test "Test-ChinaLocale returns [bool]" {
284
+ $result = Test-ChinaLocale
285
+ if ($result -isnot [bool]) { throw "Expected [bool], got $($result.GetType())" }
286
+ }
287
+
288
+ Test "Find-LocalBinary returns null for non-existent" {
289
+ $result = Find-LocalBinary -BinaryName "nonexistent-file-xyz.exe"
290
+ if ($result -ne $null) { throw "Expected null, got: $result" }
291
+ }
292
+
293
+ Test "Test-LocalBinary returns false for empty string" {
294
+ if (Test-LocalBinary -Path "") { throw "Should be false" }
295
+ }
296
+
297
+ Test "Test-LocalBinary returns false for null" {
298
+ if (Test-LocalBinary -Path $null) { throw "Should be false" }
299
+ }
300
+ }
301
+
302
+ Write-Host ""
303
+
304
+ # ── New-Symlinks (b4 link logic) ──
305
+ Write-Host "--- New-Symlinks ---" -ForegroundColor Cyan
306
+
307
+ & {
308
+ $Silent = $true
309
+ $DryRun = $false
310
+ $SkipLocal = $false
311
+ $Locate = $false
312
+ $Source = ""
313
+ $Version = ""
314
+ $InstallDir = ""
315
+ $AddToPath = $true
316
+
317
+ . $sb
318
+
319
+ # NOTE: $env:PATH (all-caps) is the real env var on Linux; $env:Path
320
+ # (mixed-case) is a different, unrelated variable.
321
+ $testPlatformKey = if ($script:OSWin) { "win32-x64" } else { "linux-x64" }
322
+ $testExt = if ($testPlatformKey.StartsWith("win32")) { ".exe" } else { "" }
323
+ $testBinaryName = "browser4-cli-$testPlatformKey$testExt"
324
+ $testShortName = "b4$testExt"
325
+
326
+ $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) "b4-install-test-$(Get-Random)"
327
+ New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
328
+
329
+ try {
330
+ # Create the dummy platform binary that New-PlatformLink needs
331
+ $dummyBinary = Join-Path $tempDir $testBinaryName
332
+ "dummy" | Out-File $dummyBinary
333
+
334
+ $testShortPath = Join-Path $tempDir $testShortName
335
+ $testCmdPath = Join-Path $tempDir "b4.cmd"
336
+
337
+ function Clear-B4Links {
338
+ if (Test-Path $testShortPath) { Remove-Item $testShortPath -Force -ErrorAction SilentlyContinue }
339
+ if (Test-Path $testCmdPath) { Remove-Item $testCmdPath -Force -ErrorAction SilentlyContinue }
340
+ }
341
+
342
+ # Helper: temporarily override $env:PATH so the New-Symlinks PATH
343
+ # scan only sees directories the test scenario controls. On Linux
344
+ # the kernel's b4 patch tool lives in /usr/bin (and /bin on some
345
+ # distros); without this override Get-Command/Get-Command b4 would
346
+ # always find the system b4 and skip creating our link.
347
+ function Invoke-WithPath([string]$Path, [ScriptBlock]$Action) {
348
+ $prevPath = $env:PATH
349
+ try {
350
+ $env:PATH = $Path
351
+ return & $Action
352
+ } finally {
353
+ $env:PATH = $prevPath
354
+ }
355
+ }
356
+
357
+ # ── Scenario 1: b4 does not exist → should create ──
358
+ Clear-B4Links
359
+
360
+ Invoke-WithPath -Path $tempDir -Action {
361
+ New-Symlinks -BinaryName $testBinaryName -InstallDir $tempDir -PlatformKey $testPlatformKey
362
+ }
363
+
364
+ Test "New-Symlinks creates b4 when it does not exist" {
365
+ if (-not ((Test-Path $testShortPath) -or (Test-Path $testCmdPath))) {
366
+ throw "b4 link was not created (neither symlink nor .cmd wrapper found in $tempDir)"
367
+ }
368
+ }
369
+
370
+ # ── Scenario 2: b4 exists in install dir → should update ──
371
+ Clear-B4Links
372
+
373
+ Invoke-WithPath -Path $tempDir -Action {
374
+ New-Symlinks -BinaryName $testBinaryName -InstallDir $tempDir -PlatformKey $testPlatformKey
375
+ }
376
+
377
+ $existingPath = if (Test-Path $testShortPath) { $testShortPath }
378
+ elseif (Test-Path $testCmdPath) { $testCmdPath }
379
+ else { $null }
380
+
381
+ if ($existingPath) {
382
+ $beforeTime = (Get-Item $existingPath).LastWriteTime
383
+ Start-Sleep -Milliseconds 200
384
+
385
+ Invoke-WithPath -Path $tempDir -Action {
386
+ New-Symlinks -BinaryName $testBinaryName -InstallDir $tempDir -PlatformKey $testPlatformKey
387
+ }
388
+
389
+ $afterPath = if (Test-Path $testShortPath) { $testShortPath }
390
+ elseif (Test-Path $testCmdPath) { $testCmdPath }
391
+ else { $null }
392
+
393
+ Test "New-Symlinks updates b4 when it already exists in install dir" {
394
+ if (-not $afterPath) {
395
+ throw "b4 link disappeared after update"
396
+ }
397
+ if ((Get-Item $afterPath).LastWriteTime -le $beforeTime) {
398
+ throw "b4 link was not updated (timestamp unchanged)"
399
+ }
400
+ }
401
+ } else {
402
+ Write-Host " SKIP New-Symlinks updates b4 (precondition: initial link creation failed)" -ForegroundColor Yellow
403
+ }
404
+
405
+ # ── Scenario 3: foreign b4 on PATH → should NOT create b4 in install dir ──
406
+ Clear-B4Links
407
+
408
+ $foreignDir = Join-Path ([System.IO.Path]::GetTempPath()) "b4-foreign-test-$(Get-Random)"
409
+ New-Item -ItemType Directory -Path $foreignDir -Force | Out-Null
410
+ try {
411
+ # Create fake b4 that is NOT browser4-cli
412
+ $foreignB4 = Join-Path $foreignDir $testShortName
413
+ if ($script:OSWin) {
414
+ # Use .cmd so Get-Command finds it via PATHEXT
415
+ $foreignCmd = Join-Path $foreignDir "b4.cmd"
416
+ "@echo off`r`necho NotBrowser4" | Out-File $foreignCmd -Encoding ASCII
417
+ } else {
418
+ "#!/bin/sh`necho NotBrowser4" | Out-File $foreignB4 -Encoding ASCII
419
+ & { chmod +x $foreignB4 2>$null } | Out-Null
420
+ }
421
+
422
+ Invoke-WithPath -Path "$foreignDir$([System.IO.Path]::PathSeparator)$tempDir" -Action {
423
+ New-Symlinks -BinaryName $testBinaryName -InstallDir $tempDir -PlatformKey $testPlatformKey
424
+ }
425
+
426
+ Test "New-Symlinks skips b4 when foreign b4 is on PATH" {
427
+ if ((Test-Path $testShortPath) -or (Test-Path $testCmdPath)) {
428
+ throw "b4 link was created even though a non-browser4-cli b4 is on PATH"
429
+ }
430
+ }
431
+ } finally {
432
+ Remove-Item $foreignDir -Recurse -Force -ErrorAction SilentlyContinue
433
+ }
434
+
435
+ # ── Scenario 4: b4 on PATH is browser4-cli → should create b4 link ──
436
+ Clear-B4Links
437
+
438
+ $oursDir = Join-Path ([System.IO.Path]::GetTempPath()) "b4-ours-test-$(Get-Random)"
439
+ New-Item -ItemType Directory -Path $oursDir -Force | Out-Null
440
+ try {
441
+ $oursB4 = Join-Path $oursDir $testShortName
442
+ if ($script:OSWin) {
443
+ $oursCmd = Join-Path $oursDir "b4.cmd"
444
+ "@echo off`r`necho browser4-cli v4.12.0" | Out-File $oursCmd -Encoding ASCII
445
+ } else {
446
+ "#!/bin/sh`necho browser4-cli v4.12.0" | Out-File $oursB4 -Encoding ASCII
447
+ & { chmod +x $oursB4 2>$null } | Out-Null
448
+ }
449
+
450
+ Invoke-WithPath -Path "$oursDir$([System.IO.Path]::PathSeparator)$tempDir" -Action {
451
+ New-Symlinks -BinaryName $testBinaryName -InstallDir $tempDir -PlatformKey $testPlatformKey
452
+ }
453
+
454
+ Test "New-Symlinks creates b4 when b4 on PATH is browser4-cli" {
455
+ if (-not ((Test-Path $testShortPath) -or (Test-Path $testCmdPath))) {
456
+ throw "b4 link was not created even though PATH b4 is browser4-cli"
457
+ }
458
+ }
459
+ } finally {
460
+ Remove-Item $oursDir -Recurse -Force -ErrorAction SilentlyContinue
461
+ }
462
+ } finally {
463
+ Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue
464
+ }
465
+ }
466
+
467
+ Write-Host ""
468
+
469
+ # ── Summary ──
470
+ Write-Host "============================================" -ForegroundColor Cyan
471
+ $total = $pass + $fail
472
+ Write-Host " Results: $pass / $total passed" -ForegroundColor $(if ($fail -eq 0) { "Green" } else { "Red" })
473
+ Write-Host "============================================" -ForegroundColor Cyan
474
+
475
+ if ($fail -gt 0) { exit 1 }
476
+ exit 0