name: Windows E2E on: push: branches: [main] paths: - 'apps/**' - 'packages/**' - 'tests/**' - 'scripts/**' - 'package.json' - 'pnpm-lock.yaml' - '.github/workflows/windows-e2e.yml' pull_request: branches: [main] workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: windows-e2e: name: Windows 真机验证 runs-on: windows-latest timeout-minutes: 30 steps: - name: 检出代码 uses: actions/checkout@v5 - name: 安装 pnpm uses: pnpm/action-setup@v4 with: version: 12 - name: 安装 Node.js uses: actions/setup-node@v4 with: node-version: 22 cache: pnpm - name: 安装依赖 run: pnpm install --frozen-lockfile - name: 构建 run: pnpm -r build - name: 类型检查 run: pnpm typecheck - name: 单元测试 — 协议纯函数 / 清单守护 / 脚本生成 # 来自 ci/workflow-unit-tests 分支(4d73d6e)的实质贡献;该分支基于老 main, # 直接 merge 会回退上万行,故只取其提交内容手工并入。 run: node --test "tests/unit/**/*.test.mjs" - name: E2E — 协议与跨平台能力 run: node tests/e2e/run.mjs - name: E2E — Windows 专属能力(服务 / 软件 / winget) run: node tests/e2e/windows.mjs # ⚠️ 这一步刻意用 **powershell(Windows PowerShell 5.1)** 而非 pwsh。 # 5.1 读无 BOM 的 .ps1 会按 ANSI/GBK 解析,中文注释会破坏字符串边界 → # 整份脚本 ParseError(真机 2026-10-04 踩过:install.ps1 报「","后面缺少表达式」)。 # 之前的冒烟全用 pwsh(7+ 认无 BOM UTF-8),所以 CI 绿、真机炸。 # 这里只做「解析 + 首行 help」检查:5.1 能否读懂,就是真机的核心约束。 # ⚠️ 必须用 shell: powershell(runner 上的 powershell 即 Windows PowerShell 5.1)。 # 不要用 shell: cmd 再套 powershell -Command "..." —— 多行带引号经 cmd 转义会全乱 # (实测:Missing closing '}' / '$errs' is not recognized,是步骤自身坏,不是脚本坏)。 - name: 冒烟 — 脚本可被 Windows PowerShell 5.1 解析(BOM 守卫) shell: powershell run: | $ErrorActionPreference = 'Stop' foreach ($f in @('scripts\install.ps1', 'scripts\uninstall.ps1', 'scripts\rescue-network.ps1')) { $errs = $null [void][System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path $f).Path, [ref]$null, [ref]$errs) if ($errs -and $errs.Count -gt 0) { throw ('5.1 parse FAILED: ' + $f + ' -> ' + $errs[0].Message) } Write-Host ('OK 5.1 parse: ' + $f) } - name: 冒烟 — install.ps1 安装链路 shell: powershell run: | $ErrorActionPreference = "Stop" ./scripts/install.ps1 -Port 18769 -NodeId ci_win $task = Get-ScheduledTask -TaskName "nodeagent" -ErrorAction Stop Write-Host "Scheduled task state: $($task.State)" $cfg = Get-Content "$env:USERPROFILE\.nodeagent\agent.json" -Raw | ConvertFrom-Json if ($cfg.port -ne 18769) { throw "port mismatch: $($cfg.port)" } if (-not $cfg.key) { throw "PSK missing in agent.json" } if (-not $cfg.tls) { throw "TLS should be on by default" } $rule = Get-NetFirewallRule -DisplayName "nodeagent (TCP 18769)" -ErrorAction Stop if (-not $rule) { throw "Firewall rule" } Write-Host "OK scheduled task / config / firewallall in place" # 一键入口冒烟:真跑 install.cmd / control.cmd。 # 目的不是「再装一遍」,而是证明双击路径可用 —— 这些脚本是用户唯一的 # 上手入口,语法或编码一旦坏了,只有真机 cmd.exe 才测得出来。 # 端口用 18770 避开前一步的 18770 之外的安装,便于分辨是哪一步装的。 - name: 冒烟 — install.cmd 双击链路(含自提权与 PSK 读取) shell: cmd run: call scripts\install.cmd 18770 env: CI: "true" - name: 冒烟 — control.cmd 状态查询 shell: cmd run: scripts\control.cmd 18770 < nul env: CI: "true" - name: 冒烟 — 产物包结构与编码 shell: pwsh run: | $ErrorActionPreference = "Stop" pnpm pack:win $zip = "release/nodeagent-win-x64.zip" if (-not (Test-Path $zip)) { throw "zip not produced: $zip" } Add-Type -AssemblyName System.IO.Compression.FileSystem $z = [IO.Compression.ZipFile]::OpenRead((Resolve-Path $zip)) try { $names = $z.Entries.Name # 必须随包分发 foreach ($need in @('install.cmd','control.cmd','install.ps1','control.ps1','README.txt','agent.mjs','node.exe')) { if ($names -notcontains $need) { throw "missing in package: $need" } } # 必须**不**在包内:PSK.txt 是现生成给本机的,打进公开资产等于泄露密钥 # (eaca9e0 起已从 zip 剔除;此处反向断言守住该性质) foreach ($forbidden in @('PSK.txt','*.key','*.pem','certs')) { if ($names -contains $forbidden) { throw "package must NOT contain: $forbidden" } } if ($names -match 'PSK\.txt') { throw "package leaks PSK.txt" } foreach ($e in $z.Entries) { $s = $e.Open(); $buf = New-Object byte[] 3 [void]$s.Read($buf, 0, 3); $s.Close() $hasBom = ($buf[0] -eq 0xEF -and $buf[1] -eq 0xBB -and $buf[2] -eq 0xBF) if ($e.Name -like '*.ps1' -and -not $hasBom) { throw "$($e.Name) lost its BOM in package" } if ($e.Name -like '*.cmd' -and $hasBom) { throw "$($e.Name) must not have a BOM" } } Write-Host "OK - package complete; .ps1 has BOM / .cmd has none; no key shipped" } finally { $z.Dispose() } - name: 冒烟 — 被控端监听 shell: pwsh run: | Start-Process -FilePath "node" -ArgumentList "apps/agent/dist/index.js" -NoNewWindow Start-Sleep -Seconds 4 $ok = (Test-NetConnection 127.0.0.1 -Port 18769 -WarningAction SilentlyContinue).TcpTestSucceeded if (-not $ok) { throw "agent 未监听 18769" } Write-Host "agent 已在 18769 监听" - name: 冒烟 — CLI 连入(wss + 自签证书 + HMAC 鉴权) shell: pwsh run: | $cfg = Get-Content "$env:USERPROFILE\.nodeagent\agent.json" -Raw | ConvertFrom-Json node apps/cli/dist/index.js connect 127.0.0.1 --port 18769 --key $cfg.key --insecure Write-Host "--- status ---" node apps/cli/dist/index.js status Write-Host "--- services ---" node apps/cli/dist/index.js services --limit 5 - name: 冒烟 — MCP 工具列表 shell: pwsh run: | $cfg = Get-Content "$env:USERPROFILE\.nodeagent\agent.json" -Raw | ConvertFrom-Json @{ client_id="ci"; host="127.0.0.1"; port=18769; tls=$true; insecure=$true; key=$cfg.key } | ConvertTo-Json | Set-Content "$env:USERPROFILE\.nodeagent\config.json" -Encoding UTF8 $req = @( '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"ci","version":"1.0"}}}', '{"jsonrpc":"2.0","method":"notifications/initialized"}', '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' ) -join "`n" ($req + "`n") | node apps/mcp/dist/index.js 2>$null | Select-String "na_status" - name: 清理 if: always() # 清理失败不掩盖真实结论(前置挂了会连带这里报错) continue-on-error: true shell: powershell run: | Get-Process node -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue ./scripts/uninstall.ps1 -Port 18769 -Purge