From c8a1054fe77e8babce10b10e4334c36cd1810c38 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 22 Oct 2026 23:50:57 +0800 Subject: [PATCH 1/4] fix: validate Windows packages and prepare v0.4.22 --- .github/workflows/release.yml | 38 ++++- CHANGELOG.md | 17 ++ README.md | 14 +- README_EN.md | 14 +- README_JA.md | 14 +- README_KO.md | 14 +- README_TH.md | 14 +- README_VI.md | 14 +- docs/RELEASE_NOTES_v0.4.22.md | 87 ++++++++++ package.json | 2 +- scripts/capture_ui_gallery.mjs | 112 +++++------- scripts/check_release_version.mjs | 27 +++ scripts/lib/windows_pe_dependencies.mjs | 2 +- scripts/start_dev_app.mjs | 25 ++- scripts/teacher_llm_real_smoke.mjs | 2 +- scripts/teacher_llm_smoke.mjs | 123 ++++++++++---- src/main/services/ikatagoClientEngine.ts | 35 +++- src/main/services/katagoRuntime.ts | 21 ++- src/main/services/pythonRuntime.ts | 31 +++- src/main/services/review.ts | 9 +- src/main/services/teacher/shellCommand.ts | 18 ++ src/main/services/teacherAgent.ts | 16 +- src/main/services/tts/misakiZhG2p.ts | 4 +- src/renderer/src/styles.css | 41 +++-- tests/analysis-trust-ui-contract.test.mjs | 4 +- tests/misaki-python-encoding.test.mjs | 82 +++++++++ tests/release-preflight.test.mjs | 101 +++++++++++ tests/sprint7-ui-polish-contract.test.mjs | 6 +- tests/windows-katago-dependencies.test.mjs | 10 ++ tests/windows-process-runtime.test.mjs | 189 +++++++++++++++++++++ 30 files changed, 893 insertions(+), 193 deletions(-) create mode 100644 docs/RELEASE_NOTES_v0.4.22.md create mode 100644 scripts/check_release_version.mjs create mode 100644 src/main/services/teacher/shellCommand.ts create mode 100644 tests/misaki-python-encoding.test.mjs create mode 100644 tests/release-preflight.test.mjs create mode 100644 tests/windows-process-runtime.test.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9306796..81ce7a9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,6 +58,42 @@ jobs: can-package-nvidia: ${{ steps.katago-assets.outputs.can-package-nvidia }} steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.30.3 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Check release tag matches package version + run: node scripts/check_release_version.mjs + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Test + run: pnpm test + + - name: Typecheck + run: pnpm typecheck + + - name: Teacher quality + run: pnpm check:teacher-quality + + - name: Release source policy and multilingual notes + run: | + pnpm check:codex-runtime + pnpm check:no-lite-release-assets + pnpm check:nvidia-release-assets + pnpm check:release-notes-i18n + - name: Check KataGo asset sources id: katago-assets shell: bash @@ -696,7 +732,7 @@ jobs: - package - package-nvidia-windows runs-on: ubuntu-latest - if: always() && needs.release-preflight.outputs.can-package == 'true' && needs.release-preflight.outputs.can-package-nvidia == 'true' && needs.package.result == 'success' && needs.package-nvidia-windows.result == 'success' && startsWith(github.ref, 'refs/tags/') + if: always() && needs.release-preflight.result == 'success' && needs.release-preflight.outputs.can-package == 'true' && needs.release-preflight.outputs.can-package-nvidia == 'true' && needs.package.result == 'success' && needs.package-nvidia-windows.result == 'success' && startsWith(github.ref, 'refs/tags/') steps: - name: Checkout release notes diff --git a/CHANGELOG.md b/CHANGELOG.md index 46f5cd0..8bb2ae2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to GoAgent will be documented here. This project follows semantic versioning once public releases begin. +## 0.4.22 - Windows Compatibility and Release Validation + +### Fixed + +- Corrected Windows file URL conversion and process launching in development and teacher smoke scripts, including paths with spaces and non-ASCII characters. +- Preserved Windows backslashes in iKataGo arguments and selected PowerShell for local teacher commands, with accurate startup error handling. +- Copied the Python review script and requirements out of `app.asar` into the app's runtime directory so packaged reviews can run independently of the launch directory. +- Forced Python speech subprocesses to use UTF-8, fixing offline speech encoding errors on Chinese Windows systems. +- Recognized driver-provided `OpenCL.dll` as a Windows system dependency while retaining checks for bundled compute libraries. +- Prevented analysis controls from overlapping the teacher panel in narrow windows and scaled the board to the space available below the wrapping header. +- Included the NVIDIA NVRTC dependency fix and the startup timeout, analysis rendering, navigation, and retry improvements merged since v0.4.21. + +### Changed + +- Required automated tests, typechecking, teaching quality, release source policy, multilingual notes, and tag/package version consistency before release packaging can publish. +- Added Windows process and release failure regression coverage. + ## 0.4.21 - Optional ChatGPT Sign-In ### Added diff --git a/README.md b/README.md index 8d006ec..e61e283 100644 --- a/README.md +++ b/README.md @@ -49,16 +49,16 @@ KataGo 是事实裁判,LLM 是讲棋老师。GoAgent 的目标是让学生不 当前发布版本: -[GoAgent v0.4.21](https://github.com/wimi321/GoAgent/releases/tag/v0.4.21) +[GoAgent v0.4.22](https://github.com/wimi321/GoAgent/releases/tag/v0.4.22) | 平台 | 下载 | | --- | --- | -| macOS Apple Silicon | [GoAgent-0.4.21-mac-arm64.dmg](https://github.com/wimi321/GoAgent/releases/download/v0.4.21/GoAgent-0.4.21-mac-arm64.dmg) | -| macOS Intel | [GoAgent-0.4.21-mac-x64.dmg](https://github.com/wimi321/GoAgent/releases/download/v0.4.21/GoAgent-0.4.21-mac-x64.dmg) | -| Windows x64 标准版免安装 ZIP | [GoAgent-0.4.21-win-x64-portable.zip](https://github.com/wimi321/GoAgent/releases/download/v0.4.21/GoAgent-0.4.21-win-x64-portable.zip) | -| Windows x64 标准版安装包 | [GoAgent-0.4.21-win-x64.exe](https://github.com/wimi321/GoAgent/releases/download/v0.4.21/GoAgent-0.4.21-win-x64.exe) | -| Windows x64 NVIDIA 免安装 7z | [GoAgent-0.4.21-win-x64-nvidia-portable.7z](https://github.com/wimi321/GoAgent/releases/download/v0.4.21/GoAgent-0.4.21-win-x64-nvidia-portable.7z) | -| Windows x64 NVIDIA 安装包 | [GoAgent-0.4.21-win-x64-nvidia.exe](https://github.com/wimi321/GoAgent/releases/download/v0.4.21/GoAgent-0.4.21-win-x64-nvidia.exe) | +| macOS Apple Silicon | [GoAgent-0.4.22-mac-arm64.dmg](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-mac-arm64.dmg) | +| macOS Intel | [GoAgent-0.4.22-mac-x64.dmg](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-mac-x64.dmg) | +| Windows x64 标准版免安装 ZIP | [GoAgent-0.4.22-win-x64-portable.zip](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-win-x64-portable.zip) | +| Windows x64 标准版安装包 | [GoAgent-0.4.22-win-x64.exe](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-win-x64.exe) | +| Windows x64 NVIDIA 免安装 7z | [GoAgent-0.4.22-win-x64-nvidia-portable.7z](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-win-x64-nvidia-portable.7z) | +| Windows x64 NVIDIA 安装包 | [GoAgent-0.4.22-win-x64-nvidia.exe](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-win-x64-nvidia.exe) | 发布说明: diff --git a/README_EN.md b/README_EN.md index 55d630c..6512271 100644 --- a/README_EN.md +++ b/README_EN.md @@ -49,16 +49,16 @@ KataGo is the source of truth. The LLM is the teacher that turns those facts int Current release: -[GoAgent v0.4.21](https://github.com/wimi321/GoAgent/releases/tag/v0.4.21) +[GoAgent v0.4.22](https://github.com/wimi321/GoAgent/releases/tag/v0.4.22) | Platform | Download | | --- | --- | -| macOS Apple Silicon | [GoAgent-0.4.21-mac-arm64.dmg](https://github.com/wimi321/GoAgent/releases/download/v0.4.21/GoAgent-0.4.21-mac-arm64.dmg) | -| macOS Intel | [GoAgent-0.4.21-mac-x64.dmg](https://github.com/wimi321/GoAgent/releases/download/v0.4.21/GoAgent-0.4.21-mac-x64.dmg) | -| Windows x64 Standard portable ZIP | [GoAgent-0.4.21-win-x64-portable.zip](https://github.com/wimi321/GoAgent/releases/download/v0.4.21/GoAgent-0.4.21-win-x64-portable.zip) | -| Windows x64 Standard installer | [GoAgent-0.4.21-win-x64.exe](https://github.com/wimi321/GoAgent/releases/download/v0.4.21/GoAgent-0.4.21-win-x64.exe) | -| Windows x64 NVIDIA portable 7z | [GoAgent-0.4.21-win-x64-nvidia-portable.7z](https://github.com/wimi321/GoAgent/releases/download/v0.4.21/GoAgent-0.4.21-win-x64-nvidia-portable.7z) | -| Windows x64 NVIDIA installer | [GoAgent-0.4.21-win-x64-nvidia.exe](https://github.com/wimi321/GoAgent/releases/download/v0.4.21/GoAgent-0.4.21-win-x64-nvidia.exe) | +| macOS Apple Silicon | [GoAgent-0.4.22-mac-arm64.dmg](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-mac-arm64.dmg) | +| macOS Intel | [GoAgent-0.4.22-mac-x64.dmg](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-mac-x64.dmg) | +| Windows x64 Standard portable ZIP | [GoAgent-0.4.22-win-x64-portable.zip](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-win-x64-portable.zip) | +| Windows x64 Standard installer | [GoAgent-0.4.22-win-x64.exe](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-win-x64.exe) | +| Windows x64 NVIDIA portable 7z | [GoAgent-0.4.22-win-x64-nvidia-portable.7z](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-win-x64-nvidia-portable.7z) | +| Windows x64 NVIDIA installer | [GoAgent-0.4.22-win-x64-nvidia.exe](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-win-x64-nvidia.exe) | Release caveats: diff --git a/README_JA.md b/README_JA.md index 68ee7e1..3aae2ef 100644 --- a/README_JA.md +++ b/README_JA.md @@ -39,16 +39,16 @@ GoAgent は、KataGo、棋盤スクリーンショット、ローカル知識カ 公開リリース: -[GoAgent v0.4.21](https://github.com/wimi321/GoAgent/releases/tag/v0.4.21) +[GoAgent v0.4.22](https://github.com/wimi321/GoAgent/releases/tag/v0.4.22) | プラットフォーム | ダウンロード | | --- | --- | -| macOS Apple Silicon | [DMG](https://github.com/wimi321/GoAgent/releases/download/v0.4.21/GoAgent-0.4.21-mac-arm64.dmg) | -| macOS Intel | [DMG](https://github.com/wimi321/GoAgent/releases/download/v0.4.21/GoAgent-0.4.21-mac-x64.dmg) | -| Windows x64 Standard portable ZIP | [ZIP](https://github.com/wimi321/GoAgent/releases/download/v0.4.21/GoAgent-0.4.21-win-x64-portable.zip) | -| Windows x64 Standard installer | [EXE](https://github.com/wimi321/GoAgent/releases/download/v0.4.21/GoAgent-0.4.21-win-x64.exe) | -| Windows x64 NVIDIA portable 7z | [7z](https://github.com/wimi321/GoAgent/releases/download/v0.4.21/GoAgent-0.4.21-win-x64-nvidia-portable.7z) | -| Windows x64 NVIDIA installer | [EXE](https://github.com/wimi321/GoAgent/releases/download/v0.4.21/GoAgent-0.4.21-win-x64-nvidia.exe) | +| macOS Apple Silicon | [DMG](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-mac-arm64.dmg) | +| macOS Intel | [DMG](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-mac-x64.dmg) | +| Windows x64 Standard portable ZIP | [ZIP](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-win-x64-portable.zip) | +| Windows x64 Standard installer | [EXE](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-win-x64.exe) | +| Windows x64 NVIDIA portable 7z | [7z](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-win-x64-nvidia-portable.7z) | +| Windows x64 NVIDIA installer | [EXE](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-win-x64-nvidia.exe) | macOS 版は Developer ID で署名され、Apple の notarization と stapled ticket を取得済みです。アップロード前に署名、Gatekeeper、ticket、DMG の整合性を CI で検証します。Windows 版は未署名のため SmartScreen が表示される場合があります。 diff --git a/README_KO.md b/README_KO.md index dc1860c..6c3683d 100644 --- a/README_KO.md +++ b/README_KO.md @@ -39,16 +39,16 @@ GoAgent는 KataGo, 바둑판 스크린샷, 로컬 지식 카드, 학생 프로 공개 릴리스: -[GoAgent v0.4.21](https://github.com/wimi321/GoAgent/releases/tag/v0.4.21) +[GoAgent v0.4.22](https://github.com/wimi321/GoAgent/releases/tag/v0.4.22) | 플랫폼 | 다운로드 | | --- | --- | -| macOS Apple Silicon | [DMG](https://github.com/wimi321/GoAgent/releases/download/v0.4.21/GoAgent-0.4.21-mac-arm64.dmg) | -| macOS Intel | [DMG](https://github.com/wimi321/GoAgent/releases/download/v0.4.21/GoAgent-0.4.21-mac-x64.dmg) | -| Windows x64 Standard portable ZIP | [ZIP](https://github.com/wimi321/GoAgent/releases/download/v0.4.21/GoAgent-0.4.21-win-x64-portable.zip) | -| Windows x64 Standard installer | [EXE](https://github.com/wimi321/GoAgent/releases/download/v0.4.21/GoAgent-0.4.21-win-x64.exe) | -| Windows x64 NVIDIA portable 7z | [7z](https://github.com/wimi321/GoAgent/releases/download/v0.4.21/GoAgent-0.4.21-win-x64-nvidia-portable.7z) | -| Windows x64 NVIDIA installer | [EXE](https://github.com/wimi321/GoAgent/releases/download/v0.4.21/GoAgent-0.4.21-win-x64-nvidia.exe) | +| macOS Apple Silicon | [DMG](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-mac-arm64.dmg) | +| macOS Intel | [DMG](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-mac-x64.dmg) | +| Windows x64 Standard portable ZIP | [ZIP](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-win-x64-portable.zip) | +| Windows x64 Standard installer | [EXE](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-win-x64.exe) | +| Windows x64 NVIDIA portable 7z | [7z](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-win-x64-nvidia-portable.7z) | +| Windows x64 NVIDIA installer | [EXE](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-win-x64-nvidia.exe) | macOS 앱은 Developer ID로 서명되고 Apple notarization 및 stapled ticket 검증을 완료했습니다. CI가 업로드 전에 서명, Gatekeeper, ticket, DMG 무결성을 확인합니다. Windows 패키지는 아직 서명되지 않아 SmartScreen이 표시될 수 있습니다. diff --git a/README_TH.md b/README_TH.md index 7c71a68..2d57cd4 100644 --- a/README_TH.md +++ b/README_TH.md @@ -39,16 +39,16 @@ GoAgent เป็นแอปเดสก์ท็อปแบบ local-first รุ่นเผยแพร่สาธารณะ: -[GoAgent v0.4.21](https://github.com/wimi321/GoAgent/releases/tag/v0.4.21) +[GoAgent v0.4.22](https://github.com/wimi321/GoAgent/releases/tag/v0.4.22) | แพลตฟอร์ม | ดาวน์โหลด | | --- | --- | -| macOS Apple Silicon | [DMG](https://github.com/wimi321/GoAgent/releases/download/v0.4.21/GoAgent-0.4.21-mac-arm64.dmg) | -| macOS Intel | [DMG](https://github.com/wimi321/GoAgent/releases/download/v0.4.21/GoAgent-0.4.21-mac-x64.dmg) | -| Windows x64 Standard portable ZIP | [ZIP](https://github.com/wimi321/GoAgent/releases/download/v0.4.21/GoAgent-0.4.21-win-x64-portable.zip) | -| Windows x64 Standard installer | [EXE](https://github.com/wimi321/GoAgent/releases/download/v0.4.21/GoAgent-0.4.21-win-x64.exe) | -| Windows x64 NVIDIA portable 7z | [7z](https://github.com/wimi321/GoAgent/releases/download/v0.4.21/GoAgent-0.4.21-win-x64-nvidia-portable.7z) | -| Windows x64 NVIDIA installer | [EXE](https://github.com/wimi321/GoAgent/releases/download/v0.4.21/GoAgent-0.4.21-win-x64-nvidia.exe) | +| macOS Apple Silicon | [DMG](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-mac-arm64.dmg) | +| macOS Intel | [DMG](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-mac-x64.dmg) | +| Windows x64 Standard portable ZIP | [ZIP](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-win-x64-portable.zip) | +| Windows x64 Standard installer | [EXE](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-win-x64.exe) | +| Windows x64 NVIDIA portable 7z | [7z](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-win-x64-nvidia-portable.7z) | +| Windows x64 NVIDIA installer | [EXE](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-win-x64-nvidia.exe) | แอป macOS ลงนามด้วย Developer ID ผ่าน Apple notarization และมี stapled ticket แล้ว โดย CI ตรวจลายเซ็น Gatekeeper ticket และความสมบูรณ์ของ DMG ก่อนอัปโหลด ส่วนแพ็กเกจ Windows ยังไม่ได้ลงนามและอาจแสดง SmartScreen diff --git a/README_VI.md b/README_VI.md index 8f69efc..c4912bb 100644 --- a/README_VI.md +++ b/README_VI.md @@ -39,16 +39,16 @@ GoAgent là ứng dụng desktop local-first cho người học và giáo viên Bản phát hành công khai: -[GoAgent v0.4.21](https://github.com/wimi321/GoAgent/releases/tag/v0.4.21) +[GoAgent v0.4.22](https://github.com/wimi321/GoAgent/releases/tag/v0.4.22) | Nền tảng | Tải xuống | | --- | --- | -| macOS Apple Silicon | [DMG](https://github.com/wimi321/GoAgent/releases/download/v0.4.21/GoAgent-0.4.21-mac-arm64.dmg) | -| macOS Intel | [DMG](https://github.com/wimi321/GoAgent/releases/download/v0.4.21/GoAgent-0.4.21-mac-x64.dmg) | -| Windows x64 Standard portable ZIP | [ZIP](https://github.com/wimi321/GoAgent/releases/download/v0.4.21/GoAgent-0.4.21-win-x64-portable.zip) | -| Windows x64 Standard installer | [EXE](https://github.com/wimi321/GoAgent/releases/download/v0.4.21/GoAgent-0.4.21-win-x64.exe) | -| Windows x64 NVIDIA portable 7z | [7z](https://github.com/wimi321/GoAgent/releases/download/v0.4.21/GoAgent-0.4.21-win-x64-nvidia-portable.7z) | -| Windows x64 NVIDIA installer | [EXE](https://github.com/wimi321/GoAgent/releases/download/v0.4.21/GoAgent-0.4.21-win-x64-nvidia.exe) | +| macOS Apple Silicon | [DMG](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-mac-arm64.dmg) | +| macOS Intel | [DMG](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-mac-x64.dmg) | +| Windows x64 Standard portable ZIP | [ZIP](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-win-x64-portable.zip) | +| Windows x64 Standard installer | [EXE](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-win-x64.exe) | +| Windows x64 NVIDIA portable 7z | [7z](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-win-x64-nvidia-portable.7z) | +| Windows x64 NVIDIA installer | [EXE](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-win-x64-nvidia.exe) | Ứng dụng macOS đã được ký bằng Developer ID, Apple notarization và stapled ticket. CI xác minh chữ ký, Gatekeeper, ticket và tính toàn vẹn DMG trước khi tải lên. Gói Windows hiện chưa ký nên có thể hiển thị SmartScreen. diff --git a/docs/RELEASE_NOTES_v0.4.22.md b/docs/RELEASE_NOTES_v0.4.22.md new file mode 100644 index 0000000..0d9c8cf --- /dev/null +++ b/docs/RELEASE_NOTES_v0.4.22.md @@ -0,0 +1,87 @@ +# GoAgent v0.4.22 + +Windows compatibility and release validation update. This release also includes the startup, analysis rendering, retry handling, and NVIDIA NVRTC fixes merged since v0.4.21. + +The bundled engines remain KataGo 1.17.1 with the official Transformer 10B Balanced model: Metal on macOS, OpenCL on standard Windows, and CUDA/CUDNN in the NVIDIA edition. SHA-256 verification, the official Zhizi API, and the multilingual download center remain available; local KataGo remains the default. KataGo 1.17.2 is reserved for upstream TensorRT-specific fixes. + +## 中文 + +- 修复 Windows 开发启动和老师冒烟测试中的盘符、文件 URL 与 `.cmd` 启动问题,支持带空格、中文的路径。 +- 修复 iKataGo 参数中的 Windows 反斜杠被吞掉的问题;老师的本机命令工具使用 Windows PowerShell,并正确报告进程启动失败。 +- 将 Python 复盘脚本和依赖清单从 `app.asar` 提取到应用运行目录,修复打包应用无法执行复盘脚本及依赖启动目录的问题。 +- 强制 Python 语音子进程使用 UTF-8,修复中文 Windows 系统上的离线语音编码错误。 +- 将显卡驱动提供的 `OpenCL.dll` 正确识别为 Windows 系统依赖,同时保留其他计算运行库的随包完整性检查。 +- 修复窄窗口下分析按钮被老师面板遮挡的问题;标题栏可换行,棋盘按剩余可用空间缩放。 +- 发布前强制运行测试、类型检查、教学质量与发布文档检查,验证标签和应用版本一致;任一步失败都会阻止发布。 +- 包含此前合入的 NVIDIA NVRTC 运行库修复、启动超时限制、分析界面性能和重试改进。 + +### 下载 / Downloads + +| 平台 / 场景 | 下载 | +| --- | --- | +| macOS Apple Silicon | [GoAgent-0.4.22-mac-arm64.dmg](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-mac-arm64.dmg) | +| macOS Intel | [GoAgent-0.4.22-mac-x64.dmg](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-mac-x64.dmg) | +| Windows 标准版 OpenCL 免安装 portable ZIP | [GoAgent-0.4.22-win-x64-portable.zip](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-win-x64-portable.zip) | +| Windows 标准版 OpenCL 安装版 installer | [GoAgent-0.4.22-win-x64.exe](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-win-x64.exe) | +| Windows NVIDIA CUDA/CUDNN 免安装 portable 7z | [GoAgent-0.4.22-win-x64-nvidia-portable.7z](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-win-x64-nvidia-portable.7z) | +| Windows NVIDIA CUDA/CUDNN 安装版 installer | [GoAgent-0.4.22-win-x64-nvidia.exe](https://github.com/wimi321/GoAgent/releases/download/v0.4.22/GoAgent-0.4.22-win-x64-nvidia.exe) | + +## 繁體中文 + +- 修正 Windows 開發啟動、老師測試與包含空格或中文的路徑處理。 +- 保留 iKataGo 參數中的 Windows 反斜線;本機命令工具使用 PowerShell 並正確回報啟動錯誤。 +- 將 Python 複盤腳本與相依套件清單從 `app.asar` 提取至應用程式執行目錄,修正打包應用程式無法執行複盤腳本及依賴啟動目錄的問題。 +- 強制 Python 語音子程序使用 UTF-8,修正中文 Windows 系統上的離線語音編碼錯誤。 +- 將顯示卡驅動程式提供的 `OpenCL.dll` 正確識別為 Windows 系統相依項目,同時保留其他計算執行庫的隨包完整性檢查。 +- 修正窄視窗下分析按鈕被老師面板遮擋的問題;標題列可換行,棋盤會依剩餘可用空間縮放。 +- 發布前強制檢查測試、型別、教學品質、發布文件及版本標籤;包含 NVIDIA NVRTC 與分析效能修正。 + +## English + +- Fixes Windows development and teacher smoke startup, including file URLs and paths containing spaces or Chinese characters. +- Preserves backslashes in iKataGo arguments and uses PowerShell for Windows teacher commands, with explicit process startup failure reporting. +- Extracts the Python review script and requirements from `app.asar` into the app's runtime directory so packaged reviews can run independently of the launch directory. +- Forces Python speech subprocesses to use UTF-8, fixing offline speech encoding errors on Chinese Windows systems. +- Recognizes driver-provided `OpenCL.dll` as a Windows system dependency while retaining checks for bundled compute libraries. +- Prevents analysis buttons from being covered by the teacher panel in narrow windows. The header wraps and the board scales to the remaining available space. +- Gates releases on tests, typechecking, teaching quality, release documentation, and matching tag/package versions. Includes the previously merged NVIDIA NVRTC, startup, analysis performance, and retry fixes. + +## 日本語 + +- Windows の開発起動と先生機能のテストを修正し、空白や中国語を含むパスに対応しました。 +- iKataGo の引数内のバックスラッシュを保持し、Windows のローカルコマンドには PowerShell を使用して起動エラーを報告します。 +- Python の棋譜検討スクリプトと依存関係リストを `app.asar` からアプリの実行用ディレクトリへ展開し、パッケージ版でも起動ディレクトリに依存せず検討を実行できるようにしました。 +- Python の音声処理サブプロセスで UTF-8 を使用し、中国語環境の Windows で発生するオフライン音声の文字コードエラーを修正します。 +- GPU ドライバーが提供する `OpenCL.dll` を Windows のシステム依存ライブラリとして認識し、その他の計算用ライブラリの同梱チェックは維持します。 +- 狭いウィンドウで分析ボタンが先生パネルに隠れる問題を修正しました。ヘッダーは折り返され、盤面は残りの表示領域に合わせて拡縮されます。 +- テスト、型検査、教材品質、リリース文書とバージョン一致を公開前に検証します。NVIDIA NVRTC と分析性能の修正も含みます。 + +## 한국어 + +- Windows 개발 실행과 교사 기능 테스트를 수정하고 공백이나 중국어가 포함된 경로를 처리합니다. +- iKataGo 인수의 역슬래시를 보존하고 Windows 명령에 PowerShell을 사용하며 실행 오류를 보고합니다. +- Python 복기 스크립트와 의존성 목록을 `app.asar`에서 앱의 런타임 디렉터리로 추출하여 패키지 앱에서도 시작 디렉터리와 관계없이 복기를 실행할 수 있도록 수정했습니다. +- Python 음성 하위 프로세스가 UTF-8을 사용하도록 하여 중국어 Windows 환경의 오프라인 음성 인코딩 오류를 수정합니다. +- 그래픽 드라이버가 제공하는 `OpenCL.dll`을 Windows 시스템 의존성으로 인식하며, 다른 연산 라이브러리의 패키지 포함 여부 검사는 유지합니다. +- 좁은 창에서 분석 버튼이 교사 패널에 가려지는 문제를 수정했습니다. 헤더는 줄바꿈되고 바둑판은 남은 공간에 맞게 크기가 조정됩니다. +- 배포 전에 테스트, 타입 검사, 교육 품질, 배포 문서와 버전 일치를 확인합니다. NVIDIA NVRTC 및 분석 성능 수정도 포함합니다. + +## ภาษาไทย + +- แก้ไขการเริ่มแอปและการทดสอบครูบน Windows รวมถึงพาธที่มีช่องว่างหรืออักษรจีน +- รักษาเครื่องหมายแบ็กสแลชในอาร์กิวเมนต์ iKataGo ใช้ PowerShell สำหรับคำสั่งบน Windows และรายงานข้อผิดพลาดในการเริ่มโปรเซส +- แยกสคริปต์ทบทวนเกม Python และรายการไลบรารีที่จำเป็นออกจาก `app.asar` ไปยังไดเรกทอรีรันไทม์ของแอป เพื่อให้แอปที่แพ็กแล้วทบทวนเกมได้โดยไม่ขึ้นกับไดเรกทอรีที่ใช้เริ่มแอป +- กำหนดให้โปรเซสย่อย Python สำหรับเสียงใช้ UTF-8 เพื่อแก้ไขข้อผิดพลาดด้านการเข้ารหัสเสียงออฟไลน์บน Windows ภาษาจีน +- ระบุ `OpenCL.dll` ที่ไดรเวอร์การ์ดจอจัดเตรียมไว้เป็นส่วนประกอบของระบบ Windows อย่างถูกต้อง และยังคงตรวจสอบว่าไลบรารีประมวลผลอื่น ๆ รวมอยู่ในแพ็กเกจ +- แก้ไขปุ่มวิเคราะห์ที่ถูกแผงครูบังเมื่อหน้าต่างแคบ โดยให้ส่วนหัวขึ้นบรรทัดใหม่ได้และปรับขนาดกระดานตามพื้นที่ที่เหลือ +- ตรวจสอบการทดสอบ ชนิดข้อมูล คุณภาพการสอน เอกสาร และหมายเลขรุ่นก่อนเผยแพร่ พร้อมการแก้ไข NVIDIA NVRTC และประสิทธิภาพการวิเคราะห์ + +## Tiếng Việt + +- Sửa khởi chạy môi trường phát triển và kiểm thử giáo viên trên Windows, gồm đường dẫn có dấu cách hoặc chữ Trung Quốc. +- Giữ dấu gạch chéo ngược trong tham số iKataGo, dùng PowerShell cho lệnh Windows và báo lỗi khởi chạy tiến trình. +- Trích xuất tập lệnh phân tích ván đấu bằng Python và danh sách thư viện phụ thuộc từ `app.asar` vào thư mục chạy của ứng dụng, giúp bản đóng gói thực hiện phân tích mà không phụ thuộc vào thư mục khởi chạy. +- Buộc các tiến trình con Python xử lý giọng nói sử dụng UTF-8, khắc phục lỗi mã hóa giọng nói ngoại tuyến trên Windows tiếng Trung. +- Nhận diện đúng `OpenCL.dll` do trình điều khiển đồ họa cung cấp là thư viện hệ thống Windows, đồng thời tiếp tục kiểm tra các thư viện tính toán khác được đóng gói kèm ứng dụng. +- Sửa lỗi bảng giáo viên che nút phân tích khi cửa sổ hẹp. Phần tiêu đề có thể xuống dòng và bàn cờ được điều chỉnh theo không gian còn lại. +- Kiểm tra bài thử, kiểu dữ liệu, chất lượng giảng dạy, tài liệu phát hành và phiên bản trước khi công bố. Bao gồm các bản sửa NVIDIA NVRTC và hiệu năng phân tích. diff --git a/package.json b/package.json index fd571d1..1ce3f6d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "goagent", - "version": "0.4.21", + "version": "0.4.22", "description": "Agentic AI teacher for Go / Weiqi / Baduk, powered by KataGo and multimodal LLMs.", "main": "out/main/index.js", "type": "module", diff --git a/scripts/capture_ui_gallery.mjs b/scripts/capture_ui_gallery.mjs index 20d4f72..00d490d 100644 --- a/scripts/capture_ui_gallery.mjs +++ b/scripts/capture_ui_gallery.mjs @@ -1,9 +1,14 @@ #!/usr/bin/env node import { createServer } from 'node:http' import { existsSync } from 'node:fs' -import { access, mkdir, readFile } from 'node:fs/promises' -import { dirname, extname, join, resolve, sep } from 'node:path' -import { spawn, spawnSync } from 'node:child_process' +import { access, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { extname, join, resolve, sep } from 'node:path' +import { spawn } from 'node:child_process' +import { pathToFileURL } from 'node:url' + +const require = createRequire(import.meta.url) const configuredUrl = process.env.GOAGENT_UI_GALLERY_URL?.trim() const rendererRoot = resolve('out/renderer') @@ -47,7 +52,9 @@ function run(command, args, options = {}) { async function ensureRendererBuild() { if (!skipBuild) { - await run('pnpm', ['build'], { cwd: process.cwd(), env: process.env }) + const packagePath = require.resolve('electron-vite/package.json') + const bin = require(packagePath).bin['electron-vite'] + await run(process.execPath, [resolve(packagePath, '..', bin), 'build'], { cwd: process.cwd(), env: process.env }) } await access(join(rendererRoot, 'index.html')).catch(() => { throw new Error('UI Gallery build is missing. Run `pnpm build` or omit `--skip-build`.') @@ -116,77 +123,50 @@ function systemBrowserPath() { async function loadPlaywright() { try { - return await import('playwright') + const loaded = process.env.GOAGENT_PLAYWRIGHT_MODULE + ? await import(pathToFileURL(process.env.GOAGENT_PLAYWRIGHT_MODULE).href) + : await import('playwright') + return loaded.chromium ? loaded : loaded.default } catch { return null } } async function captureWithCliFallback(url) { - await mkdir(outDir, { recursive: true }) - const whichResult = spawnSync('npx', ['--yes', '-p', 'playwright', 'which', 'playwright'], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'inherit'] - }) - const playwrightBin = whichResult.stdout.trim() - if (whichResult.status !== 0 || !playwrightBin) { - throw new Error('Playwright package is unavailable and npx could not prepare it. Open this self-hosted route manually: ' + url) + // npm/pnpm expose their JS entry when invoked as a package script. Running it + // with Node avoids .cmd spawning and shell quoting on Windows. + const manager = process.env.npm_execpath + if (!manager || !/\.(?:c?js|mjs)$/i.test(manager)) { + throw new Error('Playwright is unavailable. Run `pnpm capture:ui-gallery` to prepare it, or install Playwright locally. Preview: ' + url) } - const playwrightNodeModules = dirname(dirname(playwrightBin)) - const fallbackScript = ` - const { mkdir } = require('node:fs/promises') - const { createRequire } = require('node:module') - const { join } = require('node:path') - const requireFromPlaywright = createRequire(join(process.env.PLAYWRIGHT_NODE_MODULES, 'playwright', 'package.json')) - const { chromium } = requireFromPlaywright('playwright') - - ;(async () => { - const url = process.env.GOAGENT_CAPTURE_URL - const outDir = process.env.GOAGENT_CAPTURE_OUT - const targets = ${JSON.stringify(captureTargets)} - - await mkdir(outDir, { recursive: true }) - const browserPath = process.env.GOAGENT_BROWSER_PATH || undefined - const browser = await chromium.launch({ headless: true, executablePath: browserPath }) - const page = await browser.newPage({ viewport: { width: 1440, height: 1100 }, deviceScaleFactor: 1 }) - await page.goto(url, { waitUntil: 'domcontentloaded' }) - await page.locator('.ui-gallery').waitFor({ state: 'visible' }) - await page.screenshot({ path: join(outDir, 'ui-gallery-overview.png'), fullPage: true }) - for (const [name, selector] of targets) { - const locator = page.locator(selector).first() - if (await locator.count()) { - await locator.screenshot({ path: join(outDir, name + '.png') }) - } - } - const bindButton = page.getByRole('button', { name: '打开 SGF 绑定弹窗' }) - if (await bindButton.count()) { - await bindButton.click() - await page.locator('.student-dialog').screenshot({ path: join(outDir, 'student-bind-dialog.png') }) + const temporary = await mkdtemp(join(tmpdir(), 'goagent-gallery-')) + try { + const wrapper = join(temporary, 'capture.cjs') + await writeFile(wrapper, ` + const { existsSync } = require('node:fs') + const { delimiter, join } = require('node:path') + const { createRequire } = require('node:module') + const packageJson = (process.env.PATH || '').split(delimiter) + .map(directory => join(directory.replace(/^"|"$/g, ''), '..', 'playwright', 'package.json')) + .find(file => existsSync(file)) + if (!packageJson) throw new Error('The package runner did not provide Playwright') + process.env.GOAGENT_PLAYWRIGHT_MODULE = createRequire(packageJson).resolve('playwright') + import(${JSON.stringify(import.meta.url)}).catch(error => { console.error(error); process.exitCode = 1 }) + `, 'utf8') + const managerArgs = /pnpm/i.test(manager) + ? ['--package=playwright', 'dlx', 'node', wrapper] + : ['exec', '--yes', '--package=playwright', '--', 'node', wrapper] + await run(process.execPath, [manager, ...managerArgs], { + env: { + ...process.env, + GOAGENT_UI_GALLERY_URL: url, + GOAGENT_UI_GALLERY_OUT: outDir, + GOAGENT_BROWSER_PATH: systemBrowserPath() } - await browser.close() - })().catch((error) => { - console.error(error.message) - process.exit(1) }) - ` - await run('npx', [ - '--yes', - '-p', - 'playwright', - 'node', - '-e', - fallbackScript - ], { - env: { - ...process.env, - GOAGENT_CAPTURE_URL: url, - GOAGENT_CAPTURE_OUT: outDir, - GOAGENT_BROWSER_PATH: systemBrowserPath(), - PLAYWRIGHT_NODE_MODULES: playwrightNodeModules - }, - stdio: 'inherit' - }) - console.log(`Captured UI Gallery screenshots in ${outDir}`) + } finally { + await rm(temporary, { recursive: true, force: true }) + } } async function capturePage(playwright, url) { diff --git a/scripts/check_release_version.mjs b/scripts/check_release_version.mjs new file mode 100644 index 0000000..bfeae9f --- /dev/null +++ b/scripts/check_release_version.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +export function checkReleaseVersion(ref, version) { + // A manual branch run builds preview artifacts without publishing a release. + if (!ref?.startsWith('refs/tags/')) return false + const expectedRef = `refs/tags/v${version}` + if (typeof version !== 'string' || !version || ref !== expectedRef) { + throw new Error(`Release tag ${ref} does not match package.json version ${version}; expected ${expectedRef}`) + } + return true +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const { version } = JSON.parse(readFileSync('package.json', 'utf8')) + const matched = checkReleaseVersion(process.env.GITHUB_REF, version) + console.log(matched + ? `[release-version] Tag matches package.json v${version}` + : '[release-version] Branch preview; no release will be published') + } catch (error) { + console.error(`[release-version] ${error instanceof Error ? error.message : String(error)}`) + process.exitCode = 1 + } +} diff --git a/scripts/lib/windows_pe_dependencies.mjs b/scripts/lib/windows_pe_dependencies.mjs index 661106e..a5f66d9 100644 --- a/scripts/lib/windows_pe_dependencies.mjs +++ b/scripts/lib/windows_pe_dependencies.mjs @@ -47,7 +47,7 @@ export function peImports(bytes) { // OS/driver dependencies only. Never allow a CUDA Toolkit DLL to be supplied // by System32 or the developer's PATH; every other import must be app-local. export function isWindowsSystemDependency(name) { - return /^(?:api-ms-|ext-ms-)/i.test(name) || /^(?:kernel32|user32|gdi32|advapi32|shell32|shlwapi|ole32|oleaut32|ws2_32|crypt32|bcrypt|ncrypt|secur32|normaliz|version|winmm|winhttp|wininet|iphlpapi|psapi|setupapi|cfgmgr32|ntdll|rpcrt4|comdlg32|comctl32|dbghelp|msvcrt|ucrtbase|d3d12|dxgi|nvcuda)\.dll$/i.test(name) + return /^(?:api-ms-|ext-ms-)/i.test(name) || /^(?:kernel32|user32|gdi32|advapi32|shell32|shlwapi|ole32|oleaut32|ws2_32|crypt32|bcrypt|ncrypt|secur32|normaliz|version|winmm|winhttp|wininet|iphlpapi|psapi|setupapi|cfgmgr32|ntdll|rpcrt4|comdlg32|comctl32|dbghelp|msvcrt|ucrtbase|d3d12|dxgi|nvcuda|opencl)\.dll$/i.test(name) } export async function checkWindowsRuntimeDependencies(runtime, { nvidia = false } = {}) { diff --git a/scripts/start_dev_app.mjs b/scripts/start_dev_app.mjs index c22303a..8cd086e 100644 --- a/scripts/start_dev_app.mjs +++ b/scripts/start_dev_app.mjs @@ -1,9 +1,12 @@ #!/usr/bin/env node import { spawnSync } from 'node:child_process' import { existsSync, readdirSync } from 'node:fs' +import { createRequire } from 'node:module' import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' -const root = resolve(new URL('..', import.meta.url).pathname) +const root = fileURLToPath(new URL('..', import.meta.url)) +const require = createRequire(import.meta.url) const args = new Set(process.argv.slice(2)) const skipBuild = args.has('--skip-build') const devOutputDir = join(root, '.dev-release') @@ -24,6 +27,13 @@ function run(command, commandArgs, options = {}) { } } +function runPackageBin(packageName, binName, commandArgs, options = {}) { + const packagePath = require.resolve(`${packageName}/package.json`) + const { bin } = require(packagePath) + const binPath = typeof bin === 'string' ? bin : bin[binName] + run(process.execPath, [resolve(packagePath, '..', binPath), ...commandArgs], options) +} + function findFirstApp(directory) { if (!existsSync(directory)) { return null @@ -48,13 +58,12 @@ if (process.platform === 'darwin') { if (!skipBuild) { // The afterPack hook embeds the checksummed Codex App Server runtime and fails on a // fresh clone without it. The script is a no-op once the binary is verified. - run('node', ['scripts/prepare_codex_runtime.mjs', `--platform=${process.platform}-${process.arch}`]) - run('pnpm', ['exec', 'electron-vite', 'build']) - run( - 'pnpm', + run(process.execPath, ['scripts/prepare_codex_runtime.mjs', `--platform=${process.platform}-${process.arch}`]) + runPackageBin('electron-vite', 'electron-vite', ['build']) + runPackageBin( + 'electron-builder', + 'electron-builder', [ - 'exec', - 'electron-builder', '--mac', 'dir', '--publish', @@ -80,5 +89,5 @@ if (process.platform === 'darwin') { run('open', ['-n', appPath]) console.log(`GoAgent dev app launched from ${appPath}`) } else { - run('pnpm', ['exec', 'electron-vite', skipBuild ? 'preview' : 'dev']) + runPackageBin('electron-vite', 'electron-vite', [skipBuild ? 'preview' : 'dev']) } diff --git a/scripts/teacher_llm_real_smoke.mjs b/scripts/teacher_llm_real_smoke.mjs index c45df4e..a5a7af7 100644 --- a/scripts/teacher_llm_real_smoke.mjs +++ b/scripts/teacher_llm_real_smoke.mjs @@ -4,9 +4,9 @@ import { spawn } from 'node:child_process' import { createServer } from 'node:http' import { join, dirname } from 'node:path' import { fileURLToPath } from 'node:url' +import electronBin from 'electron' const root = dirname(dirname(fileURLToPath(import.meta.url))) -const electronBin = join(root, 'node_modules', '.bin', process.platform === 'win32' ? 'electron.cmd' : 'electron') function freePort() { return new Promise((resolve, reject) => { diff --git a/scripts/teacher_llm_smoke.mjs b/scripts/teacher_llm_smoke.mjs index 54346eb..1b9fd3a 100644 --- a/scripts/teacher_llm_smoke.mjs +++ b/scripts/teacher_llm_smoke.mjs @@ -5,9 +5,10 @@ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import { spawn } from 'node:child_process' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import electronBin from 'electron' -const root = resolve(new URL('..', import.meta.url).pathname) -const electronBin = join(root, 'node_modules', '.bin', process.platform === 'win32' ? 'electron.cmd' : 'electron') +const root = fileURLToPath(new URL('..', import.meta.url)) const tinyPng = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/luzK4wAAAABJRU5ErkJggg==' @@ -56,8 +57,23 @@ async function startMockLlmServer(port) { request.on('end', () => { const body = raw ? JSON.parse(raw) : {} requests.push(body) + console.log(`[teacher-smoke] mock request ${requests.length}: ${body.messages?.length ?? 0} messages, ${body.tools?.length ?? 0} tools`) const allText = (body.messages ?? []).map(textFromMessage).join('\n') const isProbe = allText.includes('请只回答 OK') || allText.includes('只输出 OK') + if (allText.includes('观察图片中央色块的颜色。')) { + assert.ok((body.messages ?? []).some(hasImage), 'Vision probe must carry its image') + response.writeHead(200, { 'Content-Type': 'application/json' }) + response.end(JSON.stringify({ choices: [{ finish_reason: 'stop', message: { content: 'BLUE' } }] })) + return + } + if (body.tools?.some((tool) => tool.function?.name === 'readiness_check')) { + response.writeHead(200, { 'Content-Type': 'application/json' }) + response.end(JSON.stringify({ choices: [{ finish_reason: 'tool_calls', message: { + content: '', + tool_calls: [{ id: 'readiness_probe', type: 'function', function: { name: 'readiness_check', arguments: '{"value":"goagent"}' } }] + } }] })) + return + } const toolMessages = (body.messages ?? []).filter((message) => message.role === 'tool') const needsTools = !isProbe && Array.isArray(body.tools) && toolMessages.length === 0 if (needsTools) { @@ -125,6 +141,11 @@ async function seedSmokeHome(homeRoot) { const appHome = join(homeRoot, '.goagent') const libraryDir = join(appHome, 'library', 'upload') await mkdir(libraryDir, { recursive: true }) + await writeFile(join(appHome, 'settings.json'), JSON.stringify({ + onboardingVersion: 1, + katagoAutoBenchmarkEnabled: false, + reviewLanguage: 'zh-CN' + }), 'utf8') const sgfPath = join(libraryDir, 'teacher-smoke.sgf') const sgf = [ '(;GM[1]FF[4]CA[UTF-8]SZ[19]KM[7.5]', @@ -227,18 +248,39 @@ async function evaluateInRenderer(wsUrl, expression, timeoutMs = 180_000) { }) } - await send('Runtime.enable') - const result = await send('Runtime.evaluate', { - expression, - awaitPromise: true, - returnByValue: true, - timeout: timeoutMs - }) - socket.close() - if (result.exceptionDetails) { - throw new Error(result.exceptionDetails.text || JSON.stringify(result.exceptionDetails)) + try { + await send('Runtime.enable') + const result = await send('Runtime.evaluate', { + expression, + awaitPromise: true, + returnByValue: true, + timeout: timeoutMs + }) + if (result.exceptionDetails) { + throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text || JSON.stringify(result.exceptionDetails)) + } + return result.result.value + } finally { + socket.close() + } +} + +async function reloadRenderer(wsUrl) { + const socket = new WebSocket(wsUrl) + try { + await new Promise((resolvePromise, reject) => { + const timer = setTimeout(() => reject(new Error('Renderer reload timed out')), 30_000) + socket.onerror = (error) => { clearTimeout(timer); reject(error) } + socket.onopen = () => socket.send(JSON.stringify({ id: 1, method: 'Page.enable' })) + socket.onmessage = (event) => { + const message = JSON.parse(event.data) + if (message.id === 1) socket.send(JSON.stringify({ id: 2, method: 'Page.reload' })) + if (message.method === 'Page.loadEventFired') { clearTimeout(timer); resolvePromise() } + } + }) + } finally { + socket.close() } - return result.result.value } async function main() { @@ -251,9 +293,9 @@ async function main() { const child = startElectron({ homeRoot, cdpPort }) try { const wsUrl = await waitForRenderer(cdpPort) - const result = await evaluateInRenderer(wsUrl, ` + const probe = await evaluateInRenderer(wsUrl, ` (async () => { - const tinyPng = ${JSON.stringify(tinyPng)}; + console.log('[teacher-smoke] LLM readiness probe'); const baseUrl = 'http://127.0.0.1:${mockPort}/v1'; const probe = await window.goagent.testLlmSettings({ llmBaseUrl: baseUrl, @@ -261,21 +303,40 @@ async function main() { llmModel: 'gpt-5.4' }); if (!probe.ok) throw new Error('LLM probe failed: ' + probe.message); - const dashboard = await window.goagent.updateSettings({ - llmBaseUrl: baseUrl, - llmApiKey: 'smoke-key', - llmModel: 'gpt-5.4', - defaultPlayerName: 'SmokeBlack' - }); + await window.goagent.updateSettings({ defaultPlayerName: 'SmokeBlack' }); + return probe; + })() + `, 45_000) + // Load the persisted, actually verified profile into the React dashboard. + // Re-writing the key after probing would reset its verification status. + await reloadRenderer(wsUrl) + const result = await evaluateInRenderer(wsUrl, ` + (async () => { + const stage = (name) => { window.__teacherSmokeStage = name; console.log('[teacher-smoke] ' + name); }; + async function waitForUi(label, predicate, timeoutMs = 30000) { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + const value = predicate(); + if (value) return value; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(label + ' timed out: ' + document.body.innerText.slice(-1800)); + } + stage('wait for visible board'); + await waitForUi('board render', () => document.querySelector('.board-table--v2 svg')); + const tinyPng = ${JSON.stringify(tinyPng)}; + const dashboard = await window.goagent.getDashboard(); const game = dashboard.games[0]; if (!game) throw new Error('No smoke game loaded'); const record = await window.goagent.getGameRecord(game.id); const moveNumber = Math.min(8, record.moves.length); + stage('real KataGo position analysis'); const analysis = await window.goagent.analyzePosition({ gameId: game.id, moveNumber, maxVisits: 24 }); + stage('teacher task with real tools'); const result = await window.goagent.runTeacherTask({ mode: 'current-move', prompt: '请分析当前手,输出结构化 JSON 讲解。', @@ -284,16 +345,7 @@ async function main() { boardImageDataUrl: tinyPng, prefetchedAnalysis: analysis }); - async function waitForUi(label, predicate, timeoutMs = 180000) { - const started = Date.now(); - while (Date.now() - started < timeoutMs) { - const value = predicate(); - if (value) return value; - await new Promise((resolve) => setTimeout(resolve, 250)); - } - throw new Error(label + ' timed out'); - } - await waitForUi('board render', () => document.querySelector('.board-table--v2 svg')); + stage('wait for teacher composer'); const composer = await waitForUi('teacher composer', () => { const textarea = document.querySelector('.ks-composer-pro textarea'); return textarea && textarea.getClientRects().length > 0 ? textarea : null; @@ -309,6 +361,7 @@ async function main() { return button && !button.disabled && button.getClientRects().length > 0 ? button : null; }); sendButton.click(); + stage('wait for UI teacher answer'); const uiAnalyze = await waitForUi('UI current-move analysis', () => { const body = document.body.innerText || ''; const errorLine = document.querySelector('.error-line')?.textContent || ''; @@ -325,9 +378,8 @@ async function main() { }; } return null; - }); + }, 120000); return { - probe, gameTitle: game.title, moveNumber, bestMove: analysis.before.topMoves[0]?.move || '', @@ -355,7 +407,7 @@ async function main() { })() `) - assert.equal(result.probe.ok, true) + assert.equal(probe.ok, true) assert.equal(result.structured?.headline, '本手要先抢全局最大点') assert.equal(result.structured?.taskType, 'current-move') assert.ok(result.candidateCount > 0, 'KataGo should return candidate moves') @@ -381,6 +433,9 @@ async function main() { assert.ok(probeRequest, 'Mock LLM should receive probe request') assert.ok(teacherRequest, 'Mock LLM should receive teacher request with tool results') assert.ok((teacherRequest.messages ?? []).some(hasImage), 'Teacher request should include board image') + assert.ok((teacherRequest.messages ?? []).some((message) => Array.isArray(message.content) && message.content.some((part) => + part.type === 'image_url' && String(part.image_url?.url ?? '').length > 50_000 + )), 'Teacher request should include the real tool-rendered board, not just the initial tiny placeholder') const teacherPayload = (teacherRequest.messages ?? []).map(textFromMessage).join('\n') assert.match(teacherPayload, /katago\.analyzePosition/) assert.match(teacherPayload, /knowledge/) diff --git a/src/main/services/ikatagoClientEngine.ts b/src/main/services/ikatagoClientEngine.ts index 168bd50..2dfe2fe 100644 --- a/src/main/services/ikatagoClientEngine.ts +++ b/src/main/services/ikatagoClientEngine.ts @@ -25,19 +25,41 @@ interface ActiveIKataGoProcess { const activeIKataGoProcesses = new Map() -function splitCommandLine(input: string): string[] { +export function splitCommandLine(input: string, platform: NodeJS.Platform = process.platform): string[] { const args: string[] = [] let current = '' let quote: '"' | "'" | '' = '' let escaping = false - for (const char of input) { + let started = false + for (let index = 0; index < input.length; index += 1) { + const char = input[index] if (escaping) { current += char + started = true escaping = false continue } - if (char === '\\') { + if (char === '\\' && platform === 'win32') { + // Windows paths use literal backslashes. Only a run immediately before a + // double quote has escaping semantics (the Windows argv convention). + let end = index + 1 + while (input[end] === '\\') end += 1 + const count = end - index + if (input[end] === '"' && quote !== "'") { + current += '\\'.repeat(Math.floor(count / 2)) + if (count % 2) current += '"' + else quote = quote === '"' ? '' : '"' + index = end + } else { + current += '\\'.repeat(count) + index = end - 1 + } + started = true + continue + } + if (char === '\\' && quote !== "'") { escaping = true + started = true continue } if (quote) { @@ -47,19 +69,22 @@ function splitCommandLine(input: string): string[] { } if (char === '"' || char === "'") { quote = char + started = true continue } if (/\s/.test(char)) { - if (current) { + if (started) { args.push(current) current = '' + started = false } continue } current += char + started = true } if (escaping) current += '\\' - if (current) args.push(current) + if (started) args.push(current) return args } diff --git a/src/main/services/katagoRuntime.ts b/src/main/services/katagoRuntime.ts index 9d292ab..26ca9a6 100644 --- a/src/main/services/katagoRuntime.ts +++ b/src/main/services/katagoRuntime.ts @@ -478,19 +478,32 @@ function modelResourceRoots(): string[] { // Runtime resolution happens on every analysis batch; PATH rarely changes, so avoid a // synchronous `which` spawn each time. -let pathKatagoLookup: { value: string; checkedAt: number } | null = null +let pathKatagoLookup: { value: string; checkedAt: number; searchPath: string } | null = null function pathKatago(): string { - if (pathKatagoLookup && Date.now() - pathKatagoLookup.checkedAt < 60_000) { + const searchPath = process.env.PATH ?? '' + if (pathKatagoLookup && pathKatagoLookup.searchPath === searchPath && Date.now() - pathKatagoLookup.checkedAt < 60_000) { return pathKatagoLookup.value } let value = '' try { - value = execFileSync('/usr/bin/which', ['katago'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() + if (process.platform === 'win32') { + // Avoid Unix-only `which` and Windows console code pages: scan the PATH + // directly so a KataGo installation under a Chinese username still works. + value = searchPath.split(';') + .map((directory) => directory.trim().replace(/^"|"$/g, '')) + .filter(Boolean) + .map((directory) => join(directory, 'katago.exe')) + .find((candidate) => { + try { return statSync(candidate).isFile() } catch { return false } + }) ?? '' + } else { + value = execFileSync('/usr/bin/which', ['katago'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() + } } catch { value = '' } - pathKatagoLookup = { value, checkedAt: Date.now() } + pathKatagoLookup = { value, checkedAt: Date.now(), searchPath } return value } diff --git a/src/main/services/pythonRuntime.ts b/src/main/services/pythonRuntime.ts index b552373..3691814 100644 --- a/src/main/services/pythonRuntime.ts +++ b/src/main/services/pythonRuntime.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { execFile } from 'node:child_process' import { promisify } from 'node:util' import { devNull } from 'node:os' +import { app } from 'electron' import { appHome } from '@main/lib/store' const execFileAsync = promisify(execFile) @@ -13,6 +14,33 @@ const venvRoot = join(runtimeRoot, 'venv') const venvBinDir = process.platform === 'win32' ? 'Scripts' : 'bin' const pythonPath = join(venvRoot, venvBinDir, process.platform === 'win32' ? 'python.exe' : 'python3') const stampPath = join(runtimeRoot, 'requirements.sha256') +export const reviewScriptPath = join(runtimeRoot, 'review_game.py') + +export async function materializeReviewScripts(projectRoot: string): Promise { + const roots = [projectRoot] + try { + // Python cannot read files inside app.asar, even though Electron's fs can. + roots.unshift(app.getAppPath()) + } catch { + // Keep the explicit project root available in standalone test harnesses. + } + if (process.resourcesPath) roots.push(process.resourcesPath, join(process.resourcesPath, 'app.asar')) + await mkdir(runtimeRoot, { recursive: true }) + for (const filename of ['review_game.py', 'requirements.txt']) { + let content: string | undefined + for (const root of [...new Set(roots)]) { + try { + content = await readFile(join(root, 'scripts', filename), 'utf8') + break + } catch { + // Try the next development or packaged resource location. + } + } + if (content === undefined) throw new Error(`找不到复盘运行文件 ${filename}`) + await writeFile(join(runtimeRoot, filename), content, 'utf8') + } + return join(runtimeRoot, 'requirements.txt') +} interface PythonLauncher { command: string @@ -125,7 +153,7 @@ export async function resolvePythonLauncher(preferredPythonBin?: string): Promis } export async function ensurePythonRuntime(projectRoot: string, preferredPythonBin?: string): Promise { - await mkdir(runtimeRoot, { recursive: true }) + const requirementsPath = await materializeReviewScripts(projectRoot) let createdVenv = false if (!(await pathExists(pythonPath))) { @@ -138,7 +166,6 @@ export async function ensurePythonRuntime(projectRoot: string, preferredPythonBi throw new Error(`Python 虚拟环境创建失败:没有找到 ${pythonPath}`) } - const requirementsPath = join(projectRoot, 'scripts', 'requirements.txt') const requirements = await readFile(requirementsPath, 'utf8') const digest = createHash('sha256').update(requirements).digest('hex') const installedDigest = (await pathExists(stampPath)) ? (await readFile(stampPath, 'utf8')).trim() : '' diff --git a/src/main/services/review.ts b/src/main/services/review.ts index 879d768..9929639 100644 --- a/src/main/services/review.ts +++ b/src/main/services/review.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { findGame, getSettings, reviewsDir } from '@main/lib/store' import type { ReviewArtifact, ReviewRequest, ReviewResult } from '@main/lib/types' import { resolveKataGoRuntime } from './katagoRuntime' -import { ensurePythonRuntime } from './pythonRuntime' +import { ensurePythonRuntime, reviewScriptPath } from './pythonRuntime' import { ensureFoxGameDownloaded } from './fox' interface PythonReviewOutput { @@ -30,7 +30,7 @@ export async function runReview(request: ReviewRequest): Promise { mkdirSync(reviewRoot, { recursive: true }) const args = [ - join(process.cwd(), 'scripts', 'review_game.py'), + reviewScriptPath, '--sgf', game.filePath, '--out-dir', @@ -59,8 +59,9 @@ export async function runReview(request: ReviewRequest): Promise { const output = await new Promise((resolve, reject) => { const child = spawn(pythonBin, args, { - cwd: process.cwd(), - env: { ...process.env } + cwd: reviewRoot, + env: { ...process.env, PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1' }, + windowsHide: true }) let stdout = '' diff --git a/src/main/services/teacher/shellCommand.ts b/src/main/services/teacher/shellCommand.ts new file mode 100644 index 0000000..c6515a1 --- /dev/null +++ b/src/main/services/teacher/shellCommand.ts @@ -0,0 +1,18 @@ +import { win32 } from 'node:path' + +export function teacherShellCommand( + command: string, + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env +): { command: string; args: string[] } { + if (platform === 'win32') { + return { + command: win32.join(env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), + args: [ + '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', + `[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new(); ${command}` + ] + } + } + return { command: env.SHELL || '/bin/sh', args: ['-lc', command] } +} diff --git a/src/main/services/teacherAgent.ts b/src/main/services/teacherAgent.ts index c194212..3d06485 100644 --- a/src/main/services/teacherAgent.ts +++ b/src/main/services/teacherAgent.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { randomUUID } from 'node:crypto' import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { teacherShellCommand } from './teacher/shellCommand' import { getGames, getSettings, replaceSettings, reportsDir, setSettings } from '@main/lib/store' import type { AgentToolImageResult, @@ -1236,11 +1237,13 @@ function runShell(input: JsonObject): Promise { const cwd = cwdInput ? resolve(cwdInput) : process.cwd() const timeoutMs = numberInput(input, 'timeoutMs', 60_000, 1_000, 180_000) const runInBackground = booleanInput(input, 'runInBackground', false) + const shell = teacherShellCommand(command) return new Promise((resolvePromise, reject) => { - const child = spawn(process.env.SHELL || 'zsh', ['-lc', command], { + const child = spawn(shell.command, shell.args, { cwd, env: process.env, - stdio: 'pipe' + stdio: 'pipe', + windowsHide: true }) const startedAt = new Date().toISOString() const taskId = randomUUID() @@ -1257,6 +1260,7 @@ function runShell(input: JsonObject): Promise { child.on('error', (error) => { if (settled) return settled = true + clearTimeout(timer) reject(error) }) const timer = setTimeout(() => { @@ -1265,7 +1269,8 @@ function runShell(input: JsonObject): Promise { settled = true reject(new Error(`shell.exec 超时: ${timeoutMs}ms`)) }, timeoutMs) - if (runInBackground) { + child.once('spawn', () => { + if (!runInBackground || settled) return SHELL_TASKS.set(taskId, { id: taskId, command, cwd, process: child, startedAt }) clearTimeout(timer) settled = true @@ -1275,8 +1280,7 @@ function runShell(input: JsonObject): Promise { cwd: redactSensitiveText(cwd), startedAt }) - return - } + }) child.on('close', (code, signal) => { if (settled) return settled = true @@ -1861,7 +1865,7 @@ function createTeacherAgentTools(state: TeacherAgentSessionState): TeacherAgentT apiName: 'shell_exec', canonicalName: 'shell.exec', label: 'Shell', - description: '在本机 shell 执行命令,支持 cwd、超时和后台任务;输出会截断并脱敏。', + description: `在本机 ${process.platform === 'win32' ? 'Windows PowerShell' : 'shell'} 执行命令,支持 cwd、超时和后台任务;输出会截断并脱敏。`, parameters: schema({ command: { type: 'string' }, cwd: { type: 'string' }, diff --git a/src/main/services/tts/misakiZhG2p.ts b/src/main/services/tts/misakiZhG2p.ts index 98d2a64..309e5b0 100644 --- a/src/main/services/tts/misakiZhG2p.ts +++ b/src/main/services/tts/misakiZhG2p.ts @@ -26,7 +26,9 @@ function runG2pScript(pythonPath: string, scriptPath: string, text: string): Pro return new Promise((resolve, reject) => { const child = spawn(pythonPath, [scriptPath], { windowsHide: true, - stdio: ['pipe', 'pipe', 'pipe'] + stdio: ['pipe', 'pipe', 'pipe'], + // Match the UTF-8 JSON input/output even on Windows with a legacy code page. + env: { ...process.env, PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1' } }) const stdout: Buffer[] = [] const stderr: Buffer[] = [] diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 7878dea..e9bc0de 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -936,7 +936,7 @@ textarea { .studio { display: grid; - grid-template-columns: clamp(220px, 17vw, 280px) minmax(520px, 1fr) clamp(500px, 30vw, 620px); + grid-template-columns: clamp(220px, 17vw, 280px) minmax(0, 1fr) clamp(500px, 30vw, 620px); height: 100%; min-height: 0; overflow: hidden; @@ -947,7 +947,7 @@ textarea { } .studio--collapsed { - grid-template-columns: 48px minmax(560px, 1fr) clamp(500px, 30vw, 620px); + grid-template-columns: 48px minmax(0, 1fr) clamp(500px, 30vw, 620px); } .desktop-statusbar { @@ -1699,7 +1699,7 @@ label, .board-workspace { display: grid; - grid-template-rows: 44px minmax(0, 1fr) 118px; + grid-template-rows: auto minmax(0, 1fr) 118px; min-width: 0; min-height: 0; background: var(--surface-0); @@ -1722,8 +1722,8 @@ label, } .board-contextbar { - display: grid; - grid-template-columns: minmax(245px, 1fr) auto auto; + display: flex; + flex-wrap: wrap; align-items: center; gap: 6px; width: 100%; @@ -1731,18 +1731,19 @@ label, } .board-contextbar--empty { - grid-template-columns: minmax(0, 1fr); + flex-wrap: nowrap; } .board-contextbar__identity { display: flex; + flex: 1 0 100%; align-items: baseline; min-width: 0; gap: 6px; } .board-contextbar__identity h1 { - flex: 0 1 auto; + flex: 1 1 auto; min-width: 0; } @@ -1762,8 +1763,9 @@ label, .board-contextbar__metrics { display: flex; + flex: 1 1 auto; align-items: center; - justify-content: flex-end; + justify-content: flex-start; gap: 4px; min-width: 0; } @@ -1823,6 +1825,7 @@ label, .board-stage { display: flex; + container-type: size; align-items: center; justify-content: center; min-height: 0; @@ -1851,6 +1854,11 @@ label, gap: 0; } +.board-table--v2 .ks-board-shell { + width: min(100%, 100cqh, 980px); + min-width: 0; +} + .board-table--v2 .ks-board-insight { width: min(760px, 100%); } @@ -4353,6 +4361,15 @@ label small { box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.72); } +.board-contextbar .analysis-control-strip { + flex-wrap: wrap; + max-width: 100%; +} + +.board-contextbar .analysis-control-strip > * { + flex-shrink: 0; +} + .analysis-mini-button { display: inline-flex; align-items: center; @@ -5708,11 +5725,11 @@ label small { @media (min-width: 1181px) { .studio { - grid-template-columns: clamp(220px, 17vw, 280px) minmax(520px, 1fr) clamp(500px, 30vw, 620px); + grid-template-columns: clamp(220px, 17vw, 280px) minmax(0, 1fr) clamp(500px, 30vw, 620px); } .studio--collapsed { - grid-template-columns: 48px minmax(560px, 1fr) clamp(500px, 30vw, 620px); + grid-template-columns: 48px minmax(0, 1fr) clamp(500px, 30vw, 620px); } .board-workspace, @@ -5723,10 +5740,6 @@ label small { overflow: visible; } - .board-table--v2 .ks-board-shell { - width: min(100%, calc(100dvh - 212px), 980px); - } - .teacher-column { display: block; position: relative; diff --git a/tests/analysis-trust-ui-contract.test.mjs b/tests/analysis-trust-ui-contract.test.mjs index 970a817..33f8608 100644 --- a/tests/analysis-trust-ui-contract.test.mjs +++ b/tests/analysis-trust-ui-contract.test.mjs @@ -94,7 +94,9 @@ test('UI Gallery capture builds and self-hosts the current renderer', async () = assert.equal(pkg.scripts['capture:ui-gallery'], 'node scripts/capture_ui_gallery.mjs') assert.match(script, /startRendererServer/) assert.match(script, /server\.listen\(0, '127\.0\.0\.1'/) - assert.match(script, /await run\('pnpm', \['build'\]/) + assert.match(script, /require\.resolve\('electron-vite\/package\.json'\)/) + assert.match(script, /const bin = require\(packagePath\)\.bin\['electron-vite'\]/) + assert.match(script, /await run\(process\.execPath, \[resolve\(packagePath, '\.\.', bin\), 'build'\]/) assert.match(script, /GOAGENT_BROWSER_PATH/) assert.match(docs, /pnpm capture:ui-gallery/) }) diff --git a/tests/misaki-python-encoding.test.mjs b/tests/misaki-python-encoding.test.mjs new file mode 100644 index 0000000..d7b4771 --- /dev/null +++ b/tests/misaki-python-encoding.test.mjs @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import ts from 'typescript' + +function findPython() { + const configured = process.env.GOAGENT_TEST_PYTHON + const candidates = configured ? [configured] : ['python3', 'python'] + for (const command of candidates) { + const probe = spawnSync(command, ['-c', 'import sys; print(sys.executable)'], { + encoding: 'utf8', timeout: 10_000, windowsHide: true, + env: { ...process.env, PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1' } + }) + if (probe.status === 0) return probe.stdout.trim() + } + assert.ok(!configured, `GOAGENT_TEST_PYTHON is not executable: ${configured}`) + return null +} + +const python = findPython() + +async function bridge(t, script) { + const root = await mkdtemp(join(tmpdir(), 'goagent 中文 G2P ')) + t.after(() => rm(root, { recursive: true, force: true })) + const scriptPath = join(root, '中文 注音.py') + await writeFile(scriptPath, script, 'utf8') + const source = (await readFile(new URL('../src/main/services/tts/misakiZhG2p.ts', import.meta.url), 'utf8')) + .replace("import { ensureTtsPythonRuntime, materializeTtsScript } from './ttsPythonRuntime'", + `const ensureTtsPythonRuntime = async () => ${JSON.stringify(python)}; const materializeTtsScript = async () => ${JSON.stringify(scriptPath)};`) + const output = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 } + }).outputText + return import(`data:text/javascript;base64,${Buffer.from(output).toString('base64')}`) +} + +async function withLegacyEncoding(action) { + const previous = { PYTHONIOENCODING: process.env.PYTHONIOENCODING, PYTHONUTF8: process.env.PYTHONUTF8 } + process.env.PYTHONIOENCODING = 'gbk:strict' + process.env.PYTHONUTF8 = '0' + try { + await action() + assert.equal(process.env.PYTHONIOENCODING, 'gbk:strict', 'The bridge must not mutate the parent environment') + assert.equal(process.env.PYTHONUTF8, '0') + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + } +} + +test('Misaki bridge accepts Chinese UTF-8 input and phonemes despite a legacy Windows Python encoding', { skip: !python && 'Python 3 is unavailable' }, async (t) => { + const { phonemizeChineseWithMisaki } = await bridge(t, ` +import json, sys +payload = json.load(sys.stdin) +sys.stderr.reconfigure(errors='strict') +print('中文注音诊断 ❓', file=sys.stderr) +print(json.dumps({'engine': 'misaki.zh.ZHG2P', 'version': '1.1', 'text': payload['text'], 'phonemes': 'ʈʂʰ 中文 ❓', 'unknownCount': 0}, ensure_ascii=False)) +`) + await withLegacyEncoding(async () => { + const result = await phonemizeChineseWithMisaki('现在复盘第八十七手 ❓', {}) + assert.equal(result.text, '现在复盘第八十七手 ❓') + assert.equal(result.phonemes, 'ʈʂʰ 中文 ❓') + assert.equal(result.unknownCount, 0) + }) +}) + +test('Misaki bridge preserves real Chinese Python stderr on failure under a legacy Windows encoding', { skip: !python && 'Python 3 is unavailable' }, async (t) => { + const { phonemizeChineseWithMisaki } = await bridge(t, ` +import json, sys +json.load(sys.stdin) +sys.stderr.reconfigure(errors='strict') +print('中文注音失败 ❓ 请检查输入', file=sys.stderr) +sys.exit(7) +`) + await withLegacyEncoding(async () => { + await assert.rejects(phonemizeChineseWithMisaki('围棋 ❓', {}), { message: '中文注音失败 ❓ 请检查输入' }) + }) +}) diff --git a/tests/release-preflight.test.mjs b/tests/release-preflight.test.mjs new file mode 100644 index 0000000..08b76d1 --- /dev/null +++ b/tests/release-preflight.test.mjs @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { test } from 'node:test' +import { checkReleaseVersion } from '../scripts/check_release_version.mjs' + +const root = fileURLToPath(new URL('..', import.meta.url)) +const version = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version +const workflow = readFileSync(new URL('../.github/workflows/release.yml', import.meta.url), 'utf8').replace(/\r\n/g, '\n') + +function job(name) { + const section = workflow.match(new RegExp(`^ ${name}:\\n([\\s\\S]*?)(?=^ [a-z][a-z-]*:|$(?![\\s\\S]))`, 'm')) + assert.ok(section, `Missing workflow job ${name}`) + return section[1] +} + +test('release tags must match the package version, including prereleases', () => { + assert.equal(checkReleaseVersion('refs/tags/v0.4.22', '0.4.22'), true) + assert.equal(checkReleaseVersion('refs/tags/v0.4.22-rc.1', '0.4.22-rc.1'), true) + for (const ref of ['refs/tags/v0.4.21', 'refs/tags/0.4.22', 'refs/tags/v0.4.22-rc.1']) { + assert.throws(() => checkReleaseVersion(ref, '0.4.22'), /does not match/) + } + assert.equal(checkReleaseVersion('refs/heads/main', '0.4.22'), false) +}) + +test('a mismatched tag exits nonzero before release preparation', () => { + const script = fileURLToPath(new URL('../scripts/check_release_version.mjs', import.meta.url)) + for (const [ref, expectedStatus] of [ + [`refs/tags/v${version}`, 0], + [`refs/tags/v${version}-wrong-version`, 1], + ['refs/heads/main', 0] + ]) { + const result = spawnSync(process.execPath, [script], { + cwd: root, + env: { ...process.env, GITHUB_REF: ref }, + encoding: 'utf8', + windowsHide: true + }) + assert.equal(result.status, expectedStatus, result.stderr) + if (expectedStatus) assert.match(result.stderr, /does not match package.json version/) + } +}) + +test('quality checks must succeed before preflight exposes packaging outputs', () => { + const preflight = job('release-preflight') + const checks = [ + 'node scripts/check_release_version.mjs', + 'pnpm install --frozen-lockfile', + 'pnpm test', + 'pnpm typecheck', + 'pnpm check:teacher-quality', + 'pnpm check:codex-runtime', + 'pnpm check:no-lite-release-assets', + 'pnpm check:nvidia-release-assets', + 'pnpm check:release-notes-i18n' + ] + const outputsStep = preflight.indexOf('id: katago-assets') + assert.ok(outputsStep >= 0) + for (const command of checks) { + const offset = preflight.indexOf(command) + assert.ok(offset >= 0 && offset < outputsStep, `${command} must gate packaging outputs`) + } + assert.doesNotMatch(preflight, /^\s*(?:continue-on-error|if):|\|\|\s*(?:true|:)/m) + for (const name of ['prepare-tts-assets', 'package', 'package-nvidia-windows', 'github-release']) { + const needs = job(name).match(/^ needs:(.*(?:\n - .*)*)/m) + assert.ok(needs?.[1].includes('release-preflight'), `${name} must depend on release preflight`) + } +}) + +test('the actual publish condition blocks failed, cancelled or skipped gates', () => { + const rawCondition = job('github-release').match(/^ if: (.+)$/m)?.[1] + assert.ok(rawCondition) + // Evaluate the workflow's expression with Actions-compatible values, including + // the always() that otherwise lets a job run after a failed dependency. + const condition = rawCondition.replace(/needs\.([a-z][a-z-]*)\./g, 'needs["$1"].') + function canPublish(results = {}, ref = `refs/tags/v${version}`, outputs = {}) { + const needs = Object.fromEntries(['release-preflight', 'package', 'package-nvidia-windows'].map(name => [name, { + result: results[name] ?? 'success', + outputs: { 'can-package': 'true', 'can-package-nvidia': 'true', ...outputs } + }])) + // Hyphenated output names use the same property syntax as job identifiers. + const expression = condition.replace(/\.outputs\.([a-z][a-z-]*)/g, '.outputs["$1"]') + return runInNewContext(expression, { + needs, + github: { ref }, + always: () => true, + startsWith: (value, prefix) => value.startsWith(prefix) + }, { timeout: 1000 }) + } + assert.equal(canPublish(), true) + for (const name of ['release-preflight', 'package', 'package-nvidia-windows']) { + for (const result of ['failure', 'cancelled', 'skipped']) { + assert.equal(canPublish({ [name]: result }), false, `${name} ${result} must block publication`) + } + } + assert.equal(canPublish({}, 'refs/heads/main'), false) + assert.equal(canPublish({}, undefined, { 'can-package': 'false' }), false) + assert.equal(canPublish({}, undefined, { 'can-package-nvidia': 'false' }), false) +}) diff --git a/tests/sprint7-ui-polish-contract.test.mjs b/tests/sprint7-ui-polish-contract.test.mjs index 0ce9482..e660567 100644 --- a/tests/sprint7-ui-polish-contract.test.mjs +++ b/tests/sprint7-ui-polish-contract.test.mjs @@ -69,9 +69,11 @@ test('GoBoardV2 renders polished stone and candidate layers', () => { assert.match(boardCss, /\.ks-previous-move-gem\s*\{[^}]*stroke-width:\s*1\.35/s) assert.match(boardCss, /\.ks-previous-move-glint\s*\{[^}]*stroke-linecap:\s*round/s) assert.match(boardCss, /calc\(100dvh - 232px\)/) - assert.match(styles, /calc\(100dvh - 212px\),\s*980px/) + assert.match(styles, /\.board-stage\s*\{[^}]*container-type:\s*size/s) + assert.match(styles, /\.board-table--v2 \.ks-board-shell\s*\{[^}]*width:\s*min\(100%,\s*100cqh,\s*980px\)/s) assert.doesNotMatch(boardCss, /820px/) - assert.match(styles, /grid-template-rows:\s*44px minmax\(0,\s*1fr\) 118px/) + assert.match(styles, /\.board-workspace\s*\{[^}]*grid-template-rows:\s*auto minmax\(0,\s*1fr\) 118px/s) + assert.match(styles, /\.board-contextbar\s*\{[^}]*flex-wrap:\s*wrap/s) const geometry = read('src/renderer/src/features/board/boardGeometry.ts') assert.match(geometry, /renderPlayedMove/) assert.match(geometry, /RenderVariationMove/) diff --git a/tests/windows-katago-dependencies.test.mjs b/tests/windows-katago-dependencies.test.mjs index a215f33..fe3178d 100644 --- a/tests/windows-katago-dependencies.test.mjs +++ b/tests/windows-katago-dependencies.test.mjs @@ -62,6 +62,16 @@ test('requires dynamic NVRTC builtins even when the PE import graph is satisfied await writeFile(join(root, 'nvrtc64_120_0.dll'), Buffer.from('wrong component')) await assert.rejects(checkWindowsRuntimeDependencies(root, { nvidia: true }), /Bundled NVRTC hash mismatch/) }) + +test('accepts the GPU driver OpenCL ICD while still requiring app-local compute libraries', async (t) => { + const root = await temp(t) + await writeFile(join(root, 'katago.exe'), fixture({ imports: ['OpenCL.dll', 'KERNEL32.dll'] })) + assert.equal(isWindowsSystemDependency('OpenCL.dll'), true) + const report = await checkWindowsRuntimeDependencies(root) + assert.deepEqual(report.checkedBinaries, ['katago.exe']) + await writeFile(join(root, 'katago.exe'), fixture({ imports: ['OpenCL.dll', 'cudnn64_9.dll'] })) + await assert.rejects(checkWindowsRuntimeDependencies(root), /Missing bundled dependency: katago.exe -> cudnn64_9.dll/) +}) test('preparation rejects a tampered offline archive before copying any DLL', { skip: process.platform !== 'win32' }, async (t) => { const root = await temp(t), runtime = join(root, 'runtime'), archive = join(root, 'bad.zip') await mkdir(runtime) diff --git a/tests/windows-process-runtime.test.mjs b/tests/windows-process-runtime.test.mjs new file mode 100644 index 0000000..b81043b --- /dev/null +++ b/tests/windows-process-runtime.test.mjs @@ -0,0 +1,189 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, normalize } from 'node:path' +import test from 'node:test' +import ts from 'typescript' + +async function loadTs(path, transform = (source) => source) { + const source = transform(await readFile(new URL(`../${path}`, import.meta.url), 'utf8')) + const output = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 } + }).outputText + return import(`data:text/javascript;base64,${Buffer.from(output).toString('base64')}`) +} + +async function workspace(t) { + const root = await mkdtemp(join(tmpdir(), 'goagent Windows 围棋 # ')) + t.after(() => rm(root, { recursive: true, force: true })) + await mkdir(join(root, 'scripts'), { recursive: true }) + return root +} + +for (const mode of ['dev', 'preview']) { + test(`development launcher starts ${mode} in a path with spaces, Unicode and #`, { skip: process.platform === 'darwin' }, async (t) => { + const root = await workspace(t) + const pkg = join(root, 'node_modules', 'electron-vite') + await mkdir(pkg, { recursive: true }) + await writeFile(join(pkg, 'package.json'), JSON.stringify({ bin: { 'electron-vite': 'cli.cjs' } })) + await writeFile(join(pkg, 'cli.cjs'), 'console.log(JSON.stringify({ cwd: process.cwd(), args: process.argv.slice(2) }))') + await writeFile(join(root, 'scripts', 'start_dev_app.mjs'), await readFile(new URL('../scripts/start_dev_app.mjs', import.meta.url))) + const result = spawnSync(process.execPath, [join(root, 'scripts', 'start_dev_app.mjs'), ...(mode === 'preview' ? ['--skip-build'] : [])], { + encoding: 'utf8', timeout: 10_000, windowsHide: true + }) + assert.equal(result.status, 0, result.stderr || String(result.error)) + assert.deepEqual(JSON.parse(result.stdout.trim()), { cwd: root, args: [mode] }) + }) +} + +for (const script of ['teacher_llm_smoke.mjs', 'teacher_llm_real_smoke.mjs']) { + test(`${script} resolves the native Electron binary and decoded workspace path`, async (t) => { + const root = await workspace(t) + const pkg = join(root, 'node_modules', 'electron') + await mkdir(pkg, { recursive: true }) + await writeFile(join(pkg, 'package.json'), JSON.stringify({ main: 'index.cjs' })) + const nativePath = join(pkg, 'dist', process.platform === 'win32' ? 'electron.exe' : 'electron') + await writeFile(join(pkg, 'index.cjs'), `module.exports = ${JSON.stringify(nativePath)}`) + const source = await readFile(new URL(`../scripts/${script}`, import.meta.url), 'utf8') + const prelude = source.slice(0, source.indexOf('function freePort()')) + await writeFile(join(root, 'scripts', script), `${prelude}\nconsole.log(JSON.stringify({ root, electronBin }))`) + const result = spawnSync(process.execPath, [join(root, 'scripts', script)], { encoding: 'utf8', timeout: 10_000, windowsHide: true }) + assert.equal(result.status, 0, result.stderr || String(result.error)) + const values = JSON.parse(result.stdout) + assert.equal(normalize(values.root).replace(/[\\/]$/, ''), root) + assert.equal(values.electronBin, nativePath) + }) +} + +const { splitCommandLine, buildIKataGoAnalysisCommand } = await loadTs('src/main/services/ikatagoClientEngine.ts') + +test('iKataGo preserves Windows drive paths, UNC paths, Unicode and empty arguments', () => { + assert.deepEqual(splitCommandLine(String.raw`--config "C:\围棋 数据\config.cfg" --cache \\server\share\cache --name ""`, 'win32'), [ + '--config', String.raw`C:\围棋 数据\config.cfg`, '--cache', String.raw`\\server\share\cache`, '--name', '' + ]) + assert.deepEqual(splitCommandLine(String.raw`"C:\Go Data\\" "a\"b"`, 'win32'), ['C:\\Go Data\\', 'a"b']) +}) + +test('iKataGo keeps POSIX escaping and literal backslashes inside single quotes', () => { + assert.deepEqual(splitCommandLine(String.raw`--config /tmp/Go\ Data/config.cfg --name "" --literal 'a\b'`, 'linux'), [ + '--config', '/tmp/Go Data/config.cfg', '--name', '', '--literal', String.raw`a\b` + ]) +}) + +test('iKataGo passes a Windows config path unchanged to the analysis process', { skip: process.platform !== 'win32' }, () => { + const command = buildIKataGoAnalysisCommand({ + ikatagoClientBin: 'C:\\Go Tools\\ikatago.exe', ikatagoPlatform: 'all', ikatagoUsername: 'test', ikatagoPassword: 'test', + ikatagoWorldUrl: '', ikatagoExtraArgs: String.raw`--config "C:\围棋 数据\config.cfg"` + }) + assert.deepEqual(command.slice(-4), ['--config', String.raw`C:\围棋 数据\config.cfg`, '--', 'analysis']) +}) + +const { teacherShellCommand } = await loadTs('src/main/services/teacher/shellCommand.ts') + +test('teacher shell chooses Windows PowerShell and retains configured POSIX shells', () => { + const windows = teacherShellCommand('Write-Output test', 'win32', { SystemRoot: 'D:\\Windows', SHELL: '/bin/bash' }) + assert.equal(windows.command, 'D:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe') + assert.deepEqual(windows.args.slice(0, 4), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command']) + assert.deepEqual(teacherShellCommand('printf test', 'linux', {}), { command: '/bin/sh', args: ['-lc', 'printf test'] }) + assert.deepEqual(teacherShellCommand('printf test', 'darwin', { SHELL: '/bin/zsh' }), { command: '/bin/zsh', args: ['-lc', 'printf test'] }) +}) + +test('teacher shell executes on Windows with UTF-8 output and a Unicode working directory', { skip: process.platform !== 'win32' }, async (t) => { + const root = await workspace(t) + const shell = teacherShellCommand("Write-Output '围棋 测试'; Write-Output (Get-Location).Path") + const result = spawnSync(shell.command, shell.args, { cwd: root, encoding: 'utf8', timeout: 10_000, windowsHide: true }) + assert.equal(result.status, 0, result.stderr || String(result.error)) + assert.deepEqual(result.stdout.trim().split(/\r?\n/), ['围棋 测试', root]) + const failure = teacherShellCommand('exit 7') + assert.equal(spawnSync(failure.command, failure.args, { timeout: 10_000, windowsHide: true }).status, 7) +}) + +test('KataGo discovery reads Windows PATH directly and refreshes when it changes', { skip: process.platform !== 'win32' }, async (t) => { + const root = await workspace(t) + const bin = join(root, '引擎 bin') + const other = join(root, 'other') + await mkdir(bin) + await mkdir(other) + await writeFile(join(bin, 'katago.exe'), '') + await mkdir(join(other, 'katago.exe')) + const originalPath = process.env.PATH + t.after(() => { process.env.PATH = originalPath }) + const runtime = await loadTs('src/main/services/katagoRuntime.ts', (source) => { + // Exercise the private discovery function unchanged, without loading the + // Electron store or launching version probes for the rest of the runtime. + const discovery = source.slice(source.indexOf('let pathKatagoLookup:'), source.indexOf('function binaryCandidates()')) + return `import { statSync } from 'node:fs'; import { join } from 'node:path'; import { execFileSync } from 'node:child_process';\n${discovery}\nexport { pathKatago }` + }) + process.env.PATH = `${other};"${bin}"` + assert.equal(runtime.pathKatago(), join(bin, 'katago.exe')) + process.env.PATH = other + assert.equal(runtime.pathKatago(), '') +}) + +test('Python review materializes packaged scripts outside app.asar independently of the launch directory', async (t) => { + const root = await workspace(t) + const appRoot = join(root, 'resources', 'app.asar') + const appHome = join(root, '用户 data') + await mkdir(join(appRoot, 'scripts'), { recursive: true }) + await writeFile(join(appRoot, 'scripts', 'review_game.py'), 'print("围棋")\n') + await writeFile(join(appRoot, 'scripts', 'requirements.txt'), 'sgfmill==1.1.1\n') + const runtime = await loadTs('src/main/services/pythonRuntime.ts', (source) => source + .replace("import { app } from 'electron'", `const app = { getAppPath: () => ${JSON.stringify(appRoot)} }`) + .replace("import { appHome } from '@main/lib/store'", `const appHome = ${JSON.stringify(appHome)}`)) + const requirements = await runtime.materializeReviewScripts(join(root, 'unrelated launch directory')) + assert.equal(runtime.reviewScriptPath, join(appHome, 'runtime', 'review_game.py')) + assert.equal(await readFile(runtime.reviewScriptPath, 'utf8'), 'print("围棋")\n') + assert.equal(await readFile(requirements, 'utf8'), 'sgfmill==1.1.1\n') + await writeFile(join(appRoot, 'scripts', 'review_game.py'), 'print("updated")\n') + await runtime.materializeReviewScripts(root) + assert.equal(await readFile(runtime.reviewScriptPath, 'utf8'), 'print("updated")\n') +}) + +test('UI gallery builds and captures through native package-manager entries without Unix which', async (t) => { + const root = await workspace(t) + const vite = join(root, 'node_modules', 'electron-vite') + const runnerRoot = join(root, 'runner', 'node_modules') + const playwright = join(runnerRoot, 'playwright') + await mkdir(vite, { recursive: true }) + await mkdir(playwright, { recursive: true }) + await mkdir(join(runnerRoot, '.bin')) + await writeFile(join(vite, 'package.json'), JSON.stringify({ bin: { 'electron-vite': 'cli.cjs' } })) + await writeFile(join(vite, 'cli.cjs'), ` + const fs = require('node:fs') + if (process.argv[2] !== 'build') process.exit(2) + fs.mkdirSync('out/renderer', { recursive: true }) + fs.writeFileSync('out/renderer/index.html', '') + `) + await writeFile(join(playwright, 'package.json'), JSON.stringify({ main: 'index.cjs' })) + await writeFile(join(playwright, 'index.cjs'), ` + const { writeFile } = require('node:fs/promises') + const capture = async ({ path }) => writeFile(path, 'captured') + const locator = { first: () => locator, waitFor: async () => {}, count: async () => 0, screenshot: capture } + const chromium = { launch: async () => ({ + newPage: async () => ({ goto: async () => {}, locator: () => locator, getByRole: () => locator, screenshot: capture }), + close: async () => {} + }) } + // Real Playwright's CommonJS entry forwards dynamically generated exports. + module.exports = Object.fromEntries([['chromium', chromium]]) + `) + const manager = join(root, 'pnpm.mjs') + await writeFile(manager, ` + import assert from 'node:assert/strict' + import { spawnSync } from 'node:child_process' + import { delimiter } from 'node:path' + assert.deepEqual(process.argv.slice(2, 5), ['--package=playwright', 'dlx', 'node']) + const result = spawnSync(process.execPath, [process.argv[5]], { + env: { ...process.env, PATH: ${JSON.stringify(join(runnerRoot, '.bin'))} + delimiter + process.env.PATH }, + stdio: 'inherit', windowsHide: true + }) + process.exit(result.status ?? 1) + `) + await writeFile(join(root, 'scripts', 'capture_ui_gallery.mjs'), await readFile(new URL('../scripts/capture_ui_gallery.mjs', import.meta.url))) + const result = spawnSync(process.execPath, [join(root, 'scripts', 'capture_ui_gallery.mjs')], { + cwd: root, encoding: 'utf8', timeout: 10_000, windowsHide: true, + env: { ...process.env, npm_execpath: manager, GOAGENT_UI_GALLERY_URL: '', GOAGENT_UI_GALLERY_OUT: '', GOAGENT_UI_GALLERY_SKIP_BUILD: '0', GOAGENT_PLAYWRIGHT_MODULE: '' } + }) + assert.equal(result.status, 0, result.stderr || String(result.error)) + assert.equal(await readFile(join(root, 'ui-gallery-overview.png'), 'utf8'), 'captured') +}) From ad6f5f2f989ab87063e0297b2d5b8e8bb0e38365 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 22 Oct 2026 23:54:52 +0800 Subject: [PATCH 2/4] test: normalize temporary directory aliases across platforms --- tests/windows-process-runtime.test.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/windows-process-runtime.test.mjs b/tests/windows-process-runtime.test.mjs index b81043b..0ffc86e 100644 --- a/tests/windows-process-runtime.test.mjs +++ b/tests/windows-process-runtime.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import { spawnSync } from 'node:child_process' -import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, normalize } from 'node:path' import test from 'node:test' @@ -15,7 +15,8 @@ async function loadTs(path, transform = (source) => source) { } async function workspace(t) { - const root = await mkdtemp(join(tmpdir(), 'goagent Windows 围棋 # ')) + // Node and PowerShell may expand macOS /var aliases or Windows 8.3 paths. + const root = await realpath(await mkdtemp(join(tmpdir(), 'goagent Windows 围棋 # '))) t.after(() => rm(root, { recursive: true, force: true })) await mkdir(join(root, 'scripts'), { recursive: true }) return root From 12f8b885c75cdf736de8ce072b73c23fcf3ba0fc Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 23 Oct 2026 00:12:13 +0800 Subject: [PATCH 3/4] test: gate Windows releases on real installer integrity --- .github/workflows/release.yml | 31 ++++ CHANGELOG.md | 1 + docs/RELEASE_NOTES_v0.4.22.md | 7 + scripts/smoke_windows_installer.ps1 | 203 +++++++++++++++++++++++++ tests/release-preflight.test.mjs | 29 ++++ tests/windows-installer-smoke.test.mjs | 80 ++++++++++ 6 files changed, 351 insertions(+) create mode 100644 scripts/smoke_windows_installer.ps1 create mode 100644 tests/windows-installer-smoke.test.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 81ce7a9..9073ded 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -512,6 +512,22 @@ jobs: if: runner.os == 'Windows' run: node scripts/smoke_windows_packaged_app.mjs --mode=full --require-katago + - name: Verify Windows installer integrity and uninstall + if: runner.os == 'Windows' + shell: pwsh + run: | + $version = node -p "JSON.parse(require('fs').readFileSync('package.json','utf8')).version" + ./scripts/smoke_windows_installer.ps1 -Installer "release/$version/GoAgent-$version-win-x64.exe" -ExpectedDir "release/$version/win-unpacked" -EvidencePath "release-evidence/windows-standard-installer.json" + if ($LASTEXITCODE -ne 0) { throw "Windows installer integrity or uninstall check failed" } + + - name: Upload Windows installer evidence + if: always() && runner.os == 'Windows' + uses: actions/upload-artifact@v4 + with: + name: GoAgent-Windows-installer-evidence + path: release-evidence/windows-standard-installer.json + if-no-files-found: warn + - name: Upload packaged artifacts uses: actions/upload-artifact@v4 with: @@ -716,6 +732,21 @@ jobs: - name: Smoke NVIDIA packaged Windows app run: node scripts/smoke_windows_packaged_app.mjs --mode=nvidia --require-katago + - name: Verify NVIDIA Windows installer integrity and uninstall + shell: pwsh + run: | + $version = node -p "JSON.parse(require('fs').readFileSync('package.json','utf8')).version" + ./scripts/smoke_windows_installer.ps1 -Installer "release/$version/GoAgent-$version-win-x64-nvidia.exe" -ExpectedDir "release/$version/win-unpacked" -EvidencePath "release-evidence/windows-nvidia-installer.json" + if ($LASTEXITCODE -ne 0) { throw "NVIDIA Windows installer integrity or uninstall check failed" } + + - name: Upload NVIDIA Windows installer evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: GoAgent-Windows-NVIDIA-installer-evidence + path: release-evidence/windows-nvidia-installer.json + if-no-files-found: warn + - name: Upload NVIDIA packaged artifacts uses: actions/upload-artifact@v4 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bb2ae2..067f14b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ This project follows semantic versioning once public releases begin. ### Changed - Required automated tests, typechecking, teaching quality, release source policy, multilingual notes, and tag/package version consistency before release packaging can publish. +- Added real Windows installer validation that compares every installed file with the packaged payload using SHA-256 and checks uninstall completion before publication. - Added Windows process and release failure regression coverage. ## 0.4.21 - Optional ChatGPT Sign-In diff --git a/docs/RELEASE_NOTES_v0.4.22.md b/docs/RELEASE_NOTES_v0.4.22.md index 0d9c8cf..20a5b31 100644 --- a/docs/RELEASE_NOTES_v0.4.22.md +++ b/docs/RELEASE_NOTES_v0.4.22.md @@ -13,6 +13,7 @@ The bundled engines remain KataGo 1.17.1 with the official Transformer 10B Balan - 将显卡驱动提供的 `OpenCL.dll` 正确识别为 Windows 系统依赖,同时保留其他计算运行库的随包完整性检查。 - 修复窄窗口下分析按钮被老师面板遮挡的问题;标题栏可换行,棋盘按剩余可用空间缩放。 - 发布前强制运行测试、类型检查、教学质量与发布文档检查,验证标签和应用版本一致;任一步失败都会阻止发布。 +- 增加 Windows 实际安装验证:逐文件比对安装目录与打包产物的 SHA-256,并检查卸载完成情况;校验失败会阻止发布。 - 包含此前合入的 NVIDIA NVRTC 运行库修复、启动超时限制、分析界面性能和重试改进。 ### 下载 / Downloads @@ -35,6 +36,7 @@ The bundled engines remain KataGo 1.17.1 with the official Transformer 10B Balan - 將顯示卡驅動程式提供的 `OpenCL.dll` 正確識別為 Windows 系統相依項目,同時保留其他計算執行庫的隨包完整性檢查。 - 修正窄視窗下分析按鈕被老師面板遮擋的問題;標題列可換行,棋盤會依剩餘可用空間縮放。 - 發布前強制檢查測試、型別、教學品質、發布文件及版本標籤;包含 NVIDIA NVRTC 與分析效能修正。 +- 新增 Windows 實際安裝驗證:逐檔比對安裝目錄與打包產物的 SHA-256,並檢查解除安裝是否完成;驗證失敗會阻止發布。 ## English @@ -45,6 +47,7 @@ The bundled engines remain KataGo 1.17.1 with the official Transformer 10B Balan - Recognizes driver-provided `OpenCL.dll` as a Windows system dependency while retaining checks for bundled compute libraries. - Prevents analysis buttons from being covered by the teacher panel in narrow windows. The header wraps and the board scales to the remaining available space. - Gates releases on tests, typechecking, teaching quality, release documentation, and matching tag/package versions. Includes the previously merged NVIDIA NVRTC, startup, analysis performance, and retry fixes. +- Adds real Windows installer validation: compares every installed file with the packaged payload using SHA-256 and checks uninstall completion. A failed check blocks publication. ## 日本語 @@ -55,6 +58,7 @@ The bundled engines remain KataGo 1.17.1 with the official Transformer 10B Balan - GPU ドライバーが提供する `OpenCL.dll` を Windows のシステム依存ライブラリとして認識し、その他の計算用ライブラリの同梱チェックは維持します。 - 狭いウィンドウで分析ボタンが先生パネルに隠れる問題を修正しました。ヘッダーは折り返され、盤面は残りの表示領域に合わせて拡縮されます。 - テスト、型検査、教材品質、リリース文書とバージョン一致を公開前に検証します。NVIDIA NVRTC と分析性能の修正も含みます。 +- Windows で実際にインストールし、各ファイルの SHA-256 をパッケージ内容と照合して、アンインストールの完了も検査します。検査に失敗した場合は公開を停止します。 ## 한국어 @@ -65,6 +69,7 @@ The bundled engines remain KataGo 1.17.1 with the official Transformer 10B Balan - 그래픽 드라이버가 제공하는 `OpenCL.dll`을 Windows 시스템 의존성으로 인식하며, 다른 연산 라이브러리의 패키지 포함 여부 검사는 유지합니다. - 좁은 창에서 분석 버튼이 교사 패널에 가려지는 문제를 수정했습니다. 헤더는 줄바꿈되고 바둑판은 남은 공간에 맞게 크기가 조정됩니다. - 배포 전에 테스트, 타입 검사, 교육 품질, 배포 문서와 버전 일치를 확인합니다. NVIDIA NVRTC 및 분석 성능 수정도 포함합니다. +- Windows에서 실제 설치를 수행하고 설치된 각 파일의 SHA-256을 패키지 내용과 비교하며 제거 완료 여부도 확인합니다. 검사에 실패하면 배포를 차단합니다. ## ภาษาไทย @@ -75,6 +80,7 @@ The bundled engines remain KataGo 1.17.1 with the official Transformer 10B Balan - ระบุ `OpenCL.dll` ที่ไดรเวอร์การ์ดจอจัดเตรียมไว้เป็นส่วนประกอบของระบบ Windows อย่างถูกต้อง และยังคงตรวจสอบว่าไลบรารีประมวลผลอื่น ๆ รวมอยู่ในแพ็กเกจ - แก้ไขปุ่มวิเคราะห์ที่ถูกแผงครูบังเมื่อหน้าต่างแคบ โดยให้ส่วนหัวขึ้นบรรทัดใหม่ได้และปรับขนาดกระดานตามพื้นที่ที่เหลือ - ตรวจสอบการทดสอบ ชนิดข้อมูล คุณภาพการสอน เอกสาร และหมายเลขรุ่นก่อนเผยแพร่ พร้อมการแก้ไข NVIDIA NVRTC และประสิทธิภาพการวิเคราะห์ +- เพิ่มการทดสอบติดตั้งจริงบน Windows โดยเปรียบเทียบ SHA-256 ของไฟล์ที่ติดตั้งทุกไฟล์กับแพ็กเกจและตรวจสอบว่าถอนการติดตั้งเสร็จสมบูรณ์ หากตรวจสอบไม่ผ่านจะไม่เผยแพร่ ## Tiếng Việt @@ -85,3 +91,4 @@ The bundled engines remain KataGo 1.17.1 with the official Transformer 10B Balan - Nhận diện đúng `OpenCL.dll` do trình điều khiển đồ họa cung cấp là thư viện hệ thống Windows, đồng thời tiếp tục kiểm tra các thư viện tính toán khác được đóng gói kèm ứng dụng. - Sửa lỗi bảng giáo viên che nút phân tích khi cửa sổ hẹp. Phần tiêu đề có thể xuống dòng và bàn cờ được điều chỉnh theo không gian còn lại. - Kiểm tra bài thử, kiểu dữ liệu, chất lượng giảng dạy, tài liệu phát hành và phiên bản trước khi công bố. Bao gồm các bản sửa NVIDIA NVRTC và hiệu năng phân tích. +- Bổ sung kiểm tra cài đặt thực tế trên Windows: đối chiếu SHA-256 của từng tệp đã cài với nội dung gói và xác nhận quá trình gỡ cài đặt hoàn tất. Kiểm tra thất bại sẽ chặn phát hành. diff --git a/scripts/smoke_windows_installer.ps1 b/scripts/smoke_windows_installer.ps1 new file mode 100644 index 0000000..6dd111d --- /dev/null +++ b/scripts/smoke_windows_installer.ps1 @@ -0,0 +1,203 @@ +#requires -Version 7.0 +<# +.SYNOPSIS +Installs an NSIS artifact into a fresh temporary directory, compares every file +with win-unpacked, and uninstalls only that temporary installation. +.NOTES +Requires Windows and PowerShell 7. Does not launch GoAgent or delete app data. +EvidencePath is a JSON file. Exit 0 requires both integrity and uninstall checks. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$Installer, + [Parameter(Mandatory = $true)][string]$ExpectedDir, + [Parameter(Mandatory = $true)][string]$EvidencePath +) + +$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) + +function Assert-WithinTestRoot { + param([string]$Path, [string]$TestRoot) + $root = [IO.Path]::GetFullPath($TestRoot).TrimEnd([IO.Path]::DirectorySeparatorChar) + $candidate = [IO.Path]::GetFullPath($Path) + if (-not $candidate.StartsWith($root + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing operation outside the dedicated test root: $candidate" + } + # A junction inside the test directory must not redirect uninstall or cleanup. + $cursor = $candidate + while ($cursor -and $cursor.Length -ge $root.Length) { + if (Test-Path -LiteralPath $cursor) { + $item = Get-Item -LiteralPath $cursor -Force + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { + throw "Refusing operation through a reparse point: $cursor" + } + } + $cursor = [IO.Path]::GetDirectoryName($cursor) + } + return $candidate +} + +function Compare-InstallFiles { + param([string]$Expected, [string]$Actual) + $expectedRoot = [IO.Path]::GetFullPath($Expected) + $actualRoot = [IO.Path]::GetFullPath($Actual) + $rows = [Collections.Generic.List[object]]::new() + $expectedNames = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($file in (Get-ChildItem -LiteralPath $expectedRoot -File -Recurse -Force | Sort-Object FullName)) { + $relative = [IO.Path]::GetRelativePath($expectedRoot, $file.FullName) + [void]$expectedNames.Add($relative) + $target = Join-Path $actualRoot $relative + $expectedHash = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash + $exists = Test-Path -LiteralPath $target -PathType Leaf + $actualHash = if ($exists) { (Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash } else { $null } + $rows.Add([pscustomobject]@{ + path = $relative; bytes = $file.Length; expectedSha256 = $expectedHash + actualSha256 = $actualHash; exists = $exists; matches = ($exists -and $actualHash -eq $expectedHash) + }) + } + $extra = @(if (Test-Path -LiteralPath $actualRoot -PathType Container) { + Get-ChildItem -LiteralPath $actualRoot -File -Recurse -Force | ForEach-Object { + $relative = [IO.Path]::GetRelativePath($actualRoot, $_.FullName) + if (-not $expectedNames.Contains($relative)) { $relative } + } + }) + $failures = @($rows | Where-Object { -not $_.matches }) + return [pscustomobject]@{ + ok = ($rows.Count -gt 0 -and $failures.Count -eq 0) + expectedFileCount = $rows.Count; matchedFileCount = $rows.Count - $failures.Count + missingFiles = @($failures | Where-Object { -not $_.exists } | ForEach-Object path) + changedFiles = @($failures | Where-Object exists | ForEach-Object path) + extraFiles = $extra; files = @($rows.ToArray()) + } +} + +function Invoke-HiddenProcess { + param([string]$File, [string]$Arguments, [string]$WorkingDirectory) + $startedAt = [DateTime]::UtcNow.ToString('o') + $process = Start-Process -FilePath $File -ArgumentList $Arguments -WorkingDirectory $WorkingDirectory -WindowStyle Hidden -PassThru + $timedOut = -not $process.WaitForExit(180000) + if ($timedOut) { + # Terminate only this process and its children, never another GoAgent instance. + $process.Kill($true) + [void]$process.WaitForExit(10000) + } + $process.Refresh() + return [pscustomobject]@{ + file = $File; arguments = $Arguments; pid = $process.Id + startedAt = $startedAt; finishedAt = [DateTime]::UtcNow.ToString('o') + timedOut = $timedOut; exitCode = $process.ExitCode + } +} + +$evidenceFile = [IO.Path]::GetFullPath($EvidencePath) +[void](New-Item -ItemType Directory -Path ([IO.Path]::GetDirectoryName($evidenceFile)) -Force) +$report = [ordered]@{ + schemaVersion = 1; startedAt = [DateTime]::UtcNow.ToString('o'); ok = $false + installer = $Installer; expectedDir = $ExpectedDir; evidencePath = $evidenceFile + testRoot = $null; installDir = $null; preflight = $null + installation = $null; integrity = $null; uninstallation = $null + executableRemoved = $false; testRootRemoved = $false + errors = [Collections.Generic.List[string]]::new() +} +$mutex = $null +$ownsMutex = $false +$testRoot = $null +$installDir = $null +$installationAttempted = $false + +try { + if (-not $IsWindows) { throw 'This installer smoke test requires Windows and PowerShell 7.' } + $installerPath = (Get-Item -LiteralPath $Installer).FullName + $expectedPath = (Get-Item -LiteralPath $ExpectedDir).FullName + if (-not (Test-Path -LiteralPath $installerPath -PathType Leaf) -or [IO.Path]::GetExtension($installerPath) -ne '.exe') { throw 'Installer must be an existing .exe file.' } + if (-not (Test-Path -LiteralPath (Join-Path $expectedPath 'GoAgent.exe') -PathType Leaf)) { throw 'ExpectedDir must contain the reference win-unpacked/GoAgent.exe.' } + if (@(Get-ChildItem -LiteralPath $expectedPath -Recurse -Force | Where-Object { $_.Attributes -band [IO.FileAttributes]::ReparsePoint }).Count) { throw 'ExpectedDir must not contain reparse points.' } + $report.installer = $installerPath + $report.expectedDir = $expectedPath + $mutex = [Threading.Mutex]::new($false, 'Local\GoAgentInstallerSmoke') + $ownsMutex = $mutex.WaitOne(0) + if (-not $ownsMutex) { throw 'Another GoAgent installer smoke test is running.' } + + $processes = @(Get-Process | Where-Object { $_.ProcessName -match '^GoAgent(?:[- ]|$)|^Uninstall GoAgent$' } | Select-Object Id, ProcessName) + $existing = [Collections.Generic.List[object]]::new() + $registryRoots = @( + 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall', + 'HKCU:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall', + 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall', + 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall' + ) + foreach ($registryRoot in $registryRoots) { + if (-not (Test-Path -LiteralPath $registryRoot)) { continue } + foreach ($key in (Get-ChildItem -LiteralPath $registryRoot)) { + $entry = Get-ItemProperty -LiteralPath $key.PSPath + if ($entry.DisplayName -match '^GoAgent(?:\s|$)') { + $existing.Add([pscustomobject]@{ registryKey = $key.Name; displayName = $entry.DisplayName; installLocation = $entry.InstallLocation }) + } + } + } + foreach ($base in @($env:LOCALAPPDATA, $env:ProgramFiles, ${env:ProgramFiles(x86)}, $env:ProgramW6432) | Where-Object { $_ } | Select-Object -Unique) { + foreach ($relative in @('GoAgent', 'Programs\GoAgent')) { + $candidate = Join-Path $base $relative + if (Test-Path -LiteralPath (Join-Path $candidate 'GoAgent.exe') -PathType Leaf) { + $existing.Add([pscustomobject]@{ installLocation = $candidate }) + } + } + } + $report.preflight = [pscustomobject]@{ processes = $processes; existingInstallations = @($existing.ToArray()); passed = ($processes.Count -eq 0 -and $existing.Count -eq 0) } + if (-not $report.preflight.passed) { throw 'Refusing to install: an existing GoAgent installation or active GoAgent/installer process was found. Nothing was changed.' } + + $tempParent = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()) + $testRoot = Join-Path $tempParent ('goagent-installer-smoke-' + [Guid]::NewGuid().ToString('N')) + [void](New-Item -ItemType Directory -Path $testRoot) + $installDir = Assert-WithinTestRoot -Path (Join-Path $testRoot '安装 GoAgent 测试') -TestRoot $testRoot + $report.testRoot = $testRoot + $report.installDir = $installDir + $installationAttempted = $true + # NSIS requires /D to be last and unquoted, including when the path has spaces. + $report.installation = Invoke-HiddenProcess -File $installerPath -Arguments "/S /currentuser /D=$installDir" -WorkingDirectory $testRoot + if ($report.installation.timedOut) { throw 'Installer timed out after 180 seconds.' } + if ($report.installation.exitCode -ne 0) { throw "Installer exited with code $($report.installation.exitCode)." } + [void](Assert-WithinTestRoot -Path $installDir -TestRoot $testRoot) + $report.integrity = Compare-InstallFiles -Expected $expectedPath -Actual $installDir + if (-not $report.integrity.ok) { throw "Installed file integrity failed: $($report.integrity.missingFiles.Count) missing and $($report.integrity.changedFiles.Count) changed files." } +} catch { + $report.errors.Add($_.Exception.Message) +} finally { + if ($installationAttempted) { + try { + [void](Assert-WithinTestRoot -Path $installDir -TestRoot $testRoot) + $uninstaller = Join-Path $installDir 'Uninstall GoAgent.exe' + if (-not (Test-Path -LiteralPath $uninstaller -PathType Leaf)) { throw "Test installation has no uninstaller; leaving its dedicated directory for diagnosis: $installDir" } + [void](Assert-WithinTestRoot -Path $uninstaller -TestRoot $testRoot) + # _?= keeps this uninstaller synchronous and fixes its target to our folder. + # No --delete-app-data flag: personal settings and accounts must be preserved. + $report.uninstallation = Invoke-HiddenProcess -File $uninstaller -Arguments "/S /currentuser _?=$installDir" -WorkingDirectory $testRoot + $report.executableRemoved = -not (Test-Path -LiteralPath (Join-Path $installDir 'GoAgent.exe')) + if ($report.uninstallation.timedOut) { throw 'Uninstaller timed out after 180 seconds.' } + if ($report.uninstallation.exitCode -ne 0) { throw "Uninstaller exited with code $($report.uninstallation.exitCode)." } + if (-not $report.executableRemoved) { throw 'Uninstaller returned but the test GoAgent.exe still exists.' } + # Delete only the fresh, script-owned root after successful uninstallation. + [void](Assert-WithinTestRoot -Path $testRoot -TestRoot $tempParent) + [void](Assert-WithinTestRoot -Path $installDir -TestRoot $testRoot) + Remove-Item -LiteralPath $testRoot -Recurse -Force + $report.testRootRemoved = -not (Test-Path -LiteralPath $testRoot) + } catch { + $report.errors.Add('Cleanup: ' + $_.Exception.Message) + } + } + if ($ownsMutex) { $mutex.ReleaseMutex() } + if ($null -ne $mutex) { $mutex.Dispose() } + $report.finishedAt = [DateTime]::UtcNow.ToString('o') + $report.ok = ($report.errors.Count -eq 0 -and $null -ne $report.integrity -and $report.integrity.ok -and $report.executableRemoved -and $report.testRootRemoved) + $report | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $evidenceFile -Encoding utf8 +} + +[pscustomobject]@{ + ok = $report.ok; evidencePath = $evidenceFile; installDir = $installDir + installExitCode = $report.installation.exitCode; uninstallExitCode = $report.uninstallation.exitCode + expectedFileCount = $report.integrity.expectedFileCount; matchedFileCount = $report.integrity.matchedFileCount + executableRemoved = $report.executableRemoved; errors = @($report.errors.ToArray()) +} | ConvertTo-Json -Depth 4 +if (-not $report.ok) { exit 1 } +exit 0 diff --git a/tests/release-preflight.test.mjs b/tests/release-preflight.test.mjs index 08b76d1..b8a80b4 100644 --- a/tests/release-preflight.test.mjs +++ b/tests/release-preflight.test.mjs @@ -99,3 +99,32 @@ test('the actual publish condition blocks failed, cancelled or skipped gates', ( assert.equal(canPublish({}, undefined, { 'can-package': 'false' }), false) assert.equal(canPublish({}, undefined, { 'can-package-nvidia': 'false' }), false) }) + +test('both Windows installer checks gate artifact upload and preserve failure evidence', () => { + for (const [name, suffix, edition] of [ + ['package', '', 'standard'], + ['package-nvidia-windows', '-nvidia', 'nvidia'] + ]) { + const content = job(name) + const steps = content.split(/^ - name: /m).slice(1) + const checkIndex = steps.findIndex(step => step.includes('./scripts/smoke_windows_installer.ps1')) + const uploadIndex = steps.findIndex(step => /^Upload (?:NVIDIA )?packaged artifacts\n/.test(step)) + assert.ok(checkIndex >= 0 && checkIndex < uploadIndex, `${name} must validate its actual installer before upload`) + const check = steps[checkIndex] + assert.match(check, /^ shell: pwsh$/m) + assert.ok(check.includes(`-Installer "release/$version/GoAgent-$version-win-x64${suffix}.exe"`)) + assert.ok(check.includes('-ExpectedDir "release/$version/win-unpacked"')) + assert.ok(check.includes(`-EvidencePath "release-evidence/windows-${edition}-installer.json"`)) + assert.match(check, /if \(\$LASTEXITCODE -ne 0\) \{ throw /) + assert.doesNotMatch(check, /continue-on-error:|if:.*(?:always\(|failure\()/) + if (edition === 'standard') assert.match(check, /^ if: runner.os == 'Windows'$/m) + + const evidence = steps[checkIndex + 1] + assert.match(evidence, /^ if: always\(\)/m) + assert.match(evidence, /uses: actions\/upload-artifact@v4/) + assert.ok(evidence.includes(`path: release-evidence/windows-${edition}-installer.json`)) + // Uploading evidence after a failure must not make package upload succeed. + assert.doesNotMatch(steps[uploadIndex], /^ (?:if:|continue-on-error:)/m) + assert.doesNotMatch(content, /^ continue-on-error:/m) + } +}) diff --git a/tests/windows-installer-smoke.test.mjs b/tests/windows-installer-smoke.test.mjs new file mode 100644 index 0000000..8c42cda --- /dev/null +++ b/tests/windows-installer-smoke.test.mjs @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import test from 'node:test' + +const scriptPath = fileURLToPath(new URL('../scripts/smoke_windows_installer.ps1', import.meta.url)) +const quote = value => `'${value.replaceAll("'", "''")}'` +const windowsOnly = { skip: process.platform !== 'win32' } + +async function fixture(t) { + const root = await mkdtemp(join(tmpdir(), 'goagent 安装 unit ')) + t.after(() => rm(root, { recursive: true, force: true })) + return root +} + +function helper(body) { + // Load function definitions from the real script without invoking its installer. + const command = ` + $ErrorActionPreference = 'Stop' + [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) + $tokens = $null; $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile(${quote(scriptPath)}, [ref]$tokens, [ref]$errors) + if ($errors.Count) { throw ($errors | Out-String) } + foreach ($fn in $ast.FindAll({ param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] }, $true)) { + Invoke-Expression $fn.Extent.Text + } + ${body} + ` + const result = spawnSync('pwsh', ['-NoProfile', '-NonInteractive', '-Command', command], { + encoding: 'utf8', timeout: 20_000, windowsHide: true + }) + assert.equal(result.status, 0, result.stderr || result.error?.message) + return JSON.parse(result.stdout.trim()) +} + +test('installer integrity detects missing DLLs and changed bytes while allowing its extra uninstaller', windowsOnly, async (t) => { + const root = await fixture(t) + const expected = join(root, 'expected'), actual = join(root, '安装 GoAgent') + await mkdir(expected); await mkdir(actual) + await writeFile(join(expected, 'GoAgent.exe'), 'same executable') + await writeFile(join(actual, 'GoAgent.exe'), 'same executable') + await writeFile(join(expected, 'missing.dll'), 'required DLL') + await writeFile(join(expected, 'changed.dll'), 'expected content') + await writeFile(join(actual, 'changed.dll'), 'different content') + await writeFile(join(actual, 'Uninstall GoAgent.exe'), 'uninstaller') + const result = helper(`Compare-InstallFiles -Expected ${quote(expected)} -Actual ${quote(actual)} | ConvertTo-Json -Depth 5`) + assert.equal(result.ok, false) + assert.equal(result.expectedFileCount, 3) + assert.equal(result.matchedFileCount, 1) + assert.deepEqual(result.missingFiles, ['missing.dll']) + assert.deepEqual(result.changedFiles, ['changed.dll']) + assert.deepEqual(result.extraFiles, ['Uninstall GoAgent.exe']) + assert.match(result.files.find(file => file.path === 'GoAgent.exe').actualSha256, /^[A-F0-9]{64}$/) + await writeFile(join(actual, 'missing.dll'), 'required DLL') + await writeFile(join(actual, 'changed.dll'), 'expected content') + const repaired = helper(`Compare-InstallFiles -Expected ${quote(expected)} -Actual ${quote(actual)} | ConvertTo-Json -Depth 5`) + assert.equal(repaired.ok, true) + assert.equal(repaired.matchedFileCount, 3) +}) + +test('installer cleanup guard rejects root, traversal, sibling-prefix and junction targets', windowsOnly, async (t) => { + const root = await fixture(t) + const owned = join(root, 'owned'), outside = join(root, 'outside'), child = join(owned, '安装 GoAgent') + await mkdir(owned); await mkdir(outside); await mkdir(child) + const junction = join(owned, 'redirect') + await symlink(outside, junction, 'junction') + const paths = [owned, join(owned, '..', 'outside'), `${owned}-sibling`, join(junction, 'GoAgent.exe')] + const result = helper(` + $allowed = Assert-WithinTestRoot -Path ${quote(child)} -TestRoot ${quote(owned)} + $rejected = @(foreach ($path in @(${paths.map(quote).join(',')})) { + try { [void](Assert-WithinTestRoot -Path $path -TestRoot ${quote(owned)}); $false } catch { $true } + }) + @{ allowed = $allowed; rejected = $rejected } | ConvertTo-Json + `) + assert.equal(result.allowed, child) + assert.deepEqual(result.rejected, [true, true, true, true]) +}) From 66934752f9722b9698ac519b210b48c1229349fd Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 23 Oct 2026 00:18:14 +0800 Subject: [PATCH 4/4] test: resolve installer fixture Windows short paths --- tests/windows-installer-smoke.test.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/windows-installer-smoke.test.mjs b/tests/windows-installer-smoke.test.mjs index 8c42cda..bd6a1c1 100644 --- a/tests/windows-installer-smoke.test.mjs +++ b/tests/windows-installer-smoke.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import { spawnSync } from 'node:child_process' -import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -11,7 +11,7 @@ const quote = value => `'${value.replaceAll("'", "''")}'` const windowsOnly = { skip: process.platform !== 'win32' } async function fixture(t) { - const root = await mkdtemp(join(tmpdir(), 'goagent 安装 unit ')) + const root = await realpath(await mkdtemp(join(tmpdir(), 'goagent 安装 unit '))) t.after(() => rm(root, { recursive: true, force: true })) return root }