training-package-acceptance.ps1 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. param(
  2. [string]$TrainingDir = 'D:\qiwei-training',
  3. [int]$DashboardPort = 4434
  4. )
  5. $ErrorActionPreference = 'Stop'
  6. $exe = Join-Path $TrainingDir 'qiwei-workbench.exe'
  7. if (-not (Test-Path (Join-Path $TrainingDir '.mcp.json'))) { throw 'Training package is missing .mcp.json' }
  8. $mcpConfig = Get-Content (Join-Path $TrainingDir '.mcp.json') -Raw | ConvertFrom-Json
  9. $mcpEntry = $mcpConfig.mcpServers.'qiwei-assistant'
  10. if (-not $mcpEntry) { throw '.mcp.json does not register qiwei-assistant' }
  11. if (-not ($mcpEntry.args -contains 'mcp')) { throw 'qiwei-assistant does not configure the mcp subcommand' }
  12. $mcpVerifier = Join-Path $PSScriptRoot 'verify-training-package-mcp.js'
  13. $mcpOutput = & node $mcpVerifier $TrainingDir 2>&1
  14. if ($LASTEXITCODE -ne 0) { throw "MCP handshake failed: $mcpOutput" }
  15. Write-Output "MCP registration and handshake: $mcpOutput"
  16. function Stop-TrainingProcesses([string]$root) {
  17. $exePath = [IO.Path]::GetFullPath((Join-Path $root 'qiwei-workbench.exe'))
  18. $deadline = (Get-Date).AddSeconds(8)
  19. $quietSince = $null
  20. do {
  21. $processes = @(Get-CimInstance Win32_Process | Where-Object {
  22. $_.ExecutablePath -and [IO.Path]::GetFullPath($_.ExecutablePath) -ieq $exePath
  23. })
  24. if (-not $processes.Count) {
  25. if (-not $quietSince) { $quietSince = Get-Date }
  26. elseif (((Get-Date) - $quietSince).TotalMilliseconds -ge 1500) { return }
  27. Start-Sleep -Milliseconds 300
  28. continue
  29. }
  30. $quietSince = $null
  31. foreach ($process in $processes) {
  32. # /T also closes helper children which can retain SQLite -wal/-shm files.
  33. & "$env:SystemRoot\System32\taskkill.exe" /PID $process.ProcessId /T /F | Out-Null
  34. }
  35. Start-Sleep -Milliseconds 300
  36. } while ((Get-Date) -lt $deadline)
  37. $remaining = @(Get-CimInstance Win32_Process | Where-Object {
  38. $_.ExecutablePath -and [IO.Path]::GetFullPath($_.ExecutablePath) -ieq $exePath
  39. })
  40. if ($remaining.Count) { throw "Training process did not exit: $($remaining.ProcessId -join ', ')" }
  41. }
  42. function Stop-TrainingRuntime([string]$root) {
  43. $runtime = Start-TrainingProcess -FilePath (Join-Path $root 'qiwei-workbench.exe') -ArgumentList @('runtime', 'stop') -WorkingDirectory $root
  44. if (-not $runtime.WaitForExit(10000)) {
  45. & "$env:SystemRoot\System32\taskkill.exe" /PID $runtime.Id /T /F | Out-Null
  46. throw "Runtime stop timed out for $root"
  47. }
  48. if ($runtime.ExitCode -ne 0) { throw "Runtime stop failed for $root (exit $($runtime.ExitCode))" }
  49. }
  50. function Remove-TestRoot([string]$root) {
  51. $deadline = (Get-Date).AddSeconds(5)
  52. do {
  53. try {
  54. Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction Stop
  55. return
  56. } catch {
  57. Start-Sleep -Milliseconds 300
  58. }
  59. } while ((Get-Date) -lt $deadline)
  60. throw "Could not remove temporary test directory: $root"
  61. }
  62. function Wait-Http([string]$url, [int]$seconds = 15) {
  63. $deadline = (Get-Date).AddSeconds($seconds)
  64. do {
  65. try { return Invoke-RestMethod -Uri $url -TimeoutSec 3 }
  66. catch { Start-Sleep -Milliseconds 500 }
  67. } while ((Get-Date) -lt $deadline)
  68. throw "Timed out waiting for $url"
  69. }
  70. function Start-TrainingProcess([string]$filePath, [string[]]$argumentList, [string]$workingDirectory, [hashtable]$environment = $null) {
  71. # PowerShell 5.1 has no Start-Process -Environment. ProcessStartInfo gives
  72. # the same per-process isolation without changing this shell's environment.
  73. $startInfo = New-Object System.Diagnostics.ProcessStartInfo
  74. $startInfo.FileName = $filePath
  75. $startInfo.Arguments = ($argumentList -join ' ')
  76. $startInfo.WorkingDirectory = $workingDirectory
  77. $startInfo.UseShellExecute = $false
  78. $startInfo.CreateNoWindow = $true
  79. if ($environment) {
  80. foreach ($entry in $environment.GetEnumerator()) {
  81. $startInfo.EnvironmentVariables[$entry.Key] = [string]$entry.Value
  82. }
  83. }
  84. return [System.Diagnostics.Process]::Start($startInfo)
  85. }
  86. function Set-EnvValue([string]$path, [string]$key, [string]$value) {
  87. $content = if (Test-Path $path) { Get-Content $path -Raw } else { '' }
  88. $line = "$key=$value"
  89. if ($content -match "(?m)^$([regex]::Escape($key))\s*=.*$") {
  90. $content = [regex]::Replace($content, "(?m)^$([regex]::Escape($key))\s*=.*$", $line)
  91. } else {
  92. $content = $content.TrimEnd() + [Environment]::NewLine + $line + [Environment]::NewLine
  93. }
  94. [IO.File]::WriteAllText($path, $content, [Text.UTF8Encoding]::new($false))
  95. }
  96. function Read-EnvValue([string]$path, [string]$key) {
  97. $line = Get-Content -LiteralPath $path | Where-Object { $_ -match "^$([regex]::Escape($key))\s*=" } | Select-Object -Last 1
  98. if (-not $line) { return '' }
  99. return ($line -replace "^$([regex]::Escape($key))\s*=\s*", '').Trim().Trim('"').Trim("'")
  100. }
  101. Stop-TrainingProcesses $TrainingDir
  102. $emptyRoot = Join-Path $env:TEMP ("qiwei-empty-" + [guid]::NewGuid().ToString('N'))
  103. New-Item -ItemType Directory -Path $emptyRoot | Out-Null
  104. Copy-Item $exe (Join-Path $emptyRoot 'qiwei-workbench.exe')
  105. New-Item -ItemType Directory -Path (Join-Path $emptyRoot 'web') | Out-Null
  106. Copy-Item (Join-Path $TrainingDir 'web\*') (Join-Path $emptyRoot 'web')
  107. [IO.File]::WriteAllText((Join-Path $emptyRoot '.env.local'), "QIWEI_API_BASE=https://server.fmode.cn/api/qiwei`nQIWEI_AUTH_TOKEN=`nQIWEI_UID=`nQIWEI_GUID=`n", [Text.UTF8Encoding]::new($false))
  108. $empty = Start-Process -FilePath (Join-Path $emptyRoot 'qiwei-workbench.exe') -ArgumentList 'dashboard','--port','4432' -WorkingDirectory $emptyRoot -PassThru -WindowStyle Hidden
  109. try {
  110. $emptyStatus = Wait-Http 'http://127.0.0.1:4432/api/status?fast=true'
  111. $emptyAuth = Wait-Http 'http://127.0.0.1:4432/api/auth/token'
  112. if ($emptyStatus.summary.authConfigured -or $emptyAuth.data.configured) { throw 'Empty package borrowed a host credential' }
  113. } finally {
  114. Stop-TrainingRuntime $emptyRoot
  115. Stop-TrainingProcesses $emptyRoot
  116. Remove-TestRoot $emptyRoot
  117. }
  118. $conflictRoot = Join-Path $env:TEMP ("qiwei-conflict-" + [guid]::NewGuid().ToString('N'))
  119. New-Item -ItemType Directory -Path $conflictRoot | Out-Null
  120. Copy-Item $exe (Join-Path $conflictRoot 'qiwei-workbench.exe')
  121. New-Item -ItemType Directory -Path (Join-Path $conflictRoot 'web') | Out-Null
  122. Copy-Item (Join-Path $TrainingDir 'web\*') (Join-Path $conflictRoot 'web')
  123. [IO.File]::WriteAllText((Join-Path $conflictRoot '.env.local'), "QIWEI_API_BASE=https://file.invalid/api/qiwei`nQIWEI_AUTH_TOKEN=file-auth`nQIWEI_UID=file-uid`nQIWEI_GUID=file-guid`n", [Text.UTF8Encoding]::new($false))
  124. $conflict = Start-TrainingProcess -FilePath (Join-Path $conflictRoot 'qiwei-workbench.exe') -ArgumentList @('dashboard','--port','4433') -WorkingDirectory $conflictRoot -Environment @{
  125. QIWEI_AUTH_TOKEN = 'startup-auth'
  126. QIWEI_UID = 'startup-uid'
  127. QIWEI_GUID = 'startup-guid'
  128. QIWEI_API_BASE = 'https://startup.invalid/api/qiwei'
  129. }
  130. try {
  131. $first = Wait-Http 'http://127.0.0.1:4433/api/health'
  132. if ($first.data.activeAccountUid -ne 'file-uid') { throw 'Package file did not override startup uid' }
  133. Set-EnvValue (Join-Path $conflictRoot '.env.local') 'QIWEI_UID' 'file-uid-next'
  134. $second = Invoke-RestMethod -Uri 'http://127.0.0.1:4433/api/health' -TimeoutSec 5
  135. if ($second.data.activeAccountUid -ne 'file-uid-next') { throw 'Updated package uid was not visible without restart' }
  136. } finally {
  137. Stop-TrainingRuntime $conflictRoot
  138. Stop-TrainingProcesses $conflictRoot
  139. Remove-TestRoot $conflictRoot
  140. }
  141. $real = Start-Process -FilePath $exe -ArgumentList 'dashboard','--port',"$DashboardPort" -WorkingDirectory $TrainingDir -PassThru -WindowStyle Hidden
  142. try {
  143. $realStatus = Wait-Http "http://127.0.0.1:$DashboardPort/api/status"
  144. $agentStatus = Wait-Http "http://127.0.0.1:$DashboardPort/api/agent/status"
  145. $result = [ordered]@{
  146. emptyPackageIsolated = $true
  147. fileOverridesStartupEnvironment = $true
  148. fileUpdateVisibleWithoutRestart = $true
  149. real = [ordered]@{
  150. dashboardPort = $DashboardPort
  151. authConfigured = [bool]$realStatus.summary.authConfigured
  152. subscribed = [bool]$realStatus.summary.subscribed
  153. trialActive = [bool]$realStatus.data.subscription.summary.trialActive
  154. source = $realStatus.data.subscription.summary.source
  155. seats = $realStatus.data.subscription.summary.seats
  156. usedSeats = $realStatus.data.subscription.summary.usedSeats
  157. online = [bool]$agentStatus.data.account.online
  158. statusCode = $agentStatus.data.account.statusCode
  159. reviewMode = $agentStatus.data.globalMode
  160. allowedSenderCount = $agentStatus.data.config.allowedSenderCount
  161. confirmedGroupCount = $agentStatus.data.config.confirmedGroupCount
  162. }
  163. }
  164. $result | ConvertTo-Json -Depth 8
  165. } finally {
  166. Stop-TrainingRuntime $TrainingDir
  167. Stop-TrainingProcesses $TrainingDir
  168. }