Share content-addressed parsing and embedding caches across isolated test runs, fingerprint relevant dependencies and model assets, and remove implicit UI warm-up. Add realistic two-round Skill delivery evaluation and cache regressions.
296 lines
11 KiB
JavaScript
296 lines
11 KiB
JavaScript
import { spawn, spawnSync } from 'node:child_process'
|
||
import { appendFileSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
|
||
import { mkdtemp } from 'node:fs/promises'
|
||
import os from 'node:os'
|
||
import path from 'node:path'
|
||
import { extractFile, uncache } from '@electron/asar'
|
||
import { resolveBundleInstaller } from '../release/artifact-bundle.mjs'
|
||
import {
|
||
PACKAGE_PRODUCT_NAME,
|
||
cleanupPackagedE2EInstall,
|
||
renameMacPackageE2EApp,
|
||
resolveMacPackageE2EAppPath,
|
||
resolveMacPackageE2EInstallRoot,
|
||
resolveWindowsPackageE2EInstallRoot
|
||
} from './package-e2e-isolation.mjs'
|
||
import { findInstalledWindowsShortcut } from './desktop-launch.mjs'
|
||
|
||
const ROOT = process.cwd()
|
||
const LOGS_ROOT = path.join(ROOT, 'tests', 'logs', 'package')
|
||
|
||
const createRunId = () => new Date().toISOString().replaceAll(':', '-').replaceAll('.', '-')
|
||
|
||
const findMountedMacApp = (mountPath) => {
|
||
const apps = readdirSync(mountPath, { withFileTypes: true })
|
||
.filter((entry) => entry.isDirectory() && entry.name.endsWith('.app'))
|
||
.map((entry) => path.join(mountPath, entry.name))
|
||
if (apps.length !== 1) {
|
||
throw new Error(`DMG 中期望只有一个 .app,实际为 ${String(apps.length)}:${mountPath}`)
|
||
}
|
||
return apps[0]
|
||
}
|
||
|
||
const resolveMacExecutable = (appPath) => {
|
||
const plistPath = path.join(appPath, 'Contents', 'Info.plist')
|
||
const result = spawnSync(
|
||
'/usr/bin/plutil',
|
||
['-extract', 'CFBundleExecutable', 'raw', '-o', '-', plistPath],
|
||
{ encoding: 'utf8' }
|
||
)
|
||
if (result.status !== 0) {
|
||
throw new Error(`无法读取 macOS 应用可执行文件:${String(result.stderr || result.stdout)}`)
|
||
}
|
||
const executableName = String(result.stdout || '').trim()
|
||
const executablePath = path.join(appPath, 'Contents', 'MacOS', executableName)
|
||
if (!existsSync(executablePath)) throw new Error(`macOS 可执行文件不存在:${executablePath}`)
|
||
return executablePath
|
||
}
|
||
|
||
const assertMacAppNotRunning = (executablePath) => {
|
||
const result = spawnSync('/usr/bin/pgrep', ['-f', executablePath], { encoding: 'utf8' })
|
||
if (result.status === 0 && String(result.stdout || '').trim()) {
|
||
throw new Error(`测试安装目录中的应用仍在运行,请先关闭:${executablePath}`)
|
||
}
|
||
}
|
||
|
||
const clearQuarantine = (targetPath) => {
|
||
const inspect = spawnSync('/usr/bin/xattr', ['-p', 'com.apple.quarantine', targetPath], {
|
||
encoding: 'utf8'
|
||
})
|
||
if (inspect.status !== 0) return
|
||
const clear = spawnSync('/usr/bin/xattr', ['-r', '-d', 'com.apple.quarantine', targetPath], {
|
||
encoding: 'utf8'
|
||
})
|
||
if (clear.status !== 0) {
|
||
throw new Error(
|
||
[
|
||
`无法清除 quarantine:${targetPath}`,
|
||
'请先手动执行:',
|
||
`sudo xattr -r -d com.apple.quarantine ${JSON.stringify(targetPath)}`
|
||
].join('\n')
|
||
)
|
||
}
|
||
}
|
||
|
||
export const preparePackagedE2EApplication = async (bundlePath, { resultPath = '' } = {}) => {
|
||
if (process.platform !== 'darwin' && process.platform !== 'win32') {
|
||
throw new Error(`正式包 E2E 只支持 macOS 和 Windows,当前平台:${process.platform}`)
|
||
}
|
||
if (!bundlePath) throw new Error('正式包 E2E 必须提供 --bundle <release-bundle.json>')
|
||
|
||
const expectedPlatform = process.platform === 'win32' ? 'windows-x64' : undefined
|
||
const resolvedBundlePath = path.resolve(ROOT, bundlePath)
|
||
const { bundle, installer, installerPath } = await resolveBundleInstaller(
|
||
resolvedBundlePath,
|
||
expectedPlatform ? { platform: expectedPlatform } : {}
|
||
)
|
||
if (process.platform === 'darwin' && !bundle.platform.startsWith('macos-')) {
|
||
throw new Error(`macOS 不能测试 ${bundle.platform} 发布包`)
|
||
}
|
||
|
||
const runId = createRunId()
|
||
const logDir = path.join(LOGS_ROOT, `${runId}-${bundle.platform}`)
|
||
const processLogPath = path.join(logDir, 'build-install.log')
|
||
mkdirSync(logDir, { recursive: true })
|
||
writeFileSync(processLogPath, '', 'utf8')
|
||
|
||
const log = (message) => {
|
||
const line = `[${new Date().toISOString()}] ${message}\n`
|
||
process.stdout.write(line)
|
||
appendFileSync(processLogPath, line, 'utf8')
|
||
}
|
||
|
||
const runLogged = async (command, args, options = {}) => {
|
||
log(`运行:${[command, ...args].join(' ')}`)
|
||
await new Promise((resolve, reject) => {
|
||
const child = spawn(command, args, {
|
||
cwd: options.cwd || ROOT,
|
||
env: options.env || process.env,
|
||
shell: process.platform === 'win32',
|
||
stdio: ['inherit', 'pipe', 'pipe']
|
||
})
|
||
const forward = (target, chunk) => {
|
||
target.write(chunk)
|
||
appendFileSync(processLogPath, chunk)
|
||
}
|
||
child.stdout.on('data', (chunk) => forward(process.stdout, chunk))
|
||
child.stderr.on('data', (chunk) => forward(process.stderr, chunk))
|
||
child.once('error', reject)
|
||
child.once('exit', (code, signal) => {
|
||
if (code === 0) resolve()
|
||
else reject(new Error(`${command} 失败:code=${String(code)} signal=${String(signal)}`))
|
||
})
|
||
})
|
||
}
|
||
|
||
const artifactPath = installerPath
|
||
let installedAppPath = ''
|
||
let executablePath = ''
|
||
|
||
try {
|
||
if (process.platform === 'darwin') {
|
||
if (!artifactPath.toLowerCase().endsWith('.dmg')) {
|
||
throw new Error(`macOS release bundle installer must be a DMG: ${artifactPath}`)
|
||
}
|
||
clearQuarantine(artifactPath)
|
||
const mountPath = await mkdtemp(path.join(os.tmpdir(), 'ncurator-e2e-dmg-'))
|
||
let mounted = false
|
||
try {
|
||
await runLogged('/usr/bin/hdiutil', [
|
||
'attach',
|
||
artifactPath,
|
||
'-nobrowse',
|
||
'-readonly',
|
||
'-mountpoint',
|
||
mountPath
|
||
])
|
||
mounted = true
|
||
const sourceAppPath = findMountedMacApp(mountPath)
|
||
if (path.basename(sourceAppPath) !== `${PACKAGE_PRODUCT_NAME}.app`) {
|
||
throw new Error(
|
||
`macOS DMG 应用名须为 ${PACKAGE_PRODUCT_NAME}.app,实际为 ${path.basename(sourceAppPath)}`
|
||
)
|
||
}
|
||
const installRoot = path.join(resolveMacPackageE2EInstallRoot(), bundle.platform)
|
||
installedAppPath = installRoot
|
||
const stagedAppPath = path.join(installRoot, `${PACKAGE_PRODUCT_NAME}.app`)
|
||
const isolatedAppPath = resolveMacPackageE2EAppPath(stagedAppPath)
|
||
for (const existingAppPath of [stagedAppPath, isolatedAppPath]) {
|
||
if (!existsSync(existingAppPath)) continue
|
||
assertMacAppNotRunning(resolveMacExecutable(existingAppPath))
|
||
}
|
||
mkdirSync(installRoot, { recursive: true })
|
||
rmSync(stagedAppPath, { recursive: true, force: true })
|
||
rmSync(isolatedAppPath, { recursive: true, force: true })
|
||
await runLogged('/usr/bin/ditto', [sourceAppPath, stagedAppPath])
|
||
clearQuarantine(stagedAppPath)
|
||
installedAppPath = renameMacPackageE2EApp(stagedAppPath, { log })
|
||
executablePath = resolveMacExecutable(installedAppPath)
|
||
} finally {
|
||
if (mounted) {
|
||
await runLogged('/usr/bin/hdiutil', ['detach', mountPath])
|
||
}
|
||
rmSync(mountPath, { recursive: true, force: true })
|
||
}
|
||
} else {
|
||
if (!artifactPath.toLowerCase().endsWith('.exe')) {
|
||
throw new Error(`Windows release bundle installer must be an EXE: ${artifactPath}`)
|
||
}
|
||
const installRoot = resolveWindowsPackageE2EInstallRoot()
|
||
mkdirSync(installRoot, { recursive: true })
|
||
installedAppPath = installRoot
|
||
await runLogged(artifactPath, ['/S', `/D=${installRoot}`])
|
||
executablePath = path.join(installRoot, `${PACKAGE_PRODUCT_NAME}.exe`)
|
||
if (!existsSync(executablePath)) {
|
||
throw new Error(`Windows 安装后的应用不存在:${executablePath}`)
|
||
}
|
||
log(`Windows 正式包 E2E 已安装到:${executablePath}`)
|
||
}
|
||
} catch (error) {
|
||
if (installedAppPath) {
|
||
try {
|
||
cleanupPackagedE2EInstall({ installRoot: installedAppPath, log })
|
||
} catch (cleanupError) {
|
||
log(
|
||
`安装失败后清理 E2E 安装失败:${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`
|
||
)
|
||
}
|
||
}
|
||
throw error
|
||
}
|
||
|
||
const artifactSha256 = installer.sha256
|
||
let launchEntryPath
|
||
try {
|
||
launchEntryPath =
|
||
process.platform === 'win32' ? findInstalledWindowsShortcut(executablePath) : installedAppPath
|
||
} catch (error) {
|
||
cleanupPackagedE2EInstall({ installRoot: installedAppPath, log })
|
||
throw error
|
||
}
|
||
const metadata = {
|
||
schemaVersion: 1,
|
||
runId,
|
||
createdAt: new Date().toISOString(),
|
||
platform: process.platform,
|
||
arch: process.arch,
|
||
releaseTag: bundle.tag,
|
||
releaseCommit: bundle.commit,
|
||
releasePlatform: bundle.platform,
|
||
bundlePath: resolvedBundlePath,
|
||
artifactPath,
|
||
artifactSha256,
|
||
installedAppPath,
|
||
executablePath,
|
||
launchEntryPath
|
||
}
|
||
writeFileSync(
|
||
path.join(logDir, 'artifact.json'),
|
||
`${JSON.stringify(metadata, null, 2)}\n`,
|
||
'utf8'
|
||
)
|
||
log(`正式包已安装:${installedAppPath}`)
|
||
|
||
return {
|
||
...metadata,
|
||
logDir,
|
||
processLogPath,
|
||
env: {
|
||
NCURATOR_E2E_APP_TARGET: 'package',
|
||
NCURATOR_E2E_EXECUTABLE: executablePath,
|
||
NCURATOR_E2E_LAUNCH_ENTRY: launchEntryPath,
|
||
NCURATOR_E2E_ARTIFACT_PATH: artifactPath,
|
||
NCURATOR_E2E_ARTIFACT_SHA256: artifactSha256,
|
||
NCURATOR_BETTER_SQLITE3_NATIVE_BINDING: ''
|
||
},
|
||
writeSummary: (input) => {
|
||
const summary = `${JSON.stringify(
|
||
{ ...metadata, finishedAt: new Date().toISOString(), ...input },
|
||
null,
|
||
2
|
||
)}\n`
|
||
writeFileSync(path.join(logDir, 'summary.json'), summary, 'utf8')
|
||
if (resultPath) {
|
||
const resolvedResultPath = path.resolve(ROOT, resultPath)
|
||
mkdirSync(path.dirname(resolvedResultPath), { recursive: true })
|
||
writeFileSync(resolvedResultPath, summary, 'utf8')
|
||
}
|
||
},
|
||
cleanup: () => {
|
||
log('开始清理正式包 E2E 安装')
|
||
return cleanupPackagedE2EApplication(metadata, { log })
|
||
}
|
||
}
|
||
}
|
||
|
||
export const cleanupPackagedE2EApplication = (session, { log = () => {} } = {}) => {
|
||
const installRoot = session?.installedAppPath
|
||
if (!installRoot) return { removed: false }
|
||
return cleanupPackagedE2EInstall({ installRoot, log })
|
||
}
|
||
|
||
// Cache identity must come from the installed artifact, never the current source tree.
|
||
export const readPackagedKnowledgeCacheIdentity = (
|
||
installedAppPath,
|
||
platform = process.platform
|
||
) => {
|
||
const resources =
|
||
platform === 'darwin'
|
||
? path.join(installedAppPath, 'Contents', 'Resources')
|
||
: path.join(installedAppPath, 'resources')
|
||
uncache(path.join(resources, 'app.asar'))
|
||
try {
|
||
const identity = JSON.parse(
|
||
extractFile(
|
||
path.join(resources, 'app.asar'),
|
||
'out/main/knowledge-cache-identity.json'
|
||
).toString('utf8')
|
||
)
|
||
return /^[a-f0-9]{64}$/.test(identity.split) && /^[a-f0-9]{64}$/.test(identity.embedding)
|
||
? identity
|
||
: null
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|