Files
Ncurator/scripts/test/package-e2e-isolation.mjs
Yoan Huang 33de80f1c3 feat(e2e): enhance E2E installation and cleanup process
- Refactor E2E installation logic to improve handling of Windows and macOS paths.
- Introduce cleanup functions to remove E2E installation directories and associated shortcuts.
- Update tests to cover new cleanup functionality and ensure proper detection of E2E install paths.
- Modify package isolation helpers to streamline the installation and cleanup processes.
2026-09-17 23:23:28 +08:00

236 lines
8.6 KiB
JavaScript

import { spawnSync } from 'node:child_process'
import { existsSync, renameSync, rmSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
/** 与 electron-builder productName 一致;候选包本身不改名。 */
export const PACKAGE_PRODUCT_NAME = '馆长'
/** macOS 测试安装副本显示名;测完随 NCurator-E2E 目录删除。 */
export const PACKAGE_E2E_DISPLAY_NAME = '馆长 E2E'
const PACKAGE_E2E_ROOT_NAME = 'NCurator-E2E'
const e2ePathSegmentPattern = /(^|[\\/])NCurator-E2E(?=[\\/]|$)/iu
export const isPackageE2EInstallPath = (targetPath) => {
const raw = String(targetPath || '').trim()
if (!raw) return false
return e2ePathSegmentPattern.test(raw)
}
export const resolvePackageE2ECleanupRoot = (targetPath) => {
const raw = String(targetPath || '').trim()
if (!isPackageE2EInstallPath(raw)) {
throw new Error(`拒绝清理非 E2E 安装路径:${raw || '(empty)'}`)
}
let current = path.resolve(raw)
while (path.basename(current) !== PACKAGE_E2E_ROOT_NAME) {
const parent = path.dirname(current)
if (parent === current) {
throw new Error(`无法解析 E2E 安装根目录:${raw}`)
}
current = parent
}
return current
}
export const isPackageE2ELauncherTarget = (targetPath, installRoot) => {
if (!isPackageE2EInstallPath(installRoot) || !String(targetPath || '').trim()) return false
const root = path.resolve(installRoot)
let target
try {
target = path.resolve(String(targetPath))
} catch {
return false
}
const prefix = root.endsWith(path.sep) ? root : `${root}${path.sep}`
if (process.platform === 'win32') {
return (
target.toLowerCase() === root.toLowerCase() ||
target.toLowerCase().startsWith(prefix.toLowerCase())
)
}
return target === root || target.startsWith(prefix)
}
export const resolveWindowsPackageE2EInstallRoot = () =>
path.join(os.homedir(), 'AppData', 'Local', PACKAGE_E2E_ROOT_NAME)
export const resolveMacPackageE2EInstallRoot = () =>
path.join(os.homedir(), 'Applications', PACKAGE_E2E_ROOT_NAME)
export const resolveMacPackageE2EAppPath = (installedAppPath) => {
const currentName = path.basename(installedAppPath)
if (currentName === `${PACKAGE_E2E_DISPLAY_NAME}.app`) return installedAppPath
if (currentName !== `${PACKAGE_PRODUCT_NAME}.app`) {
throw new Error(
`macOS 正式包 E2E 期望安装名为 ${PACKAGE_PRODUCT_NAME}.app,实际为 ${currentName}`
)
}
return path.join(path.dirname(installedAppPath), `${PACKAGE_E2E_DISPLAY_NAME}.app`)
}
/**
* 把安装副本改名为「馆长 E2E.app」,避免与正式 Applications 下的「馆长.app」同名混淆。
* 只改本机 E2E 安装副本,不修改 DMG 内原始产物。
*/
export const renameMacPackageE2EApp = (installedAppPath, { log = () => {} } = {}) => {
const targetAppPath = resolveMacPackageE2EAppPath(installedAppPath)
if (path.resolve(installedAppPath) === path.resolve(targetAppPath)) {
log(`macOS E2E 应用已是隔离名:${targetAppPath}`)
return targetAppPath
}
if (existsSync(targetAppPath)) {
rmSync(targetAppPath, { recursive: true, force: true })
}
renameSync(installedAppPath, targetAppPath)
log(`macOS E2E 应用已改名为:${targetAppPath}`)
return targetAppPath
}
const runPowerShell = (script) => {
const encoded = Buffer.from(script, 'utf16le').toString('base64')
return spawnSync(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded],
{ encoding: 'utf8', windowsHide: true, maxBuffer: 4 * 1024 * 1024 }
)
}
const runPowerShellJson = (script, { log = () => {} } = {}) => {
const result = runPowerShell(script)
const stdout = String(result.stdout || '').trim()
const stderr = String(result.stderr || '').trim()
if (result.status !== 0) {
throw new Error(
`PowerShell E2E 清理失败:${stderr || stdout || `exit ${String(result.status)}`}`
)
}
if (!stdout) return null
const jsonLine = stdout
.split(/\r?\n/u)
.map((line) => line.trim())
.filter(Boolean)
.at(-1)
if (!jsonLine) return null
if (!jsonLine.startsWith('{') && !jsonLine.startsWith('[')) {
log(stdout)
return null
}
return JSON.parse(jsonLine)
}
const stopWindowsPackageE2EProcesses = (cleanupRoot, { log = () => {} } = {}) => {
const result = runPowerShell(`
$ErrorActionPreference = 'SilentlyContinue'
$root = [IO.Path]::GetFullPath(${JSON.stringify(cleanupRoot)}).TrimEnd('\\') + '\\'
Get-CimInstance Win32_Process | Where-Object {
$_.ExecutablePath -and $_.ExecutablePath.StartsWith($root, [StringComparison]::OrdinalIgnoreCase)
} | ForEach-Object {
Stop-Process -Id $_.ProcessId -Force
}
`)
if (result.status !== 0) {
log(`结束 E2E 进程时出现警告:${String(result.stderr || result.stdout || '').trim()}`)
}
}
const cleanupWindowsPackageE2ELaunchers = (cleanupRoot, { log = () => {} } = {}) => {
const result = runPowerShellJson(
`
$ErrorActionPreference = 'Stop'
$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::UTF8
$installRoot = [IO.Path]::GetFullPath(${JSON.stringify(cleanupRoot)})
function Test-E2EPath([string]$target) {
if ([string]::IsNullOrWhiteSpace($target)) { return $false }
if ($target.ToLower().Contains($installRoot.ToLower())) { return $true }
try {
$full = [IO.Path]::GetFullPath($target.Trim().Trim('"'))
return $full.StartsWith($installRoot, [StringComparison]::OrdinalIgnoreCase)
} catch {
return $false
}
}
$dirs = @(
[Environment]::GetFolderPath('Desktop'),
[Environment]::GetFolderPath('CommonDesktopDirectory'),
(Join-Path $env:APPDATA 'Microsoft\\Windows\\Start Menu\\Programs'),
(Join-Path $env:ProgramData 'Microsoft\\Windows\\Start Menu\\Programs')
) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -Unique
$shell = New-Object -ComObject WScript.Shell
$removedShortcuts = 0
foreach ($dir in $dirs) {
Get-ChildItem -LiteralPath $dir -Filter '*.lnk' -Recurse -ErrorAction SilentlyContinue | ForEach-Object {
$shortcut = $shell.CreateShortcut($_.FullName)
if (-not (Test-E2EPath ([string]$shortcut.TargetPath))) { return }
Remove-Item -LiteralPath $_.FullName -Force
$script:removedShortcuts++
}
}
$removedUninstall = 0
$uninstallRoot = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall'
if (Test-Path $uninstallRoot) {
Get-ChildItem $uninstallRoot -ErrorAction SilentlyContinue | ForEach-Object {
$props = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
if ($null -eq $props) { return }
$hit = $false
foreach ($value in @($props.InstallLocation, $props.UninstallString, $props.QuietUninstallString, $props.DisplayIcon)) {
if (Test-E2EPath ([string]$value)) { $hit = $true; break }
}
if (-not $hit) { return }
Remove-Item -LiteralPath $_.PSPath -Recurse -Force
$script:removedUninstall++
}
}
$softwareKey = 'HKCU:\\Software\\com.yoan.ncurator'
if (Test-Path $softwareKey) {
$installLocation = [string](Get-ItemProperty $softwareKey -ErrorAction SilentlyContinue).InstallLocation
if (Test-E2EPath $installLocation) {
Remove-Item -LiteralPath $softwareKey -Recurse -Force
}
}
Write-Output (@{
removedShortcuts = $removedShortcuts
removedUninstall = $removedUninstall
} | ConvertTo-Json -Compress)
`,
{ log }
)
log(
`已清理指向 E2E 的启动入口:快捷方式 ${String(result?.removedShortcuts ?? 0)} 个,卸载项 ${String(result?.removedUninstall ?? 0)} 个`
)
return result
}
const sleep = (ms) => {
const buffer = new Int32Array(new SharedArrayBuffer(4))
Atomics.wait(buffer, 0, 0, ms)
}
export const removePackageE2EInstallDirectory = (installRoot, { log = () => {} } = {}) => {
const cleanupRoot = resolvePackageE2ECleanupRoot(installRoot)
if (!existsSync(cleanupRoot)) {
log(`E2E 安装目录不存在,跳过:${cleanupRoot}`)
return { cleanupRoot, removed: false }
}
for (let attempt = 0; attempt < 8; attempt += 1) {
rmSync(cleanupRoot, { recursive: true, force: true })
if (!existsSync(cleanupRoot)) {
log(`已删除 E2E 安装目录:${cleanupRoot}`)
return { cleanupRoot, removed: true }
}
sleep(250)
}
throw new Error(`无法删除 E2E 安装目录:${cleanupRoot}`)
}
/** 测完删除 NCurator-E2E 安装目录;Windows 同时去掉指向它的快捷方式和卸载项。 */
export const cleanupPackagedE2EInstall = ({ installRoot, log = () => {} } = {}) => {
const cleanupRoot = resolvePackageE2ECleanupRoot(installRoot)
if (process.platform === 'win32') {
stopWindowsPackageE2EProcesses(cleanupRoot, { log })
cleanupWindowsPackageE2ELaunchers(cleanupRoot, { log })
sleep(400)
}
return removePackageE2EInstallDirectory(cleanupRoot, { log })
}