Files
ycode/scripts/update-backend-regression.ts

828 lines
33 KiB
TypeScript

import assert from 'node:assert/strict'
import { createHash, generateKeyPairSync } from 'node:crypto'
import { createServer, type ServerResponse } from 'node:http'
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { basename, dirname, join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { signPayload as releaseSignPayload } from './updates/lib.mjs'
import { FileUpdatePersistence } from '../src/main/updates/storage'
import { compareVersions, parseVersion } from '../src/main/updates/semver'
import { normalizeManifestUrl, verifySignedManifest } from '../src/main/updates/manifest'
import { isTrustedUpdateRendererUrl } from '../src/main/updates/trust'
import { createNsisInstaller, NSIS_UNINSTALL_KEY } from '../src/main/updates/installer'
import { sanitizeLogText } from '../src/main/updates/logger'
import { UpdateManager } from '../src/main/updates/manager'
import {
UpdateError,
type CachedInstaller,
type FetchLike,
type InstallEnvironment,
type InstallerAdapter,
type SignedUpdateManifest,
type UpdateManifestPayload
} from '../src/main/updates/types'
const tests: Array<[string, () => Promise<void> | void]> = []
const test = (name: string, run: () => Promise<void> | void): void => {
tests.push([name, run])
}
const sleep = (milliseconds: number): Promise<void> => new Promise(resolve => setTimeout(resolve, milliseconds))
async function waitFor(predicate: () => boolean | Promise<boolean>, timeout = 5_000): Promise<void> {
const deadline = Date.now() + timeout
while (!await predicate()) {
if (Date.now() >= deadline) throw new Error('Timed out waiting for test condition')
await sleep(10)
}
}
function sha256(bytes: Buffer): string {
return createHash('sha256').update(bytes).digest('hex')
}
function makeBytes(size: number, fill: number): Buffer {
const bytes = Buffer.allocUnsafe(size)
for (let index = 0; index < size; index += 1) bytes[index] = (fill + index) & 0xff
return bytes
}
function expectUpdateError(run: () => unknown, code: string): void {
assert.throws(run, (error: unknown) => error instanceof UpdateError && error.code === code)
}
const { privateKey, publicKey } = generateKeyPairSync('ed25519')
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()
const publicPem = publicKey.export({ type: 'spki', format: 'pem' }).toString()
function manifestFor(
origin: string,
version: string,
bytes: Buffer,
notes = { zh: `更新 ${version}`, en: `Release ${version}` },
filename = `YCode-${version}-Setup.exe`
): SignedUpdateManifest {
const payload: UpdateManifestPayload = {
schemaVersion: 1,
version,
publishedAt: '2026-10-20T09:00:00.000Z',
notes,
platforms: {
'windows-x64': {
url: `${origin}/releases/versions/${version}/${filename}`,
size: bytes.byteLength,
sha256: sha256(bytes)
}
}
}
return {
payload,
signature: releaseSignPayload(payload, privatePem)
}
}
interface HttpFixture {
latest: SignedUpdateManifest | null
files: Map<string, Buffer>
requests: string[]
downloadRanges: Array<string | null>
redirect: 'none' | 'same-origin' | 'cross-origin'
crossOrigin: string
oversizeManifest: boolean
interruptNextDownload: boolean
chunkSize: number
chunkDelayMs: number
downloadStarted: number
origin: string
close: () => Promise<void>
}
async function sendBody(response: ServerResponse, body: Buffer, fixture: HttpFixture): Promise<void> {
for (let offset = 0; offset < body.byteLength; offset += fixture.chunkSize) {
if (response.destroyed) return
const chunk = body.subarray(offset, Math.min(body.byteLength, offset + fixture.chunkSize))
if (!response.write(chunk)) await new Promise<void>(resolve => response.once('drain', resolve))
if (fixture.chunkDelayMs > 0) await sleep(fixture.chunkDelayMs)
}
if (!response.destroyed) response.end()
}
async function createHttpFixture(): Promise<HttpFixture> {
let server: ReturnType<typeof createServer>
const fixture: HttpFixture = {
latest: null,
files: new Map(),
requests: [],
downloadRanges: [],
redirect: 'none',
crossOrigin: '',
oversizeManifest: false,
interruptNextDownload: false,
chunkSize: 64 * 1024,
chunkDelayMs: 0,
downloadStarted: 0,
origin: '',
close: async () => {
server.closeAllConnections()
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
}
}
server = createServer((request, response) => {
response.on('error', () => undefined)
const url = new URL(request.url ?? '/', fixture.origin)
fixture.requests.push(url.pathname)
if (url.pathname === '/releases/latest.json') {
if (fixture.oversizeManifest) {
const length = 1024 * 1024 + 1
response.writeHead(200, { 'content-type': 'application/json', 'content-length': String(length) })
response.end(Buffer.alloc(length, 0x20))
return
}
if (fixture.redirect === 'same-origin' && !url.searchParams.has('redirected')) {
response.writeHead(302, { location: '/releases/latest.json?redirected=1' })
response.end()
return
}
if (fixture.redirect === 'cross-origin') {
response.writeHead(302, { location: `${fixture.crossOrigin}/releases/latest.json` })
response.end()
return
}
const body = Buffer.from(JSON.stringify(fixture.latest))
response.writeHead(200, { 'content-type': 'application/json', 'content-length': String(body.byteLength) })
response.end(body)
return
}
const file = fixture.files.get(url.pathname)
if (!file) {
response.writeHead(404)
response.end()
return
}
const rangeMatch = /^bytes=(\d+)-$/.exec(request.headers.range ?? '')
const range = rangeMatch ? Number(rangeMatch[1]) : null
fixture.downloadRanges.push(rangeMatch?.[0] ?? null)
fixture.downloadStarted += 1
if (range !== null && range >= file.byteLength) {
response.writeHead(416, { 'content-range': `bytes */${file.byteLength}` })
response.end()
return
}
const start = range ?? 0
if (fixture.interruptNextDownload && start === 0) {
fixture.interruptNextDownload = false
const partial = file.subarray(0, Math.max(1, Math.floor(file.byteLength / 2)))
response.writeHead(200, {
'content-type': 'application/octet-stream',
'content-length': String(file.byteLength)
})
response.write(partial)
setTimeout(() => response.destroy(new Error('simulated interruption')), 20)
return
}
response.writeHead(start > 0 ? 206 : 200, {
'content-type': 'application/octet-stream',
'content-length': String(file.byteLength - start),
...(start > 0 ? { 'content-range': `bytes ${start}-${file.byteLength - 1}/${file.byteLength}` } : {})
})
void sendBody(response, file.subarray(start), fixture).catch(() => undefined)
})
await new Promise<void>((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('Fake HTTP server did not bind')
fixture.origin = `http://127.0.0.1:${address.port}`
return fixture
}
class FakeInstaller implements InstallerAdapter {
validateCount = 0
verifyCount = 0
launchCount = 0
launchError: Error | null = null
onLaunch: (() => Promise<void>) | null = null
async validateEnvironment(_requiredBytes?: number): Promise<InstallEnvironment> {
this.validateCount += 1
return {
installationDirectory: 'C:\\Users\\Test\\AppData\\Local\\Programs\\YCode',
executablePath: 'C:\\Users\\Test\\AppData\\Local\\Programs\\YCode\\YCode.exe',
registryKey: `HKCU\\${NSIS_UNINSTALL_KEY}`
}
}
async verifyInstaller(_installer: CachedInstaller): Promise<void> {
this.verifyCount += 1
}
async launch(_installer: CachedInstaller, _environment: InstallEnvironment): Promise<void> {
this.launchCount += 1
if (this.onLaunch) await this.onLaunch()
if (this.launchError) throw this.launchError
}
}
interface ManagerFixture {
manager: UpdateManager
installer: FakeInstaller
persistence: FileUpdatePersistence
cacheDirectory: string
controls: {
beforeResult: boolean
beforeGate: Promise<boolean> | null
beforeCalls: number
failedCalls: number
quitCalls: number
quitError: Error | null
}
}
let managerSequence = 0
function createManagerFixture(
root: string,
origin: string,
options: { autoDownload?: boolean; currentVersion?: string; identity?: string } = {}
): ManagerFixture {
managerSequence += 1
const directory = join(root, `manager-${options.identity ?? managerSequence}`)
const installer = new FakeInstaller()
const persistence = new FileUpdatePersistence(join(directory, 'state.json'))
const controls: ManagerFixture['controls'] = {
beforeResult: true,
beforeGate: null,
beforeCalls: 0,
failedCalls: 0,
quitCalls: 0,
quitError: null
}
const manager = new UpdateManager({
currentVersion: options.currentVersion ?? '0.0.1',
manifestUrl: `${origin}/releases/latest.json`,
cacheDirectory: join(directory, 'cache'),
logFile: join(directory, 'updater.log'),
persistence,
publicKey: async () => publicPem,
supported: true,
installer,
beforeInstall: async () => {
controls.beforeCalls += 1
if (controls.beforeGate) return controls.beforeGate
return controls.beforeResult
},
quitForUpdate: async () => {
controls.quitCalls += 1
if (controls.quitError) throw controls.quitError
},
installFailed: async () => {
controls.failedCalls += 1
},
autoDownload: options.autoDownload ?? false,
checkDelayMs: 5,
checkIntervalMs: 60_000,
upToDateResetMs: 30,
manifestTimeoutMs: 2_000,
downloadTimeoutMs: 10_000
})
return { manager, installer, persistence, cacheDirectory: join(directory, 'cache'), controls }
}
test('strict SemVer parser and comparator', () => {
assert.deepEqual(parseVersion('1.2.3-alpha.1+build.7').prerelease, ['alpha', '1'])
assert.equal(compareVersions('1.2.3+build.1', '1.2.3+build.2'), 0)
assert.equal(compareVersions('1.2.3-alpha.2', '1.2.3-alpha.10'), -1)
assert.equal(compareVersions('1.2.3', '1.2.3-rc.1'), 1)
expectUpdateError(() => parseVersion('01.2.3'), 'invalid-version')
expectUpdateError(() => parseVersion('1.2.3-01'), 'invalid-version')
})
test('manifest signature, exact metadata and URL policy', async () => {
const origin = 'http://127.0.0.1:65530'
const bytes = makeBytes(1024, 5)
const manifest = manifestFor(origin, '1.0.1', bytes)
const verified = verifySignedManifest(Buffer.from(JSON.stringify(manifest)), {
manifestUrl: `${origin}/releases/latest.json`,
publicKeyPem: publicPem
})
assert.equal(verified.payload.version, '1.0.1')
const tampered = structuredClone(manifest)
tampered.payload.platforms['windows-x64'].sha256 = '0'.repeat(64)
expectUpdateError(
() => verifySignedManifest(Buffer.from(JSON.stringify(tampered)), {
manifestUrl: `${origin}/releases/latest.json`,
publicKeyPem: publicPem
}),
'invalid-signature'
)
const invalidVersionPayload = structuredClone(manifest.payload) as unknown as Record<string, unknown>
invalidVersionPayload.version = '01.0.0'
const invalidVersionDocument = {
payload: invalidVersionPayload,
signature: releaseSignPayload(invalidVersionPayload, privatePem)
}
expectUpdateError(
() => verifySignedManifest(Buffer.from(JSON.stringify(invalidVersionDocument)), {
manifestUrl: `${origin}/releases/latest.json`,
publicKeyPem: publicPem
}),
'invalid-version'
)
const outsidePath = structuredClone(manifest.payload)
outsidePath.platforms['windows-x64'].url = `${origin}/releases/other/YCode.exe`
const outsidePathDocument = {
payload: outsidePath,
signature: releaseSignPayload(outsidePath, privatePem)
}
expectUpdateError(
() => verifySignedManifest(Buffer.from(JSON.stringify(outsidePathDocument)), {
manifestUrl: `${origin}/releases/latest.json`,
publicKeyPem: publicPem
}),
'invalid-manifest'
)
assert.equal(normalizeManifestUrl(`${origin}/releases/latest.json`), `${origin}/releases/latest.json`)
})
test('trusted renderer URL gate rejects task and foreign frames', () => {
const appPath = 'C:\\Program Files\\YCode\\resources\\app.asar'
const mainFile = pathToFileURL('C:\\Program Files\\YCode\\resources\\app.asar\\out\\renderer\\index.html').href
const settingsFile = pathToFileURL('C:\\Program Files\\YCode\\resources\\app.asar\\out\\renderer\\settings.html').href
assert.equal(isTrustedUpdateRendererUrl(mainFile, { appPath, platform: 'win32' }), true)
assert.equal(isTrustedUpdateRendererUrl(settingsFile, { appPath, platform: 'win32' }), true)
assert.equal(isTrustedUpdateRendererUrl(`${mainFile}?window=task`, { appPath, platform: 'win32' }), false)
assert.equal(isTrustedUpdateRendererUrl('file:///C:/Windows/System32/drivers/etc/hosts', { appPath, platform: 'win32' }), false)
assert.equal(isTrustedUpdateRendererUrl('http://localhost:5173/index.html?window=task', {
appPath,
rendererUrl: 'http://localhost:5173'
}), false)
assert.equal(isTrustedUpdateRendererUrl('http://localhost:5173/settings.html', {
appPath,
rendererUrl: 'http://localhost:5173'
}), true)
assert.equal(isTrustedUpdateRendererUrl('http://127.0.0.1:5173/settings.html', {
appPath,
rendererUrl: 'http://localhost:5173'
}), false)
})
test('installer adapter uses spawn, current key and NSIS arguments', async () => {
assert.equal(NSIS_UNINSTALL_KEY.includes('8a4ee941-d893-569b-8bcb-2a55d270c04b'), true)
const root = 'C:\\Users\\Test\\AppData\\Local\\YCode Custom\\Install Dir'
const executable = `${root}\\YCode.exe`
const cacheDirectory = join(await mkdtemp(join(tmpdir(), 'ycode-update-adapter-')), 'cache')
const calls: Array<{ command: string; args: readonly string[]; detached: boolean; stdio: string }> = []
const child = {
once(event: 'spawn' | 'error', listener: (() => void) | ((error: Error) => void)) {
if (event === 'spawn') queueMicrotask(() => (listener as () => void)())
return child
},
unref() {}
}
const baseOptions = {
platform: 'win32',
isPackaged: true,
execPath: executable,
appPath: `${root}\\resources\\app.asar`,
cacheDirectory,
processId: 4321,
resolveRealPath: async value => value,
readStat: async () => ({ isFile: () => true }),
queryRegistryValue: async name => {
if (name === 'UninstallString') return `"${root}\\Uninstall YCode.exe" /currentuser`
if (name === 'DisplayIcon') return `${executable},0`
return null
},
verifyFile: async () => undefined,
spawn: (command, args, options) => {
calls.push({ command, args, detached: options.detached, stdio: options.stdio })
return child
}
} as const
const adapter = createNsisInstaller({
...baseOptions,
readDiskStats: async () => ({ bsize: 4096, bavail: 1_000_000 })
})
const environment = await adapter.validateEnvironment(1024)
assert.equal(environment.installationDirectory, root)
const cached: CachedInstaller = {
version: '1.0.2',
path: join(cacheDirectory, 'YCode-1.0.2-Setup.exe'),
partPath: join(cacheDirectory, 'YCode-1.0.2-Setup.exe.part'),
size: 10,
sha256: 'a'.repeat(64)
}
await adapter.launch(cached, environment)
assert.equal(calls.length, 1)
assert.equal(calls[0].detached, true)
assert.equal(calls[0].stdio, 'ignore')
assert.deepEqual([...calls[0].args], [
'/S',
'--updated',
'--force-run',
'/currentuser',
'/YCODEWAITPID=4321',
`/D=${root}`
])
assert.equal(calls[0].args.at(-1), `/D=${root}`)
const lowSpace = createNsisInstaller({
...baseOptions,
readDiskStats: async () => ({ bsize: 1, bavail: 1 })
})
await assert.rejects(
() => lowSpace.validateEnvironment(1024),
(error: unknown) => error instanceof UpdateError && error.code === 'installer-space-check'
)
const directoryMarker = createNsisInstaller({
...baseOptions,
readStat: async value => ({ isFile: () => !value.endsWith('app.asar') })
})
await assert.rejects(
() => directoryMarker.validateEnvironment(0),
(error: unknown) => error instanceof UpdateError
&& error.code === 'installer-marker'
&& /app\.asar/.test(error.message)
)
})
test('HTTP manifest limits, same-origin redirects and cross-origin rejection', async () => {
const first = await createHttpFixture()
const second = await createHttpFixture()
const root = await mkdtemp(join(tmpdir(), 'ycode-update-http-'))
try {
const bytes = makeBytes(256 * 1024, 11)
first.latest = manifestFor(first.origin, '1.0.1', bytes)
first.files.set('/releases/versions/1.0.1/YCode-1.0.1-Setup.exe', bytes)
first.redirect = 'same-origin'
const redirected = createManagerFixture(root, first.origin, { identity: 'redirect' })
const redirectedState = await redirected.manager.check()
assert.equal(redirectedState.status, 'available')
first.redirect = 'cross-origin'
first.crossOrigin = second.origin
second.latest = first.latest
const cross = createManagerFixture(root, first.origin, { identity: 'cross' })
const crossState = await cross.manager.check()
assert.equal(crossState.status, 'error')
assert.match(crossState.error ?? '', /跨源/)
first.redirect = 'none'
first.oversizeManifest = true
const oversized = createManagerFixture(root, first.origin, { identity: 'oversize' })
const oversizedState = await oversized.manager.check()
assert.equal(oversizedState.status, 'error')
assert.match(oversizedState.error ?? '', /大小限制/)
} finally {
await first.close()
await second.close()
await rm(root, { recursive: true, force: true })
}
})
test('automatic download, dismissal and up-to-date idle reset', async () => {
const http = await createHttpFixture()
const root = await mkdtemp(join(tmpdir(), 'ycode-update-auto-'))
try {
const bytes = makeBytes(192 * 1024, 17)
http.latest = manifestFor(http.origin, '1.0.1', bytes)
http.files.set('/releases/versions/1.0.1/YCode-1.0.1-Setup.exe', bytes)
const auto = createManagerFixture(root, http.origin, { autoDownload: true, identity: 'auto' })
const checked = await auto.manager.checkAndDownload(false)
assert.equal(checked.status, 'downloading')
assert.equal(checked.version, '1.0.1')
assert.equal(checked.lastExplicit, false)
await waitFor(async () => (await auto.manager.getState()).status === 'ready')
assert.equal((await auto.manager.getState()).progress, 100)
assert.equal((await auto.manager.getState()).lastExplicit, false)
http.oversizeManifest = true
const retainedReady = await auto.manager.check(false)
assert.equal(retainedReady.status, 'ready')
assert.equal(retainedReady.lastExplicit, false)
http.oversizeManifest = false
const dismissed = await auto.manager.dismiss()
assert.equal(dismissed.dismissedVersion, '1.0.1')
assert.equal(dismissed.lastExplicit, true)
const persisted = await auto.persistence.load()
assert.equal(persisted.dismissedVersion, '1.0.1')
const second = createManagerFixture(root, http.origin, { autoDownload: true, identity: 'dismissed' })
await second.persistence.save({ schemaVersion: 1, dismissedVersion: '1.0.1' })
const before = http.requests.length
const dismissedCheck = await second.manager.checkAndDownload()
await sleep(80)
assert.equal(dismissedCheck.dismissedVersion, '1.0.1')
assert.equal(dismissedCheck.lastExplicit, true)
assert.equal(http.requests.length - before, 1)
http.latest = manifestFor(http.origin, '0.0.1', bytes)
const current = createManagerFixture(root, http.origin, { autoDownload: true, identity: 'current' })
const upToDate = await current.manager.check()
assert.equal(upToDate.status, 'up-to-date')
await sleep(60)
assert.equal((await current.manager.getState()).status, 'idle')
http.latest = manifestFor(http.origin, '1.0.1', bytes)
const explicit = createManagerFixture(root, http.origin, { identity: 'explicit-reset' })
assert.equal((await explicit.manager.check(true)).lastExplicit, true)
http.oversizeManifest = true
const backgroundFailure = await explicit.manager.check(false)
assert.equal(backgroundFailure.status, 'error')
assert.equal(backgroundFailure.lastExplicit, false)
http.oversizeManifest = false
} finally {
await http.close()
await rm(root, { recursive: true, force: true })
}
})
test('download interruption resumes with valid Range and fixed cache filename', async () => {
const http = await createHttpFixture()
const root = await mkdtemp(join(tmpdir(), 'ycode-update-resume-'))
try {
const bytes = makeBytes(2 * 1024 * 1024, 23)
http.latest = manifestFor(http.origin, '1.0.1', bytes)
http.files.set('/releases/versions/1.0.1/YCode-1.0.1-Setup.exe', bytes)
const fixture = createManagerFixture(root, http.origin, { identity: 'resume' })
assert.equal((await fixture.manager.check()).status, 'available')
http.interruptNextDownload = true
const interrupted = await fixture.manager.download()
assert.equal(interrupted.status, 'error')
const partInfo = await stat(join(fixture.cacheDirectory, 'YCode-1.0.1-Setup.exe.part'))
assert.ok(partInfo.size > 0)
const resumed = await fixture.manager.download()
assert.equal(resumed.status, 'ready')
assert.ok(http.downloadRanges.some(range => range?.startsWith('bytes=')))
const cached = await readFile(join(fixture.cacheDirectory, 'YCode-1.0.1-Setup.exe'))
assert.equal(sha256(cached), sha256(bytes))
} finally {
await http.close()
await rm(root, { recursive: true, force: true })
}
})
test('concurrent download is single-flight and release switch aborts stale bytes', async () => {
const http = await createHttpFixture()
const root = await mkdtemp(join(tmpdir(), 'ycode-update-race-'))
try {
const firstBytes = makeBytes(4 * 1024 * 1024, 31)
const secondBytes = makeBytes(384 * 1024, 47)
http.latest = manifestFor(http.origin, '1.0.1', firstBytes)
http.files.set('/releases/versions/1.0.1/YCode-1.0.1-Setup.exe', firstBytes)
http.files.set('/releases/versions/1.0.2/YCode-1.0.2-Setup.exe', secondBytes)
http.chunkSize = 32 * 1024
http.chunkDelayMs = 15
const fixture = createManagerFixture(root, http.origin, { identity: 'race' })
assert.equal((await fixture.manager.check()).status, 'available')
const staleDownload = fixture.manager.download()
await waitFor(() => http.downloadStarted > 0)
http.latest = manifestFor(http.origin, '1.0.2', secondBytes)
const switched = await fixture.manager.check()
assert.equal(switched.status, 'available')
assert.equal(switched.version, '1.0.2')
const staleResult = await staleDownload
assert.notEqual(staleResult.status, 'ready')
assert.equal((await fixture.manager.getState()).version, '1.0.2')
http.chunkDelayMs = 0
const rangeCount = http.downloadRanges.length
const [one, two] = await Promise.all([fixture.manager.download(), fixture.manager.download()])
assert.equal(one.status, 'ready')
assert.equal(two.status, 'ready')
assert.equal(http.downloadRanges.length - rangeCount, 1)
const cached = await readFile(join(fixture.cacheDirectory, 'YCode-1.0.2-Setup.exe'))
assert.equal(sha256(cached), sha256(secondBytes))
} finally {
await http.close()
await rm(root, { recursive: true, force: true })
}
})
test('install cancellation, launch failure marker cleanup, and success ordering', async () => {
const http = await createHttpFixture()
const root = await mkdtemp(join(tmpdir(), 'ycode-update-install-'))
try {
const bytes = makeBytes(256 * 1024, 61)
http.latest = manifestFor(http.origin, '1.0.2', bytes)
http.files.set('/releases/versions/1.0.2/YCode-1.0.2-Setup.exe', bytes)
const fixture = createManagerFixture(root, http.origin, { identity: 'install' })
assert.equal((await fixture.manager.check()).status, 'available')
fixture.controls.beforeResult = false
const cancelled = await fixture.manager.install()
assert.equal(cancelled.status, 'ready')
assert.equal(fixture.installer.launchCount, 0)
assert.equal(fixture.controls.failedCalls, 0)
assert.equal((await fixture.manager.download()).status, 'ready')
fixture.controls.beforeResult = true
fixture.installer.launchError = new Error('spawn failed')
const failed = await fixture.manager.install()
assert.equal(failed.status, 'error')
assert.equal(fixture.controls.failedCalls, 1)
const cleared = await fixture.persistence.load()
assert.equal(cleared.unfinishedVersion, undefined)
assert.equal(cleared.target, undefined)
assert.equal((await fixture.manager.check()).status, 'ready')
fixture.installer.launchError = null
let markerAtLaunch: string | undefined
fixture.installer.onLaunch = async () => {
markerAtLaunch = (await fixture.persistence.load()).unfinishedVersion
}
const installed = await fixture.manager.install()
assert.equal(installed.status, 'installing')
assert.equal(markerAtLaunch, '1.0.2')
assert.equal(fixture.installer.validateCount >= 2, true)
assert.equal(fixture.installer.verifyCount >= 2, true)
assert.equal(fixture.installer.launchCount, 2)
assert.equal(fixture.controls.quitCalls, 1)
const retained = await fixture.persistence.load()
assert.equal(retained.unfinishedVersion, '1.0.2')
assert.equal(retained.target?.sha256, sha256(bytes))
const afterCommittedDismiss = await fixture.manager.dismiss()
assert.equal(afterCommittedDismiss.status, 'installing')
assert.equal(afterCommittedDismiss.dismissedVersion, undefined)
const afterCommittedInstall = await fixture.manager.install()
assert.equal(afterCommittedInstall.status, 'installing')
assert.equal(fixture.installer.launchCount, 2)
assert.equal(fixture.controls.beforeCalls, 3)
} finally {
await http.close()
await rm(root, { recursive: true, force: true })
}
})
test('confirmation pending blocks dismiss and repeated install', async () => {
const http = await createHttpFixture()
const root = await mkdtemp(join(tmpdir(), 'ycode-update-confirm-lock-'))
try {
const bytes = makeBytes(128 * 1024, 67)
http.latest = manifestFor(http.origin, '1.0.2', bytes)
http.files.set('/releases/versions/1.0.2/YCode-1.0.2-Setup.exe', bytes)
const fixture = createManagerFixture(root, http.origin, { identity: 'confirm-lock' })
assert.equal((await fixture.manager.check()).status, 'available')
assert.equal((await fixture.manager.download()).status, 'ready')
let releaseConfirmation!: (accepted: boolean) => void
fixture.controls.beforeGate = new Promise(resolve => {
releaseConfirmation = resolve
})
const firstInstall = fixture.manager.install()
await waitFor(() => fixture.controls.beforeCalls === 1)
const dismissed = await fixture.manager.dismiss()
assert.equal(dismissed.status, 'ready')
assert.equal(dismissed.dismissedVersion, undefined)
const repeatedInstall = fixture.manager.install()
assert.equal(fixture.installer.launchCount, 0)
assert.equal(fixture.controls.beforeCalls, 1)
releaseConfirmation(false)
const [firstState, repeatedState] = await Promise.all([firstInstall, repeatedInstall])
assert.equal(firstState.status, 'ready')
assert.equal(repeatedState.status, 'ready')
assert.equal(fixture.installer.launchCount, 0)
assert.equal(fixture.controls.failedCalls, 0)
assert.equal((await fixture.persistence.load()).dismissedVersion, undefined)
} finally {
await http.close()
await rm(root, { recursive: true, force: true })
}
})
test('committed installer blocks recovery after quit failure', async () => {
const http = await createHttpFixture()
const root = await mkdtemp(join(tmpdir(), 'ycode-update-committed-'))
try {
const bytes = makeBytes(128 * 1024, 73)
http.latest = manifestFor(http.origin, '1.0.2', bytes)
http.files.set('/releases/versions/1.0.2/YCode-1.0.2-Setup.exe', bytes)
const fixture = createManagerFixture(root, http.origin, { identity: 'committed' })
assert.equal((await fixture.manager.check()).status, 'available')
assert.equal((await fixture.manager.download()).status, 'ready')
fixture.controls.quitError = new Error('simulated quit failure')
const committed = await fixture.manager.install()
assert.equal(committed.status, 'installing')
assert.match(committed.error ?? '', /必须退出 YCode/)
assert.equal(fixture.installer.launchCount, 1)
assert.equal(fixture.controls.beforeCalls, 1)
assert.equal(fixture.controls.quitCalls, 1)
assert.equal(fixture.controls.failedCalls, 0)
const retained = await fixture.persistence.load()
assert.equal(retained.unfinishedVersion, '1.0.2')
assert.equal(retained.target?.version, '1.0.2')
const requestCount = http.requests.length
const dismissed = await fixture.manager.dismiss()
const checked = await fixture.manager.check()
const downloaded = await fixture.manager.download()
const checkedAndDownloaded = await fixture.manager.checkAndDownload()
const repeated = await fixture.manager.install()
for (const state of [dismissed, checked, downloaded, checkedAndDownloaded, repeated]) {
assert.equal(state.status, 'installing')
assert.match(state.error ?? '', /必须退出 YCode/)
}
assert.equal(http.requests.length, requestCount)
assert.equal(fixture.installer.launchCount, 1)
assert.equal(fixture.controls.beforeCalls, 1)
assert.equal(fixture.controls.quitCalls, 1)
assert.equal(fixture.controls.failedCalls, 0)
assert.equal((await fixture.persistence.load()).unfinishedVersion, '1.0.2')
} finally {
await http.close()
await rm(root, { recursive: true, force: true })
}
})
test('corrupt cache is revalidated and rejected', async () => {
const http = await createHttpFixture()
const root = await mkdtemp(join(tmpdir(), 'ycode-update-cache-'))
try {
const bytes = makeBytes(128 * 1024, 71)
http.latest = manifestFor(http.origin, '1.0.1', bytes)
http.files.set('/releases/versions/1.0.1/YCode-1.0.1-Setup.exe', bytes)
const fixture = createManagerFixture(root, http.origin, { identity: 'cache' })
assert.equal((await fixture.manager.check()).status, 'available')
assert.equal((await fixture.manager.download()).status, 'ready')
await writeFile(join(fixture.cacheDirectory, 'YCode-1.0.1-Setup.exe'), Buffer.from('tampered'))
const checked = await fixture.manager.check()
assert.equal(checked.status, 'available')
const retried = await fixture.manager.download()
assert.equal(retried.status, 'ready')
} finally {
await http.close()
await rm(root, { recursive: true, force: true })
}
})
test('unfinished marker clears after target version is installed', async () => {
const root = await mkdtemp(join(tmpdir(), 'ycode-update-marker-'))
try {
const persistence = new FileUpdatePersistence(join(root, 'state.json'))
await persistence.save({
schemaVersion: 1,
unfinishedVersion: '1.0.0',
target: {
version: '1.0.0',
size: 10,
sha256: 'a'.repeat(64),
manifestUrl: 'http://127.0.0.1/releases/latest.json'
}
})
const manager = new UpdateManager({
currentVersion: '1.0.0',
manifestUrl: 'http://127.0.0.1/releases/latest.json',
cacheDirectory: join(root, 'cache'),
logFile: join(root, 'updater.log'),
persistence,
publicKey: async () => publicPem,
supported: true,
installer: new FakeInstaller(),
beforeInstall: async () => false,
quitForUpdate: async () => undefined
})
const state = await manager.getState()
assert.equal(state.unfinishedVersion, undefined)
const persisted = await persistence.load()
assert.equal(persisted.target, undefined)
} finally {
await rm(root, { recursive: true, force: true })
}
})
test('log redaction removes URL credentials and query secrets', () => {
const sanitized = sanitizeLogText('GET http://user:password@example.test/a?token=secret Authorization: abc123')
assert.equal(sanitized.includes('password'), false)
assert.equal(sanitized.includes('secret'), false)
assert.equal(sanitized.includes('abc123'), false)
})
async function main(): Promise<void> {
let failures = 0
for (const [name, run] of tests) {
try {
await run()
console.log(`ok - ${name}`)
} catch (error) {
failures += 1
console.error(`not ok - ${name}`)
console.error(error)
}
}
console.log(`\n${tests.length - failures}/${tests.length} updater backend tests passed`)
if (failures > 0) process.exitCode = 1
}
void main()