Files
Ncurator/scripts/test/update-real-windows.mjs
hy 240ab91f7b refactor: remove unused test scripts and improve argument validation
- Removed `test:ai`, `test:settings`, and `test:workbench` scripts from package.json.
- Updated test plan documentation to reflect the removal of `test:workbench`.
- Enhanced argument parsing in release scripts to ensure required values are provided.
- Added validation for unknown arguments in release scripts.
- Improved error handling for invalid arguments in test workflow.
- Updated unit tests to cover new validation logic for release arguments.
2026-09-18 14:49:01 +08:00

690 lines
25 KiB
JavaScript

import { execFile, spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
import { access, cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
import http from 'node:http'
import net from 'node:net'
import path from 'node:path'
import process from 'node:process'
import { promisify } from 'node:util'
import { extractFile, uncache } from '@electron/asar'
import { _electron as electron } from 'playwright'
const execFileAsync = promisify(execFile)
const ROOT = process.cwd()
const DEFAULT_TIMEOUT_MS = 45 * 60 * 1000
const VALUE_OPTIONS = new Set([
'--source-installer',
'--source-version',
'--expected-version',
'--target-installer',
'--downgrade-installer',
'--timeout-ms'
])
const USAGE = `Usage:
node scripts/test/update-real-windows.mjs
--source-installer <path>
--source-version <x.y.z>
--expected-version <x.y.z>
[--target-installer <path>]
[--downgrade-installer <path>]
[--timeout-ms <positive-integer>]
[--keep-work]
[--reuse-update-cache]
[--help]
`
const parseArgs = (argv) => {
const result = {
sourceInstaller: '',
sourceVersion: '',
expectedVersion: '',
targetInstaller: '',
downgradeInstaller: '',
timeoutMs: DEFAULT_TIMEOUT_MS,
keepWork: false,
reuseUpdateCache: false,
help: false
}
for (let index = 0; index < argv.length; index += 1) {
const key = argv[index]
const value = argv[index + 1]
if (key === '--help') {
result.help = true
continue
}
if (key === '--keep-work') {
result.keepWork = true
continue
} else if (key === '--reuse-update-cache') {
result.reuseUpdateCache = true
continue
} else if (VALUE_OPTIONS.has(key)) {
if (!value || value.startsWith('--')) throw new Error(`${key} requires a value`)
if (key === '--source-installer') result.sourceInstaller = path.resolve(value)
if (key === '--source-version') result.sourceVersion = value
if (key === '--expected-version') result.expectedVersion = value
if (key === '--target-installer') result.targetInstaller = path.resolve(value)
if (key === '--downgrade-installer') result.downgradeInstaller = path.resolve(value)
if (key === '--timeout-ms') {
const timeoutMs = Number(value)
if (!Number.isInteger(timeoutMs) || timeoutMs < 1) {
throw new Error('--timeout-ms must be a positive integer')
}
result.timeoutMs = timeoutMs
}
} else {
throw new Error(`unknown argument: ${key}`)
}
index += 1
}
return result
}
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const pathExists = (filePath) =>
access(filePath)
.then(() => true)
.catch(() => false)
const validateVersionPair = (sourceVersion, expectedVersion) => {
const parseVersion = (version) => {
const match = version.match(/^(\d+)\.(\d+)\.(\d+)$/u)
return match ? match.slice(1).map(Number) : undefined
}
const source = parseVersion(sourceVersion)
const expected = parseVersion(expectedVersion)
if (!source || !expected) {
throw new Error(
'Pass explicit --source-version and --expected-version values, for example 0.1.68 -> 0.1.69'
)
}
const comparison = expected.findIndex((value, index) => value !== source[index])
if (comparison < 0 || expected[comparison] < source[comparison]) {
throw new Error('expected-version must be newer than source-version')
}
}
const allocatePort = async () => {
const server = net.createServer()
await new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
const address = server.address()
if (!address || typeof address === 'string') throw new Error('Unable to allocate a debug port')
await new Promise((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve()))
)
return address.port
}
const runInherited = (command, args, options = {}) =>
new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: ROOT,
env: process.env,
stdio: 'inherit',
shell: process.platform === 'win32' && /\.(cmd|bat)$/iu.test(command),
...options
})
child.once('error', reject)
child.once('exit', (code, signal) => {
if (code === 0) resolve()
else reject(new Error(`${command} exited with ${String(code ?? signal)}`))
})
})
const readInstalledVersion = async (installDirectory) => {
const asarPath = path.join(installDirectory, 'resources', 'app.asar')
uncache(asarPath)
const packageJson = extractFile(asarPath, 'package.json')
return JSON.parse(packageJson.toString('utf8')).version
}
const findExecutable = async (installDirectory) => {
const entries = await readdir(installDirectory)
const executable = entries.find(
(name) => /\.exe$/iu.test(name) && !/^Uninstall .*\.exe$/iu.test(name)
)
if (!executable) throw new Error(`No main executable found in ${installDirectory}`)
return path.join(installDirectory, executable)
}
const findUninstaller = async (installDirectory) => {
const entries = await readdir(installDirectory)
const uninstaller = entries.find((name) => /^Uninstall .*\.exe$/iu.test(name))
return uninstaller ? path.join(installDirectory, uninstaller) : undefined
}
const buildInstaller = async ({ outputPath, version }) => {
process.stdout.write(`[update-real-win] Building packaged version ${version}\n`)
await runInherited('pnpm.cmd', ['run', 'build'])
const electronBuilder = path.join(ROOT, 'node_modules', '.bin', 'electron-builder.cmd')
await runInherited(electronBuilder, [
'--win',
'--x64',
'--publish',
'never',
`--config.extraMetadata.version=${version}`,
`--config.directories.output=${outputPath}`
])
const installerName = (await readdir(outputPath)).find((name) => /\.exe$/iu.test(name))
if (!installerName) throw new Error(`Built installer was not found in ${outputPath}`)
const installerPath = path.join(outputPath, installerName)
if (!(await pathExists(installerPath))) {
throw new Error(`Built installer was not found: ${installerPath}`)
}
return installerPath
}
const installSourceVersion = async (installerPath, installDirectory, logs) => {
process.stdout.write(`[update-real-win] Installing ${installerPath}\n`)
try {
const result = await execFileAsync(
installerPath,
['/S', '/currentuser', `/D=${installDirectory}`],
{
timeout: 10 * 60 * 1000,
windowsHide: true,
maxBuffer: 10 * 1024 * 1024
}
)
if (result.stdout.trim()) logs.push(`[installer:stdout] ${result.stdout.trim()}`)
if (result.stderr.trim()) logs.push(`[installer:stderr] ${result.stderr.trim()}`)
} catch (error) {
const detail =
error instanceof Error ? `${error.message} (code=${String(error.code)})` : String(error)
throw new Error(`Source installer failed: ${detail}`)
}
const executablePath = await findExecutable(installDirectory)
return executablePath
}
const verifyGuardedDowngradeIsRejected = async ({
installerPath,
installDirectory,
expectedVersion,
markerPath,
logs
}) => {
process.stdout.write(`[update-real-win] Verifying downgrade rejection with ${installerPath}\n`)
const result = await execFileAsync(
installerPath,
['/S', '/currentuser', `/D=${installDirectory}`],
{
timeout: 5 * 60 * 1000,
windowsHide: true,
maxBuffer: 10 * 1024 * 1024
}
)
if (result.stdout.trim()) logs.push(`[downgrade:stdout] ${result.stdout.trim()}`)
if (result.stderr.trim()) logs.push(`[downgrade:stderr] ${result.stderr.trim()}`)
const installedVersion = await readInstalledVersion(installDirectory)
const markerPreserved = await pathExists(markerPath)
if (installedVersion !== expectedVersion || !markerPreserved) {
throw new Error(
`Downgrade protection failed: installed=${installedVersion}, expected=${expectedVersion}, markerPreserved=${String(markerPreserved)}`
)
}
return { checked: true, rejected: true, installedVersion, markerPreserved }
}
const stopInstalledProcesses = async (installDirectory) => {
const escaped = installDirectory.replaceAll("'", "''")
const command =
`$root = [IO.Path]::GetFullPath('${escaped}').TrimEnd('\\') + '\\'; ` +
'Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | ' +
'Where-Object { $_.ExecutablePath -and $_.ExecutablePath.StartsWith($root, [StringComparison]::OrdinalIgnoreCase) } | ' +
'ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }'
await execFileAsync('powershell.exe', ['-NoProfile', '-Command', command], {
windowsHide: true
}).catch(() => undefined)
await delay(1000)
}
const waitForInstalledVersion = async (installDirectory, expectedVersion, timeoutMs) => {
const deadline = Date.now() + timeoutMs
let version = ''
while (Date.now() < deadline) {
try {
version = await readInstalledVersion(installDirectory)
if (version === expectedVersion) return version
} catch {
// The installer can briefly replace app.asar while this poll is running.
}
await delay(2000)
}
return version
}
const isUpdaterInstallerRunning = async () => {
const command =
"@(Get-CimInstance Win32_Process -Filter \"Name LIKE '%Windows-x64.exe' -or Name LIKE '%windows-x64.exe'\" -ErrorAction SilentlyContinue).Count"
const { stdout } = await execFileAsync('powershell.exe', ['-NoProfile', '-Command', command], {
windowsHide: true
})
return Number(stdout.trim()) > 0
}
const waitForUpdaterInstallerExit = async (timeoutMs) => {
const deadline = Date.now() + timeoutMs
let consecutiveNotRunning = 0
while (Date.now() < deadline) {
const running = await isUpdaterInstallerRunning()
consecutiveNotRunning = running ? 0 : consecutiveNotRunning + 1
if (consecutiveNotRunning >= 5) return
await delay(1000)
}
throw new Error('Timed out waiting for the Windows update installer to exit')
}
const launchApp = async ({ executablePath, userDataPath, port, logs }) => {
const electronApp = await electron.launch({
executablePath,
cwd: path.dirname(executablePath),
env: {
...process.env,
NCURATOR_E2E_OBSERVER: '1',
NCURATOR_E2E_WINDOW_MODE: 'visible',
NCURATOR_USER_DATA_PATH: userDataPath,
NCURATOR_EMBEDDED_BROWSER_REMOTE_DEBUGGING_PORT: String(port)
},
timeout: 120_000
})
const child = electronApp.process()
child?.stdout?.on('data', (chunk) => logs.push(`[stdout] ${String(chunk).trim()}`))
child?.stderr?.on('data', (chunk) => logs.push(`[stderr] ${String(chunk).trim()}`))
const page = await electronApp.firstWindow({ timeout: 120_000 })
await page.waitForFunction(() => Boolean(window.api), undefined, { timeout: 120_000 })
return { electronApp, page }
}
const recordSnapshot = (snapshot, statusHistory) => {
statusHistory.push({
at: new Date().toISOString(),
status: snapshot.status,
currentVersion: snapshot.currentVersion,
availableVersion: snapshot.availableVersion,
progressPercent: snapshot.progressPercent,
error: snapshot.error
})
}
const waitForStatus = async ({ page, statuses, timeoutMs, statusHistory }) => {
const deadline = Date.now() + timeoutMs
let previousStatus = ''
let previousProgressStep = -1
while (Date.now() < deadline) {
const snapshot = await page.evaluate(() => window.api.appUpdateGetSnapshot())
const progressStep = Math.floor(snapshot.progressPercent || 0)
const changed =
snapshot.status !== previousStatus ||
(snapshot.status === 'downloading' && progressStep >= previousProgressStep + 5)
if (changed) {
recordSnapshot(snapshot, statusHistory)
process.stdout.write(
`[update-real-win] ${snapshot.status}${snapshot.progressPercent == null ? '' : ` ${snapshot.progressPercent.toFixed(1)}%`}\n`
)
previousStatus = snapshot.status
previousProgressStep = progressStep
}
if (statuses.includes(snapshot.status)) return snapshot
await delay(1000)
}
throw new Error(`Timed out waiting for update status: ${statuses.join(', ')}`)
}
const createLocalFeed = async ({ directory }) => {
const server = http.createServer((req, res) => {
const requestPath = decodeURIComponent((req.url || '/').split('?')[0]).replace(/^\/+/u, '')
const filePath = path.join(directory, requestPath)
if (!path.resolve(filePath).startsWith(path.resolve(directory))) {
res.writeHead(403)
res.end()
return
}
readFile(filePath)
.then((data) => {
const contentType = requestPath.endsWith('.yml')
? 'text/yaml; charset=utf-8'
: 'application/octet-stream'
res.writeHead(200, {
'content-type': contentType,
'content-length': data.length,
'cache-control': 'no-store'
})
res.end(data)
})
.catch(() => {
res.writeHead(404)
res.end()
})
})
await new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
const address = server.address()
const port = address && typeof address === 'object' ? address.port : 0
return {
server,
url: `http://127.0.0.1:${port}`,
close: () => new Promise((resolve) => server.close(resolve))
}
}
const sha512Base64 = async (filePath) => {
const data = await readFile(filePath)
return createHash('sha512').update(data).digest('base64')
}
const writeLatestYml = async ({ installerPath, version, feedDirectory }) => {
const fileName = path.basename(installerPath)
const stats = await stat(installerPath)
const sha512 = await sha512Base64(installerPath)
const latest = [
`version: ${version}`,
'files:',
` - url: ${fileName}`,
` sha512: ${sha512}`,
` size: ${stats.size}`,
`path: ${fileName}`,
`sha512: ${sha512}`,
`releaseDate: '${new Date().toISOString()}'`,
''
].join('\n')
await writeFile(path.join(feedDirectory, 'latest.yml'), latest, 'utf8')
return latest
}
const repointInstalledUpdateUrl = async (installDirectory, updateUrl) => {
const configPath = path.join(installDirectory, 'resources', 'app-update.yml')
const config = await readFile(configPath, 'utf8')
const updated = config.replace(/^url:\s*\S+.*$/mu, `url: ${updateUrl}`)
if (updated === config) {
throw new Error(`Installed application is missing its update url: ${configPath}`)
}
await writeFile(configPath, updated, 'utf8')
return updateUrl
}
const uninstallTestApp = async (installDirectory, logs) => {
await stopInstalledProcesses(installDirectory)
const uninstallerPath = await findUninstaller(installDirectory)
if (!uninstallerPath) return
const result = await execFileAsync(uninstallerPath, ['/S'], {
timeout: 5 * 60 * 1000,
windowsHide: true,
maxBuffer: 10 * 1024 * 1024
}).catch((error) => ({ stdout: '', stderr: String(error) }))
if (result.stdout?.trim()) logs.push(`[uninstaller:stdout] ${result.stdout.trim()}`)
if (result.stderr?.trim()) logs.push(`[uninstaller:stderr] ${result.stderr.trim()}`)
const deadline = Date.now() + 60_000
while (Date.now() < deadline) {
const present = await findExecutable(installDirectory)
.then(() => true)
.catch(() => false)
if (!present) return
await delay(1000)
}
}
const main = async () => {
const options = parseArgs(process.argv.slice(2))
if (options.help) {
process.stdout.write(USAGE)
return
}
if (process.platform !== 'win32' || process.arch !== 'x64') {
throw new Error('The Windows real update test requires Windows x64')
}
validateVersionPair(options.sourceVersion, options.expectedVersion)
const runId = new Date().toISOString().replace(/[:.]/gu, '-')
const reportDirectory = path.join(
ROOT,
'tests',
'logs',
'settings',
'update',
'real-windows',
runId
)
const runtimeDirectory = path.join(ROOT, '.codex-tmp', 'app-update-real-windows', runId)
const outputPath = path.join(runtimeDirectory, 'packaged')
const feedDirectory = path.join(runtimeDirectory, 'feed')
const installDirectory = path.join(process.env.LOCALAPPDATA, 'Programs', 'Ncurator')
const userDataPath = path.join(runtimeDirectory, 'user-data')
const updaterCacheDirectory = path.join(process.env.LOCALAPPDATA, 'ncurator-updater')
const updaterCacheBackupDirectory = path.join(
process.env.LOCALAPPDATA,
`ncurator-updater.update-real-backup-${runId}`
)
const markerPath = path.join(userDataPath, 'update-smoke-marker.txt')
const logs = []
const statusHistory = []
const startedAt = new Date().toISOString()
let firstLaunch
let finalLaunch
let report
let feed
let updateUrl = 'local-feed'
let updaterCacheBackedUp = false
let testInstallationOwned = false
await mkdir(reportDirectory, { recursive: true })
await mkdir(runtimeDirectory, { recursive: true })
await mkdir(userDataPath, { recursive: true })
await mkdir(feedDirectory, { recursive: true })
await writeFile(markerPath, `created=${startedAt}\n`, 'utf8')
try {
if (await pathExists(installDirectory)) {
const entries = await readdir(installDirectory).catch(() => [])
if (entries.some((name) => /\.exe$/iu.test(name))) {
throw new Error(
`Refusing to replace an existing Ncurator installation: ${installDirectory}`
)
}
}
testInstallationOwned = true
if (!options.reuseUpdateCache && (await pathExists(updaterCacheDirectory))) {
await rename(updaterCacheDirectory, updaterCacheBackupDirectory)
updaterCacheBackedUp = true
}
const sourceInstaller =
options.sourceInstaller ||
(await buildInstaller({ outputPath, version: options.sourceVersion }))
options.sourceInstaller = sourceInstaller
const executablePath = await installSourceVersion(sourceInstaller, installDirectory, logs)
const preparedVersion = await readInstalledVersion(installDirectory)
if (preparedVersion !== options.sourceVersion) {
throw new Error(
`Installed source version is ${preparedVersion}, expected ${options.sourceVersion}`
)
}
const targetInstaller =
options.targetInstaller ||
(await buildInstaller({ outputPath, version: options.expectedVersion }))
options.targetInstaller = targetInstaller
const stagedInstallerPath = path.join(feedDirectory, path.basename(targetInstaller))
await cp(targetInstaller, stagedInstallerPath)
await writeLatestYml({
installerPath: stagedInstallerPath,
version: options.expectedVersion,
feedDirectory
})
feed = await createLocalFeed({ directory: feedDirectory })
updateUrl = feed.url
process.stdout.write(`[update-real-win] Local update feed: ${updateUrl}\n`)
await repointInstalledUpdateUrl(installDirectory, updateUrl)
firstLaunch = await launchApp({
executablePath,
userDataPath,
port: await allocatePort(),
logs
})
const initialSnapshot = await firstLaunch.page.evaluate(() => window.api.appUpdateGetSnapshot())
recordSnapshot(initialSnapshot, statusHistory)
if (!initialSnapshot.isPackaged || initialSnapshot.currentVersion !== options.sourceVersion) {
throw new Error(
`Packaged application reported an invalid version: ${initialSnapshot.currentVersion}`
)
}
await firstLaunch.page.screenshot({
path: path.join(reportDirectory, '01-installed-source.png'),
fullPage: true
})
await firstLaunch.page.evaluate(() => window.api.appUpdateCheck())
const available = await waitForStatus({
page: firstLaunch.page,
statuses: ['available', 'error'],
timeoutMs: options.timeoutMs,
statusHistory
})
if (available.status === 'error') throw new Error(available.error || 'Update check failed')
if (available.availableVersion !== options.expectedVersion) {
throw new Error(
`Available version is ${available.availableVersion || 'unknown'}, expected ${options.expectedVersion}`
)
}
await firstLaunch.page.screenshot({
path: path.join(reportDirectory, '02-update-available.png'),
fullPage: true
})
const updateButton = firstLaunch.page.getByTestId('workbench-app-update-button')
if (await updateButton.isVisible().catch(() => false)) await updateButton.click()
else await firstLaunch.page.evaluate(() => window.api.appUpdateDownload())
const downloaded = await waitForStatus({
page: firstLaunch.page,
statuses: ['downloaded', 'error'],
timeoutMs: options.timeoutMs,
statusHistory
})
if (downloaded.status === 'error') throw new Error(downloaded.error || 'Update download failed')
await firstLaunch.page.screenshot({
path: path.join(reportDirectory, '03-update-downloaded.png'),
fullPage: true
})
const processExit = new Promise((resolve) => {
firstLaunch.electronApp.process()?.once('exit', (code, signal) => resolve({ code, signal }))
})
if (await updateButton.isVisible().catch(() => false)) await updateButton.click()
else await firstLaunch.page.evaluate(() => window.api.appUpdateInstall()).catch(() => undefined)
await Promise.race([processExit, delay(60_000)])
await waitForUpdaterInstallerExit(10 * 60 * 1000)
const finalInstalledVersion = await waitForInstalledVersion(
installDirectory,
options.expectedVersion,
5 * 60 * 1000
)
if (finalInstalledVersion !== options.expectedVersion) {
throw new Error(
`Installed version remained ${finalInstalledVersion || 'unreadable'} after update; expected ${options.expectedVersion}. This matches the "Failed to uninstall old application files" failure mode when the old installation files are still locked.`
)
}
await stopInstalledProcesses(installDirectory)
finalLaunch = await launchApp({
executablePath,
userDataPath,
port: await allocatePort(),
logs
})
const finalSnapshot = await finalLaunch.page.evaluate(() => window.api.appUpdateGetSnapshot())
recordSnapshot(finalSnapshot, statusHistory)
const markerPreserved = await pathExists(markerPath)
await finalLaunch.page.screenshot({
path: path.join(reportDirectory, '04-updated-app.png'),
fullPage: true
})
let downgradeCheck = { checked: false, rejected: false }
if (options.downgradeInstaller) {
await finalLaunch.electronApp.close()
finalLaunch = undefined
downgradeCheck = await verifyGuardedDowngradeIsRejected({
installerPath: options.downgradeInstaller,
installDirectory,
expectedVersion: options.expectedVersion,
markerPath,
logs
})
}
report = {
success:
finalSnapshot.currentVersion === options.expectedVersion &&
markerPreserved &&
(!options.downgradeInstaller || downgradeCheck.rejected),
startedAt,
finishedAt: new Date().toISOString(),
platform: process.platform,
arch: process.arch,
sourceVersion: options.sourceVersion,
expectedVersion: options.expectedVersion,
finalInstalledVersion,
finalReportedVersion: finalSnapshot.currentVersion,
markerPreserved,
downgradeInstaller: options.downgradeInstaller || null,
downgradeCheck,
statusHistory,
sourceInstaller: options.sourceInstaller,
targetInstaller: options.targetInstaller,
updateUrl,
installDirectory
}
if (!report.success) process.exitCode = 1
} catch (error) {
report = {
success: false,
startedAt,
finishedAt: new Date().toISOString(),
platform: process.platform,
arch: process.arch,
sourceVersion: options.sourceVersion,
expectedVersion: options.expectedVersion,
statusHistory,
sourceInstaller: options.sourceInstaller,
targetInstaller: options.targetInstaller,
updateUrl,
installDirectory,
error: error instanceof Error ? error.stack || error.message : String(error)
}
process.exitCode = 1
} finally {
await feed?.close().catch(() => undefined)
await firstLaunch?.electronApp.close().catch(() => undefined)
await finalLaunch?.electronApp.close().catch(() => undefined)
if (testInstallationOwned) await uninstallTestApp(installDirectory, logs)
if (!options.reuseUpdateCache) {
await rm(updaterCacheDirectory, { recursive: true, force: true })
if (updaterCacheBackedUp) {
await rename(updaterCacheBackupDirectory, updaterCacheDirectory)
}
}
await writeFile(path.join(reportDirectory, 'process.log'), `${logs.join('\n')}\n`, 'utf8')
await writeFile(
path.join(reportDirectory, 'report.json'),
`${JSON.stringify(report, null, 2)}\n`,
'utf8'
)
if (!options.keepWork) await rm(runtimeDirectory, { recursive: true, force: true })
process.stdout.write(`[update-real-win] Report: ${path.join(reportDirectory, 'report.json')}\n`)
}
}
await main()