diff --git a/.github/workflows/build-docker-image.yml b/.github/workflows/build-docker-image.yml index 75941c8c..eb05f9c0 100644 --- a/.github/workflows/build-docker-image.yml +++ b/.github/workflows/build-docker-image.yml @@ -94,6 +94,12 @@ jobs: run: | bash scripts/publish-static.sh "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_REF_TYPE" + - name: publish verified upgrade download sizes + env: + GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static + GITHUB_BRANCH: ${{ github.ref_name }} + run: node scripts/publish-upgrade-info.cjs "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_SHA" + static_gitlab: needs: build-static runs-on: ubuntu-latest diff --git a/back/api/system.ts b/back/api/system.ts index ddb4af52..eea252a6 100644 --- a/back/api/system.ts +++ b/back/api/system.ts @@ -205,6 +205,17 @@ export default (app: Router) => { }, ); + route.get( + '/update-status', + async (req: Request, res: Response, next: NextFunction) => { + try { + res.send(await Container.get(SystemService).getUpgradeStatus()); + } catch (e) { + return next(e); + } + }, + ); + route.put( '/reload', celebrate({ diff --git a/back/services/health.ts b/back/services/health.ts index 3c86f5fa..48ac8ca7 100644 --- a/back/services/health.ts +++ b/back/services/health.ts @@ -2,8 +2,10 @@ import { Service } from 'typedi'; import Logger from '../loaders/logger'; import cronClient from '../schedule/client'; import { HttpServerService } from './http'; +import { runtimeBuild } from '../shared/runtimeBuild'; interface HealthStatus { + build: typeof runtimeBuild; status: 'ok' | 'error'; services: { http: boolean; @@ -22,12 +24,11 @@ interface HealthStatus { export class HealthService { private startTime = Date.now(); - constructor( - private httpServerService: HttpServerService, - ) {} + constructor(private httpServerService: HttpServerService) {} async check(): Promise { const status: HealthStatus = { + build: runtimeBuild, status: 'ok', services: { http: true, diff --git a/back/services/system.ts b/back/services/system.ts index 0cd1be79..07f7ca02 100644 --- a/back/services/system.ts +++ b/back/services/system.ts @@ -344,45 +344,51 @@ export default class SystemService { } public async updateSystem() { - const cp = spawn('real_time=true ql update false', { shell: '/bin/bash' }); + return this.startUpgradeJob('download'); + } - cp.stdout.on('data', (data) => { - this.sockService.sendMessage({ - type: 'updateSystemVersion', - message: data.toString(), - }); - }); + private upgradeJobs() { + return require(path.join( + config.rootPath, + 'cli/src/internal/maintenance/panelUpgrade.cjs', + )); + } - cp.stderr.on('data', (data) => { - this.sockService.sendMessage({ - type: 'updateSystemVersion', - message: data.toString(), - }); - }); + public async getUpgradeStatus() { + return { + code: 200, + data: await this.upgradeJobs().getStatus(config.tmpPath), + }; + } - cp.on('error', (err) => { - this.sockService.sendMessage({ - type: 'updateSystemVersion', - message: JSON.stringify(err), - status: 'failed', - }); - }); - - cp.on('exit', (code) => { - this.sockService.sendMessage({ - type: 'updateSystemVersion', - message: '', - status: code === 0 ? 'success' : 'failed', - }); - }); - - return { code: 200 }; + private async startUpgradeJob(action: 'download' | 'apply') { + try { + const data = await this.upgradeJobs().startJob( + { + root: config.rootPath, + tmp: config.tmpPath, + env: { + ...process.env, + dir_data: config.dataPath, + dir_config: config.configPath, + QlPort: String(config.port), + QlBaseUrl: config.baseUrl, + }, + }, + action, + ); + return { code: 200, data }; + } catch (error: any) { + this.logger.error('[upgrade] %s', error.message); + return { code: 400, message: t('升级任务启动失败,请检查日志后重试') }; + } } public async reloadSystem(target?: 'system' | 'data') { if (target && !['system', 'data'].includes(target)) { return { code: 400, message: t('参数错误') }; } + if (target === 'system') return this.startUpgradeJob('apply'); const cmd = `real_time=true ql reload ${shellQuote(target || '')}`; const cp = spawn(cmd, { shell: '/bin/bash', diff --git a/back/shared/i18n.ts b/back/shared/i18n.ts index abd2faf1..060bbfa1 100644 --- a/back/shared/i18n.ts +++ b/back/shared/i18n.ts @@ -1,6 +1,8 @@ const messages: Record> = { zh: {}, en: { + '升级任务启动失败,请检查日志后重试': + 'Failed to start the update. Check the logs and try again.', '[schedule][停止已运行任务] 任务ID: %s, PID: %s': '[schedule][Stopping running task] Task ID: %s, PID: %s', diff --git a/back/shared/runtimeBuild.ts b/back/shared/runtimeBuild.ts new file mode 100644 index 00000000..7814b13f --- /dev/null +++ b/back/shared/runtimeBuild.ts @@ -0,0 +1,41 @@ +import { readFileSync } from 'fs'; +import path from 'path'; +import { createHash, randomUUID } from 'crypto'; + +function read(filename: string): Buffer | undefined { + try { + return readFileSync(filename); + } catch { + return undefined; + } +} + +function manifest(): { sourceCommit?: string } { + try { + const value = JSON.parse( + read(path.resolve(__dirname, '../../build-info.json'))?.toString() || + '{}', + ); + return value && typeof value === 'object' ? value : {}; + } catch { + return {}; + } +} + +// Capture before serving requests. Reading the installed files on each request +// would let an old process claim the identity of a newly copied build. +const backend = read(path.resolve(__dirname, '../app.js')); +const root = process.env.QL_DIR || path.resolve(__dirname, '../../..'); +const version = read(path.join(root, 'version.yaml')) + ?.toString() + .match(/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+)/m)?.[1]; + +export const runtimeBuild = Object.freeze({ + instanceId: randomUUID(), + parentPid: process.ppid, + version, + sourceCommit: manifest().sourceCommit, + backendSha256: backend + ? createHash('sha256').update(backend).digest('hex') + : undefined, +}); diff --git a/cli/src/internal/maintenance/panelUpgrade.cjs b/cli/src/internal/maintenance/panelUpgrade.cjs new file mode 100644 index 00000000..72749b72 --- /dev/null +++ b/cli/src/internal/maintenance/panelUpgrade.cjs @@ -0,0 +1,471 @@ +// Persistent panel jobs run independently of HTTP requests and browser sessions. +const fs = require('node:fs/promises'); +const path = require('node:path'); +const { randomUUID } = require('node:crypto'); +const { spawn } = require('node:child_process'); +const http = require('node:http'); +const { setTimeout: delay } = require('node:timers/promises'); +const artifacts = require('./upgradeArtifacts.cjs'); + +const active = new Set(['starting', 'running', 'restarting']); +const stateFile = (tmp) => path.join(tmp, 'panel-upgrade.json'); +const logFile = (tmp) => path.join(tmp, 'panel-upgrade.log'); +const launches = new Map(); + +async function withPanelLifecycle(root, tmp, env, operation, signal) { + if (!env.QL_CLI_ROOT) + return artifacts.withLifecycleHooks(root, tmp, env, operation); + if (!path.isAbsolute(env.QL_CLI_ROOT)) + throw new Error('QL_CLI_ROOT must be absolute'); + const internal = path.join(env.QL_CLI_ROOT, 'dist/internal'); + // Keep the selected installation's operators loaded across file replacement. + const { createContext, sourceEnvironment } = require(path.join( + internal, + 'runtime/context.js', + )); + const { cancellableOperation } = require(path.join( + internal, + 'runtime/cancellation.js', + )); + const operator = require(path.join(internal, 'maintenance/operator.js')); + const { prepareUpgradeSelection } = require(path.join( + internal, + 'maintenance/upgradeSelection.js', + )); + const context = createContext( + { root, 'data-dir': env.dir_data || env.QL_DATA_DIR }, + env, + ); + context.env = await sourceEnvironment( + context.env, + [ + context.paths.file_config_user, + path.join(context.paths.dir_preload, 'lang_env.sh'), + ], + [], + { signal }, + ); + const hook = (name, args = [], signal) => + cancellableOperation(signal, () => { + if (name === 'install') + return operator.installPanelDependencies(context, args[0]); + if (name === 'stop') return operator.stopPanel(context); + if (name === 'start') return operator.startPanel(context); + throw new Error('Invalid lifecycle operation'); + }); + return operation(hook, (selected) => + prepareUpgradeSelection(context, selected.source, selected.static), + ); +} + +async function targetVersion(source) { + const text = await fs.readFile(path.join(source, 'version.yaml'), 'utf8'); + const version = text.match(/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+)/m)?.[1]; + if (!version) throw new Error('Invalid update version'); + return version; +} + +async function readRunningBuild(env) { + let base = env.QlBaseUrl || ''; + if (base && !base.startsWith('/')) base = `/${base}`; + base = base.replace(/\/$/, ''); + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => request.destroy(new Error('Version check timed out')), + 5000, + ); + const request = http.get( + { + hostname: '127.0.0.1', + port: Number(env.QlPort || 5700), + path: `${base}/api/health`, + }, + (response) => { + let body = ''; + response.on('data', (chunk) => { + body += chunk; + if (body.length > 65536) + request.destroy(new Error('Invalid version response')); + }); + response.on('error', reject); + response.on('end', () => { + try { + const result = JSON.parse(body); + if ( + response.statusCode !== 200 || + result.code !== 200 || + result.data?.status !== 'ok' + ) + throw new Error('后端启动检查失败。'); + resolve(result.data.build); + } catch (error) { + reject(error); + } + }); + }, + ); + request.on('error', reject); + request.on('close', () => clearTimeout(timer)); + }); +} + +async function verifyRunningVersion(env, expected, previousBuild) { + const build = await readRunningBuild(env); + if ( + !build || + !build.instanceId || + build.instanceId === previousBuild?.instanceId || + (previousBuild?.parentPid !== undefined && + build.parentPid === previousBuild.parentPid) || + build.version !== expected.version || + build.sourceCommit !== expected.sourceCommit || + build.backendSha256 !== expected.backendSha256 + ) + throw new Error('启动版本与更新包不一致'); +} + +async function stagedMetadata(selected) { + const manifest = JSON.parse( + await fs.readFile(path.join(selected.static, 'build-info.json'), 'utf8'), + ); + return { + version: await targetVersion(selected.source), + sourceCommit: manifest.sourceCommit, + backendSha256: manifest.files['build/app.js'], + stageDirectory: path.basename(path.dirname(selected.source)), + }; +} + +function assertSelected(expected, actual) { + for (const key of [ + 'sourceCommit', + 'backendSha256', + 'stageDirectory', + 'version', + ]) { + if (expected[key] !== actual[key]) + throw new Error('暂存更新已变化,请重新下载更新。'); + } +} + +async function save(tmp, state) { + const temporary = `${stateFile(tmp)}.${randomUUID()}`; + await fs.writeFile(temporary, JSON.stringify(state), { mode: 0o600 }); + await fs.rename(temporary, stateFile(tmp)); +} + +async function readState(tmp) { + try { + return JSON.parse(await fs.readFile(stateFile(tmp), 'utf8')); + } catch (error) { + if (error.code === 'ENOENT') return { status: 'idle' }; + throw error; + } +} + +async function getStatus(tmp) { + const state = await readState(tmp); + if (active.has(state.status)) { + let alive = false; + if (Number.isSafeInteger(state.pid) && state.pid > 0) { + try { + process.kill(state.pid, 0); + alive = true; + } catch (error) { + alive = error.code === 'EPERM'; + } + } else alive = Date.now() - state.startedAt < 10000; + if (!alive) { + state.status = 'failed'; + state.error = '升级任务意外中断,请重试'; + } + } + // Bound reads even when an installer produces a very large log. + const handle = await fs.open(logFile(tmp), 'r').catch(() => undefined); + if (handle) { + try { + const { size } = await handle.stat(); + const buffer = Buffer.alloc(Math.min(size, 32768)); + const { bytesRead } = await handle.read( + buffer, + 0, + buffer.length, + Math.max(0, size - buffer.length), + ); + state.log = buffer.subarray(0, bytesRead).toString(); + } finally { + await handle.close(); + } + } + return state; +} + +async function startJob({ root, tmp, env = process.env }, action) { + if (!['download', 'apply'].includes(action)) + throw new Error('Invalid upgrade action'); + await fs.mkdir(tmp, { recursive: true }); + const preceding = launches.get(tmp) || Promise.resolve(); + const launching = preceding + .catch(() => {}) + .then(() => + artifacts.withUpgradeLock( + path.join(tmp, 'panel-job-launch'), + async () => { + const previous = await getStatus(tmp); + if (active.has(previous.status)) { + if (previous.action !== action) + throw new Error('已有更新或重载正在执行。'); + return previous; + } + let selected; + let metadata; + if (action === 'apply') { + // Fail before launching a process or stopping the working panel. + try { + selected = await artifacts.selectedUpgrade( + tmp, + artifacts.releaseBranch(env), + env, + ); + metadata = await stagedMetadata(selected); + if (previous.status === 'ready') + assertSelected(previous, metadata); + } catch (error) { + const { log, ...record } = previous; + await save(tmp, { + ...record, + status: 'failed', + phase: 'verifying', + error: error.message, + }); + throw error; + } + } else if (previous.status === 'ready') return previous; + const state = { + id: randomUUID(), + action, + status: 'starting', + phase: action === 'apply' ? 'verifying' : 'downloading', + startedAt: Date.now(), + ...metadata, + }; + await save(tmp, state); + const log = await fs.open(logFile(tmp), 'w', 0o600); + try { + const child = spawn( + process.execPath, + [__filename, 'launch', state.id], + { + env: { ...env, QL_DIR: root, dir_tmp: tmp }, + detached: true, + stdio: ['ignore', log.fd, log.fd], + }, + ); + await new Promise((resolve, reject) => { + child.once('exit', (code, signal) => { + if (code === 0) resolve(); + else + reject( + new Error(`Upgrade launcher exited (${signal || code})`), + ); + }); + child.once('error', reject); + }); + child.unref(); + } catch (error) { + await save(tmp, { + ...state, + status: 'failed', + error: error.message, + }); + throw error; + } finally { + await log.close(); + } + return state; + }, + env, + ), + ); + launches.set(tmp, launching); + try { + return await launching; + } finally { + if (launches.get(tmp) === launching) launches.delete(tmp); + } +} + +async function runJob({ root, tmp, env = process.env }, id) { + let state = await readState(tmp); + if (state.id !== id || state.status !== 'starting') + throw new Error('Stale upgrade job'); + const controller = new AbortController(); + const abort = () => controller.abort(new Error('升级任务意外中断,请重试')); + process.once('SIGTERM', abort); + process.once('SIGINT', abort); + // Serialize progress writes so an older tick cannot overwrite ready/completed. + let writes = Promise.resolve(); + const report = (patch) => { + state = { ...state, ...patch, pid: process.pid, updatedAt: Date.now() }; + const snapshot = { ...state }; + writes = writes.then(() => save(tmp, snapshot)); + return writes; + }; + try { + await report({ status: 'running' }); + let terminal; + await artifacts.withUpgradeLock( + tmp, + () => + withPanelLifecycle( + root, + tmp, + env, + async (hook, prepareSelection) => { + const run = (program, args, capture) => + artifacts.runProcess( + env, + program, + args, + capture, + controller.signal, + ); + if (state.action === 'download') { + const selected = await artifacts.stageUpgrade({ + root, + tmp, + env, + mirror: 'github', + fallbackMirror: 'gitee', + run, + signal: controller.signal, + onProgress: report, + install: (source) => + hook('install', [source], controller.signal), + }); + terminal = { + status: 'ready', + phase: 'ready', + ...(await stagedMetadata(selected)), + }; + } else { + const selected = await artifacts.selectedUpgrade( + tmp, + artifacts.releaseBranch(env), + env, + ); + assertSelected(state, await stagedMetadata(selected)); + const previousBuild = await readRunningBuild(env).catch( + () => undefined, + ); + const selection = await prepareSelection?.(selected); + try { + await artifacts.reloadSystem({ + root, + tmp, + env, + data: + env.dir_data || env.QL_DATA_DIR || path.join(root, 'data'), + staticRoot: env.dir_static || path.join(root, 'static'), + config: + env.dir_config || + path.join( + env.QL_DATA_DIR || path.join(root, 'data'), + 'config', + ), + lifecycle: { + signal: controller.signal, + stop: async () => { + await report({ + status: 'restarting', + phase: 'restarting', + }); + await hook('stop'); + }, + start: async (recover) => { + await hook( + 'start', + [], + recover ? undefined : controller.signal, + ); + await artifacts.waitForHealth( + env, + 60000, + recover ? undefined : controller.signal, + ); + if (!recover) + await verifyRunningVersion(env, state, previousBuild); + }, + }, + additionalReplacements: selection + ? [{ source: selection.source, target: selection.target }] + : [], + }); + } finally { + if (selection) + await fs.rm(selection.directory, { + recursive: true, + force: true, + }); + } + terminal = { status: 'completed', phase: 'completed' }; + } + }, + controller.signal, + ), + env, + ); + await report(terminal); + } catch (error) { + console.error(error); + // A failed write must not prevent recording the final failure. + await writes.catch(() => {}); + writes = Promise.resolve(); + await report({ status: 'failed', error: error.message }); + } finally { + process.removeListener('SIGTERM', abort); + process.removeListener('SIGINT', abort); + await writes; + } +} + +module.exports = { getStatus, startJob, runJob }; +async function launchWorker(id) { + // detached creates a session but keeps the original parent. PM2 kills the + // backend's entire descendant tree, so an intermediate launcher must exit + // before the worker is allowed to stop that backend. + const child = spawn(process.execPath, [__filename, 'worker', id], { + env: { ...process.env, QL_UPGRADE_LAUNCHER_PID: String(process.pid) }, + detached: true, + stdio: 'inherit', + }); + await new Promise((resolve, reject) => { + child.once('spawn', resolve); + child.once('error', reject); + }); + child.unref(); +} + +async function runOrphanedWorker(id) { + const launcher = Number(process.env.QL_UPGRADE_LAUNCHER_PID); + const deadline = Date.now() + 10000; + while (process.platform !== 'win32' && process.ppid === launcher) { + if (Date.now() >= deadline) + throw new Error('Upgrade launcher did not exit'); + await delay(10); + } + const root = process.env.QL_DIR; + await runJob( + { root, tmp: process.env.dir_tmp || path.join(root, '.tmp') }, + id, + ); +} + +if (require.main === module && ['launch', 'worker'].includes(process.argv[2])) { + const operation = + process.argv[2] === 'launch' ? launchWorker : runOrphanedWorker; + operation(process.argv[3]).catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/cli/src/internal/maintenance/upgradeArtifacts.cjs b/cli/src/internal/maintenance/upgradeArtifacts.cjs index 8db7c2bc..af8d0bb7 100644 --- a/cli/src/internal/maintenance/upgradeArtifacts.cjs +++ b/cli/src/internal/maintenance/upgradeArtifacts.cjs @@ -24,6 +24,8 @@ const upgradeEnglish = { '暂存升级指针无效。': 'Invalid staged upgrade pointer', '暂存目录无效。': 'Invalid staging directory', '暂存升级就绪记录不匹配。': 'Invalid upgrade readiness', + '运行依赖已变化,请重新下载更新。': + 'Installed dependencies changed; download the update again', '保留备份: %s': 'Retained backup: %s', '后端启动检查失败。': 'Backend failed health check', '保留生命周期快照: %s': 'Retained lifecycle snapshot: %s', @@ -171,6 +173,82 @@ async function withUpgradeLock(tmp, operation, env = process.env) { } } +async function downloadWithProgress( + run, + archive, + url, + artifact, + onProgress, + quiet = true, + expected, +) { + const headers = `${archive}.headers`; + await fs.rm(archive, { force: true }); + await fs.rm(headers, { force: true }); + let sampling = Promise.resolve(); + const sample = async (finished = false) => { + const bytes = (await fs.stat(archive).catch(() => undefined))?.size || 0; + const response = (await fs.readFile(headers, 'utf8').catch(() => '')) + .split(/(?=HTTP\/\S+ \d{3})/) + .at(-1); + const length = response?.match(/^content-length:\s*(\d+)\s*$/im); + const total = + expected?.size || + (length && Number(length[1]) > 0 ? Number(length[1]) : null); + await onProgress({ + phase: 'downloading', + artifact, + downloadedBytes: bytes, + totalBytes: total, + percent: total + ? finished + ? 100 + : Math.min(99, Math.floor((bytes * 100) / total)) + : null, + }); + }; + const timer = setInterval(() => { + sampling = sampling.then(() => sample()); + // Observe failures immediately; propagate after the transfer has closed. + sampling.catch(() => {}); + }, 1000); + try { + await run('curl', [ + '--fail', + '--location', + ...(quiet ? ['--silent', '--show-error'] : []), + '--proto', + '=https', + '--proto-redir', + '=https', + '--connect-timeout', + '30', + '--max-time', + '600', + '--speed-limit', + '1024', + '--speed-time', + '30', + '--dump-header', + headers, + '--output', + archive, + url, + ]); + clearInterval(timer); + await sampling; + if (expected) { + const bytes = await fs.readFile(archive); + if (bytes.length !== expected.size || hash(bytes) !== expected.sha256) + throw new Error('Upgrade archive size or checksum mismatch'); + } + await sample(true); + } finally { + clearInterval(timer); + await sampling; + } +} + async function stageUpgrade({ root, tmp, @@ -180,38 +258,99 @@ async function stageUpgrade({ run, install, signal, + onProgress, }) { + const report = onProgress || (async () => {}); const workspace = await fs.mkdtemp(path.join(tmp, 'upgrade-')); const branch = releaseBranch(env); try { + // Metadata is optional for older releases and unavailable mirrors. Never + // invent a percentage when neither metadata nor HTTP supplies a total. + let info; + if (onProgress && mirror === 'github') { + try { + const readJson = async (url) => { + const filename = path.join(workspace, 'metadata.json'); + await run('curl', [ + '--fail', + '--silent', + '--show-error', + '--location', + '--proto', + '=https', + '--proto-redir', + '=https', + '--connect-timeout', + '5', + '--max-time', + '10', + '--max-filesize', + '1048576', + '--dump-header', + `${filename}.headers`, + '--output', + filename, + url, + ]); + return JSON.parse(await fs.readFile(filename, 'utf8')); + }; + const base = 'https://raw.githubusercontent.com/whyour/qinglong-static'; + const current = await readJson( + `${base}/${branch}/build-info.json?t=${Date.now()}`, + ); + if (!/^[a-f0-9]{40}$/.test(current.sourceCommit)) + throw new Error('Invalid commit'); + const candidate = await readJson( + `${base}/upgrade-info/${current.sourceCommit}.json`, + ); + if ( + candidate.version !== 1 || + candidate.sourceCommit !== current.sourceCommit || + !/^[a-f0-9]{40}$/.test(candidate.staticCommit) || + !['qinglong-static', 'qinglong'].every((repo) => { + const entry = candidate.archives?.[repo]; + return ( + Number.isSafeInteger(entry?.size) && + entry.size > 0 && + /^[a-f0-9]{64}$/.test(entry.sha256) + ); + }) + ) + throw new Error('Invalid archive metadata'); + info = candidate; + } catch { + signal?.throwIfAborted(); + } + } async function extract(repository, ref) { const archive = path.join(workspace, `${repository}.zip`); const url = mirror === 'github' ? `https://codeload.github.com/whyour/${repository}/zip/${ref}` : `https://gitee.com/whyour/${repository}/repository/archive/${ref}.zip`; - const download = (target) => - run('curl', [ - '--fail', - '--location', - '--proto', - '=https', - '--proto-redir', - '=https', - '--connect-timeout', - '30', - '--max-time', - '600', - '--speed-limit', - '1024', - '--speed-time', - '30', - '--output', + const download = async (target, expected) => { + await report({ + phase: 'downloading', + artifact: repository, + downloadedBytes: 0, + totalBytes: expected?.size || null, + percent: expected ? 0 : null, + }); + await downloadWithProgress( + run, archive, target, - ]); + repository, + report, + Boolean(onProgress), + expected, + ); + }; try { - await download(url); + await download( + url, + mirror === 'github' ? info?.archives[repository] : undefined, + ); } catch (error) { signal?.throwIfAborted(); if (mirror !== 'github' || fallbackMirror !== 'gitee') throw error; @@ -220,6 +359,11 @@ async function stageUpgrade({ `https://gitee.com/whyour/${repository}/repository/archive/${ref}.zip`, ); } + await report({ + phase: 'extracting', + artifact: repository, + percent: null, + }); const listing = await run('unzip', ['-Z1', archive], true); const expected = `${repository}-${ref}`; const names = listing.stdout.split('\n').filter(Boolean); @@ -243,11 +387,20 @@ async function stageUpgrade({ } // Static branches are mutable snapshots. Their manifest pins the exact // source revision, avoiding a race between two independent branch reads. - const staticRoot = await extract('qinglong-static', branch); + const downloadedStatic = await extract( + 'qinglong-static', + info?.staticCommit || branch, + ); + const staticRoot = path.join(workspace, `qinglong-static-${branch}`); + if (downloadedStatic !== staticRoot) + await fs.rename(downloadedStatic, staticRoot); const manifest = await manifestAt(staticRoot, env); + if (info && manifest.sourceCommit !== info.sourceCommit) + throw new Error(upgradeMessage('构建清单无效。', env)); const downloadedSource = await extract('qinglong', manifest.sourceCommit); const source = path.join(workspace, `qinglong-${branch}`); await fs.rename(downloadedSource, source); + await report({ phase: 'verifying', percent: null }); await verifyUpgrade(source, staticRoot, env); let dependenciesChanged = false; for (const name of ['package.json', 'pnpm-lock.yaml']) { @@ -265,6 +418,7 @@ async function stageUpgrade({ ) dependenciesChanged = true; if (dependenciesChanged) { + await report({ phase: 'dependencies', percent: null }); await install(source); await fs.access(path.join(source, 'node_modules')); await verifyUpgrade(source, staticRoot, env); @@ -275,6 +429,7 @@ async function stageUpgrade({ path.join(workspace, 'ready.json'), JSON.stringify({ ...selected, + root: path.resolve(root), sourceCommit: manifest.sourceCommit, dependenciesChanged, }), @@ -343,6 +498,28 @@ async function selectedUpgrade(tmp, branch, env = process.env) { throw new Error(upgradeMessage('暂存升级就绪记录不匹配。', env)); if (ready.dependenciesChanged) await fs.access(path.join(selected.source, 'node_modules')); + else { + const root = ready.root || env.QL_DIR; + if ( + !root || + !( + await fs.stat(path.join(root, 'node_modules')).catch(() => undefined) + )?.isDirectory() + ) + throw new Error(upgradeMessage('运行依赖已变化,请重新下载更新。', env)); + for (const name of ['package.json', 'pnpm-lock.yaml']) { + const current = await fs + .readFile(path.join(root, name)) + .catch(() => undefined); + if ( + !current || + !(await fs.readFile(path.join(selected.source, name))).equals(current) + ) + throw new Error( + upgradeMessage('运行依赖已变化,请重新下载更新。', env), + ); + } + } return selected; } @@ -430,6 +607,7 @@ async function reloadSystem({ config, env, lifecycle, + additionalReplacements = [], }) { const selected = await selectedUpgrade(tmp, releaseBranch(env), env); const reserved = new Set(['data', '.tmp', 'static', '.git', '.env']); @@ -449,6 +627,7 @@ async function reloadSystem({ target: path.join(config, 'config.sample.sh'), }, ); + replacements.push(...additionalReplacements); await replaceAndReload(replacements, lifecycle, env); // Cleanup after a committed installation must not report a failed update. await fs @@ -687,6 +866,7 @@ async function main() { } module.exports = { + downloadWithProgress, upgradeMessage, releaseBranch, verifyUpgrade, diff --git a/cli/src/remote/api/openOperations.ts b/cli/src/remote/api/openOperations.ts index a7e5821c..ffb8fae9 100644 --- a/cli/src/remote/api/openOperations.ts +++ b/cli/src/remote/api/openOperations.ts @@ -416,6 +416,7 @@ export const openOperations: readonly OpenOperation[] = [ body: 'json', }, { name: 'system update-check', method: 'PUT', path: 'system/update-check' }, + { name: 'system update-status', method: 'GET', path: 'system/update-status' }, { name: 'system update', method: 'PUT', path: 'system/update' }, { name: 'system reload', method: 'PUT', path: 'system/reload', body: 'json' }, { name: 'system notify', method: 'PUT', path: 'system/notify', body: 'json' }, diff --git a/cli/test/internal/operator.test.cjs b/cli/test/internal/operator.test.cjs index 59ad89d5..91c742fa 100644 --- a/cli/test/internal/operator.test.cjs +++ b/cli/test/internal/operator.test.cjs @@ -276,7 +276,7 @@ test('upgrade staging extracts both archives before publishing readiness and rej 'utf8', ), ), - { ...staged, sourceCommit, dependenciesChanged: false }, + { ...staged, root: ctx.root, sourceCommit, dependenciesChanged: false }, ); await fs.symlink( '../../outside', diff --git a/scripts/publish-upgrade-info.cjs b/scripts/publish-upgrade-info.cjs new file mode 100644 index 00000000..bdb586b1 --- /dev/null +++ b/scripts/publish-upgrade-info.cjs @@ -0,0 +1,136 @@ +// Publish archive sizes separately: adding them to the archive itself would +// change its bytes. Immutable commit URLs make both size and checksum stable. +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { createHash } = require('node:crypto'); +const { execFileSync } = require('node:child_process'); + +function publishUpgradeInfo( + destination, + ref, + sourceCommit, + execute = execFileSync, +) { + const run = (program, args) => + execute(program, args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (!/^[a-f0-9]{40}$/.test(sourceCommit)) + throw new Error('Invalid source commit'); + run('git', ['check-ref-format', `refs/heads/${ref}`]); + const staticCommit = run('git', [ + 'ls-remote', + destination, + `refs/heads/${ref}`, + ]) + .trim() + .split(/\s/)[0]; + if (!/^[a-f0-9]{40}$/.test(staticCommit)) + throw new Error('Missing static commit'); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-upgrade-info-')); + try { + const archives = {}; + for (const [repository, commit] of [ + ['qinglong-static', staticCommit], + ['qinglong', sourceCommit], + ]) { + const filename = path.join(tmp, `${repository}.zip`); + run('curl', [ + '--fail', + '--silent', + '--show-error', + '--location', + '--proto', + '=https', + '--proto-redir', + '=https', + '--connect-timeout', + '30', + '--max-time', + '600', + '--output', + filename, + `https://codeload.github.com/whyour/${repository}/zip/${commit}`, + ]); + run('unzip', ['-tq', filename]); + if (repository === 'qinglong-static') { + const manifest = JSON.parse( + run('unzip', [ + '-p', + filename, + `${repository}-${commit}/build-info.json`, + ]), + ); + if (manifest.sourceCommit !== sourceCommit || manifest.dirty !== false) + throw new Error('Static source commit does not match this build'); + } + const bytes = fs.readFileSync(filename); + archives[repository] = { + size: bytes.length, + sha256: createHash('sha256').update(bytes).digest('hex'), + }; + } + const info = { version: 1, sourceCommit, staticCommit, archives }; + for (let attempt = 0; attempt < 3; attempt++) { + const checkout = path.join(tmp, `metadata-${attempt}`); + const exists = run('git', [ + 'ls-remote', + destination, + 'refs/heads/upgrade-info', + ]).trim(); + if (exists) + run('git', [ + 'clone', + '--quiet', + '--depth', + '1', + '--branch', + 'upgrade-info', + '--single-branch', + destination, + checkout, + ]); + else run('git', ['init', '--quiet', '-b', 'upgrade-info', checkout]); + const git = (...args) => run('git', ['-C', checkout, ...args]); + git('config', 'user.name', 'github-actions[bot]'); + git( + 'config', + 'user.email', + 'github-actions[bot]@users.noreply.github.com', + ); + fs.writeFileSync( + path.join(checkout, `${sourceCommit}.json`), + JSON.stringify(info) + '\n', + ); + git('add', `${sourceCommit}.json`); + git( + 'commit', + '--quiet', + '--allow-empty', + '-m', + `Archive metadata for ${sourceCommit}`, + ); + try { + git('push', '--quiet', destination, 'HEAD:refs/heads/upgrade-info'); + return info; + } catch (error) { + if (attempt === 2) throw error; + } + } + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +} + +module.exports = { publishUpgradeInfo }; +if (require.main === module) { + try { + publishUpgradeInfo(...process.argv.slice(2)); + } catch { + // Git errors may include a credential-bearing remote URL. + console.error('Failed to publish verified upgrade archive metadata'); + process.exitCode = 1; + } +} diff --git a/src/components/UpgradeNotice.tsx b/src/components/UpgradeNotice.tsx new file mode 100644 index 00000000..d3365e26 --- /dev/null +++ b/src/components/UpgradeNotice.tsx @@ -0,0 +1,60 @@ +import { CloudDownloadOutlined } from '@ant-design/icons'; +import { Modal, Tooltip } from 'antd'; +import { useState } from 'react'; +import intl from 'react-intl-universal'; +import useUpgrade, { activeStatuses, phaseLabels } from '@/hooks/useUpgrade'; +import CheckUpdate from '@/pages/setting/checkUpdate'; + +export default function UpgradeNotice({ + collapsed, + systemInfo, +}: { + collapsed: boolean; + systemInfo: any; +}) { + const { job, connected } = useUpgrade(); + const [open, setOpen] = useState(false); + const showNotice = + activeStatuses.includes(job.status) || + job.status === 'ready' || + job.status === 'failed'; + if (!showNotice && !open) return null; + const text = !connected + ? intl.get('连接已断开,正在重新连接') + : job.status === 'failed' + ? intl.get('更新失败') + : intl.get(phaseLabels[job.phase || ''] || '检查更新'); + const label = + job.phase === 'downloading' && job.percent != null + ? `${text} ${job.percent}%` + : text; + return ( + <> + {showNotice && ( + + + + )} + setOpen(false)} + width={650} + destroyOnClose + > + + + + ); +} diff --git a/src/hooks/useUpgrade.ts b/src/hooks/useUpgrade.ts new file mode 100644 index 00000000..1f24a0b2 --- /dev/null +++ b/src/hooks/useUpgrade.ts @@ -0,0 +1,138 @@ +import config from '@/utils/config'; +import { notification } from 'antd'; +import intl from 'react-intl-universal'; +import { useSyncExternalStore } from 'react'; + +export interface UpgradeJob { + id?: string; + action?: 'download' | 'apply'; + status: string; + phase?: string; + artifact?: string; + downloadedBytes?: number; + totalBytes?: number | null; + percent?: number | null; + version?: string; + error?: string; + log?: string; +} + +export const activeStatuses = ['starting', 'running', 'restarting']; +export const reloadKey = 'qinglong-upgrade-reload'; +export const completionKey = 'qinglong-upgrade-completed'; +export const phaseLabels: Record = { + downloading: '下载更新中...', + extracting: '正在解压更新包', + verifying: '正在校验更新包', + dependencies: '正在准备依赖', + ready: '更新已就绪', + restarting: '正在重启并更新', + completed: '更新完成', +}; + +// The sidebar and settings share one poller and one mutation state. +export function createUpgradeStore() { + let snapshot = { + job: { status: 'loading' } as UpgradeJob, + busy: false, + connected: true, + }; + const listeners = new Set<() => void>(); + let revision = 0; + let timer: ReturnType | undefined; + let controller: AbortController | undefined; + const publish = (patch: Partial) => { + snapshot = { ...snapshot, ...patch }; + listeners.forEach((listener) => listener()); + }; + const poll = async () => { + const current = revision; + const attempt = new AbortController(); + controller = attempt; + const timeout = setTimeout(() => attempt.abort(), 5000); + try { + const token = localStorage.getItem(config.authKey); + const response = await fetch(`${config.apiPrefix}system/update-status`, { + cache: 'no-store', + headers: token ? { Authorization: `Bearer ${token}` } : {}, + signal: attempt.signal, + }); + if (!response.ok) throw new Error('Upgrade status unavailable'); + const result = await response.json(); + if (result.code !== 200 || !result.data) + throw new Error('Invalid upgrade status'); + if (!listeners.size || current !== revision || attempt.signal.aborted) + return; + publish({ job: result.data, connected: true }); + if ( + result.data.status === 'completed' && + result.data.id === sessionStorage.getItem(reloadKey) + ) { + sessionStorage.setItem(completionKey, result.data.id); + sessionStorage.removeItem(reloadKey); + window.location.reload(); + return; + } + if ( + result.data.status === 'completed' && + result.data.id === sessionStorage.getItem(completionKey) + ) { + sessionStorage.removeItem(completionKey); + notification.success({ + message: intl.get('升级成功'), + description: intl.get('已重新启动并运行新版本,无需再次重启。'), + duration: 8, + }); + } + } catch { + if (listeners.size && current === revision && controller === attempt) + publish({ connected: false }); + } finally { + clearTimeout(timeout); + if (listeners.size && controller === attempt) + timer = setTimeout( + poll, + !snapshot.connected || activeStatuses.includes(snapshot.job.status) + ? 1000 + : 15000, + ); + } + }; + return { + getSnapshot: () => snapshot, + subscribe(listener: () => void) { + listeners.add(listener); + if (listeners.size === 1) poll(); + return () => { + listeners.delete(listener); + if (!listeners.size) { + clearTimeout(timer); + controller?.abort(); + revision++; + } + }; + }, + beginMutation() { + if (snapshot.busy) return false; + revision++; + publish({ busy: true }); + return true; + }, + finishMutation() { + revision++; + controller?.abort(); + controller = undefined; + clearTimeout(timer); + publish({ busy: false }); + if (listeners.size) timer = setTimeout(poll, 1000); + }, + setJob(job: UpgradeJob) { + publish({ job }); + }, + }; +} + +export const upgradeStore = createUpgradeStore(); +export default function useUpgrade() { + return useSyncExternalStore(upgradeStore.subscribe, upgradeStore.getSnapshot); +} diff --git a/src/layouts/index.less b/src/layouts/index.less index 5103878f..a58fc114 100644 --- a/src/layouts/index.less +++ b/src/layouts/index.less @@ -354,20 +354,50 @@ select:-webkit-autofill:focus { .side-menu-container { display: flex; + width: 100%; + min-width: 0; + gap: 8px; justify-content: space-between; align-items: center; color: @text-color; - .side-menu-collapse-button:hover { - color: #1890ff; + .side-menu-collapse-button { + flex-shrink: 0; + + &:hover { + color: #1890ff; + } } .side-menu-user-wrapper { - display: flex; - align-items: center; + flex: 1; + overflow: hidden; } } +.side-menu-user-wrapper { + display: flex; + min-width: 0; + max-width: 100%; + align-items: center; + gap: 5px; + + .ant-avatar { + flex-shrink: 0; + } + + .side-menu-username { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +body[data-mode='phone'] .side-menu-user-wrapper { + max-width: 50vw; +} + .side-menu-user-drop-menu { position: relative; text-align: left; @@ -475,11 +505,105 @@ body[data-mode='phone'] header { } body[data-dark='true'] { + .upgrade-notice { + background: #3a3214; + border-color: #6b5415; + color: #ffd666; + } .ant-popover-arrow-content { --antd-arrow-background-color: rgb(24, 26, 27); } } +.upgrade-notice { + display: flex; + align-items: center; + gap: 8px; + width: calc(100% - 24px); + min-height: 36px; + padding: 7px 10px; + margin: 8px 12px 12px; + border: 1px solid #ffe58f; + border-radius: 6px; + background: #fffbe6; + color: #ad6800; + font: inherit; + font-size: 12px; + line-height: 20px; + text-align: left; + cursor: pointer; + + > .anticon { + display: inline-flex; + flex: 0 0 14px; + align-items: center; + justify-content: center; + line-height: 1; + } + + > span:not(.anticon) { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &:focus-visible { + outline: 2px solid #d48806; + outline-offset: 2px; + } + + &.upgrade-notice-collapsed { + justify-content: center; + padding: 7px 0; + } +} + +.upgrade-panel { + display: flex; + flex-direction: column; + gap: 16px; + max-width: 600px; + + .upgrade-status-content { + display: flex; + flex-direction: column; + gap: 16px; + } + + .upgrade-progress { + display: flex; + flex-direction: column; + gap: 8px; + + .ant-progress { + margin: 0; + } + } + + .upgrade-progress-meta { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 4px 12px; + font-variant-numeric: tabular-nums; + } + + .upgrade-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + } + + .upgrade-log-link { + height: auto; + padding: 0; + line-height: 22px; + } +} + .ant-tabs-content-holder { flex: 1; overflow-y: auto; diff --git a/src/layouts/index.tsx b/src/layouts/index.tsx index 791f2906..413b3887 100644 --- a/src/layouts/index.tsx +++ b/src/layouts/index.tsx @@ -19,6 +19,8 @@ import './index.less'; import { init } from '../utils/init'; import WebSocketManager from '../utils/websocket'; import { observePageLoadTiming } from '../utils/navigationTiming'; +import UpgradeNotice from '@/components/UpgradeNotice'; +import { completionKey, reloadKey, upgradeStore } from '@/hooks/useUpgrade'; export interface SharedContext { headerStyle: React.CSSProperties; @@ -54,6 +56,9 @@ export default function () { ); const [collapsed, setCollapsed] = useState(false); const [initLoading, setInitLoading] = useState(true); + const observeUpgrade = + Boolean(user.username) && + !['/login', '/initialization'].includes(location.pathname); const { enable: enableDarkMode, disable: disableDarkMode, @@ -162,6 +167,18 @@ export default function () { getHealthStatus(); }, []); + useEffect(() => { + // A reload during restart may initially land on the health error page. + // Keep recovering the accepted job even before the sidebar can mount. + if ( + observeUpgrade || + sessionStorage.getItem(reloadKey) || + sessionStorage.getItem(completionKey) + ) { + return upgradeStore.subscribe(() => {}); + } + }, [observeUpgrade]); + useEffect(() => { reloadSystemConfig(); }, []); @@ -321,6 +338,12 @@ export default function () { } title={false} + menuFooterRender={(props) => ( + + )} menuItemRender={(menuItemProps: any, defaultDom: any) => { if ( menuItemProps.isUrl || @@ -351,7 +374,9 @@ export default function () { user.avatar ? `${config.apiPrefix}static/${user.avatar}` : '' } /> - {user.username} + + {user.username} + ) @@ -377,7 +402,9 @@ export default function () { : '' } /> - {user.username} + + {user.username} + )} diff --git a/src/locales/en-US.json b/src/locales/en-US.json index 63fd3b86..97b655b5 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -338,7 +338,7 @@ "显示": "Display", "智能微秘书": "Smart WeChat Assistant", "暂无数据": "No data", - "暂无日志": "No logs available", + "暂无日志": "No logs yet", "暂无脚本": "No scripts available", "暂无运行中任务": "No running tasks", "暗色": "Dark", @@ -731,5 +731,30 @@ "白屏时间": "Time to first response: {duration} ms", "请求完毕至DOM加载": "Response end to DOM interactive: {duration} ms", "解释DOM树耗时": "DOM completion time: {duration} ms", - "从开始至load总耗时": "Total load time: {duration} ms" + "从开始至load总耗时": "Total load time: {duration} ms", + "正在解压更新包": "Extracting update", + "正在校验更新包": "Verifying update", + "正在准备依赖": "Preparing dependencies", + "更新已就绪": "Update ready", + "正在重启并更新": "Restarting and updating", + "更新完成": "Update complete", + "更新失败": "Update failed", + "正在准备更新": "Preparing update", + "连接已断开,正在重新连接": "Connection lost. Reconnecting…", + "更新已就绪,重启后应用新版本": "Update ready. Restart to apply the new version.", + "更新在后台进行,可以继续使用面板": "The update runs in the background. You can keep using the panel.", + "下载构建产物": "Downloading application files", + "下载源码": "Downloading source files", + "正在获取下载大小": "Total size unavailable", + "查看更新日志": "View update log", + "重启并更新": "Restart and update", + "升级任务启动失败,请检查日志后重试": "Failed to start the update. Check the logs and try again.", + "升级任务意外中断,请重试": "The update was interrupted. Please try again.", + "系统将在30秒后自动刷新": "The page will refresh in 30 seconds.", + "运行依赖已变化,请重新下载更新。": "Installed dependencies changed. Download the update again.", + "暂存更新已变化,请重新下载更新。": "The staged update changed. Download the update again.", + "启动版本与更新包不一致": "The running build does not match the update package.", + "下载完成,等待重启": "Download complete, restart to update", + "升级成功": "Update successful", + "已重新启动并运行新版本,无需再次重启。": "Restarted and running the new version. No further restart is needed." } diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 9863ec57..a95b7157 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -731,5 +731,30 @@ "白屏时间": "白屏时间: {duration} ms", "请求完毕至DOM加载": "请求完毕至DOM加载: {duration} ms", "解释DOM树耗时": "解释 DOM 树耗时: {duration} ms", - "从开始至load总耗时": "从开始至 load 总耗时: {duration} ms" + "从开始至load总耗时": "从开始至 load 总耗时: {duration} ms", + "正在解压更新包": "正在解压更新包", + "正在校验更新包": "正在校验更新包", + "正在准备依赖": "正在准备依赖", + "更新已就绪": "更新已就绪", + "正在重启并更新": "正在重启并更新", + "更新完成": "更新完成", + "更新失败": "更新失败", + "正在准备更新": "正在准备更新", + "连接已断开,正在重新连接": "连接已断开,正在重新连接", + "更新已就绪,重启后应用新版本": "更新已就绪,重启后应用新版本", + "更新在后台进行,可以继续使用面板": "更新在后台进行,可以继续使用面板", + "下载构建产物": "下载构建产物", + "下载源码": "下载源码", + "正在获取下载大小": "正在获取下载大小", + "查看更新日志": "查看更新日志", + "重启并更新": "重启并更新", + "升级任务启动失败,请检查日志后重试": "升级任务启动失败,请检查日志后重试", + "升级任务意外中断,请重试": "升级任务意外中断,请重试", + "系统将在30秒后自动刷新": "系统将在30秒后自动刷新", + "运行依赖已变化,请重新下载更新。": "运行依赖已变化,请重新下载更新。", + "暂存更新已变化,请重新下载更新。": "暂存更新已变化,请重新下载更新。", + "启动版本与更新包不一致": "启动版本与更新包不一致", + "下载完成,等待重启": "下载完成,等待重启", + "升级成功": "升级成功", + "已重新启动并运行新版本,无需再次重启。": "已重新启动并运行新版本,无需再次重启。" } diff --git a/src/pages/setting/checkUpdate.tsx b/src/pages/setting/checkUpdate.tsx index 47e48aa4..250a34da 100644 --- a/src/pages/setting/checkUpdate.tsx +++ b/src/pages/setting/checkUpdate.tsx @@ -1,249 +1,219 @@ -import { disableBody } from '@/utils'; import config from '@/utils/config'; import { request } from '@/utils/http'; -import WebSocketManager from '@/utils/websocket'; import Ansi from 'ansi-to-react'; -import { Button, Modal, Statistic, message } from 'antd'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { Button, Modal, Progress, Space, Typography, message } from 'antd'; +import { useState } from 'react'; +import useUpgrade, { + activeStatuses, + phaseLabels, + reloadKey, + upgradeStore, +} from '@/hooks/useUpgrade'; import intl from 'react-intl-universal'; -const { Countdown } = Statistic; +const sizeLabel = (bytes: number) => `${(bytes / 1024 / 1024).toFixed(1)} MB`; const CheckUpdate = ({ systemInfo }: any) => { - const [updateLoading, setUpdateLoading] = useState(false); - const [value, setValue] = useState(''); - const modalRef = useRef(); - const lastStatusRef = useRef(); - const [updateStatus, setUpdateStatus] = useState(); - - const checkUpgrade = () => { - if (updateLoading || modalRef.current) return; - setUpdateLoading(true); - message.loading(intl.get('检查更新中...'), 0); - request - .put(`${config.apiPrefix}system/update-check`) - .then(({ code, data }) => { - message.destroy(); - if (code === 200) { - if (data.hasNewVersion) { - showConfirmUpdateModal(data); - } else { - showForceUpdateModal(data); - } - } - }) - .catch((error: any) => { - message.destroy(); - message.error(intl.get('检查更新失败,请检查网络后重试')); - }) - .finally(() => { - setUpdateLoading(false); - }); - }; - - const showForceUpdateModal = (data: any) => { - Modal.confirm({ - width: 500, - title: intl.get('更新'), - content: ( - <> -
{intl.get('已经是最新版了!')}
-
- {intl.get('青龙')} {data.lastVersion}{' '} - {intl.get('是目前检测到的最新可用版本了。')} -
- - ), - okText: intl.get('重新下载'), - onOk() { - startUpdate(); - }, - }); - }; - - const showConfirmUpdateModal = (data: any) => { - const { lastVersion, lastLog } = data; - Modal.confirm({ - width: 500, - title: ( - <> -
{intl.get('更新可用')}
-
- {intl.get('新版本可用,当前版本', { - version: lastVersion, - current: systemInfo.version, - })} -
- - ), - content: ( -
-          {lastLog}
-        
- ), - okText: intl.get('下载更新'), - cancelText: intl.get('以后再说'), - onOk() { - startUpdate(); - }, - }); - }; - - const showUpdatingModal = () => { - setValue(''); - lastStatusRef.current = 'running'; - setUpdateStatus('running'); - modalRef.current = Modal.info({ - width: 600, - maskClosable: false, - closable: false, - keyboard: false, - okButtonProps: { disabled: true }, - title: intl.get('下载更新中...'), - centered: true, - afterClose: () => { - modalRef.current = undefined; - }, - content:
,
-    });
-  };
+  const { job, busy, connected } = useUpgrade();
+  const [details, setDetails] = useState(false);
+  const active = activeStatuses.includes(job.status);
+  const ready = job.status === 'ready';
+  const completed = job.status === 'completed';
 
   const startUpdate = async () => {
-    showUpdatingModal();
+    if (busy || active) return;
+    if (!upgradeStore.beginMutation()) return;
     try {
-      const response = await request.put(`${config.apiPrefix}system/update`);
-      if (response.code !== 200) throw new Error(response.message);
+      const result = await request.put(`${config.apiPrefix}system/update`);
+      if (result.code !== 200) throw new Error(result.message);
+      upgradeStore.setJob(result.data);
     } catch {
-      lastStatusRef.current = 'failed';
-      setUpdateStatus('failed');
       message.error(intl.get('更新失败,请检查网络及日志或稍后再试'));
+    } finally {
+      upgradeStore.finishMutation();
     }
   };
 
-  const reloadSystem = (type?: string) => {
-    request
-      .put(`${config.apiPrefix}update/${type}`)
-      .then(({ code }: any) => {
-        if (code !== 200) return;
-        message.success({
-          content: (
-            
-              {intl.get('系统将在')}
-              
-              {intl.get('秒后自动刷新')}
-            
-          ),
-          duration: 30,
-        });
-        disableBody();
-        setTimeout(() => {
-          window.location.reload();
-        }, 30000);
-      })
-      .catch((error: any) => {
-        console.log(error);
+  const checkUpgrade = async () => {
+    if (!upgradeStore.beginMutation()) return;
+    try {
+      const { code, data } = await request.put(
+        `${config.apiPrefix}system/update-check`,
+      );
+      if (code !== 200) throw new Error('Update check failed');
+      Modal.confirm({
+        width: 500,
+        title: intl.get(data.hasNewVersion ? '更新可用' : '已经是最新版了!'),
+        content: data.hasNewVersion ? (
+          <>
+            

+ {intl.get('新版本可用,当前版本', { + version: data.lastVersion, + current: systemInfo.version, + })} +

+
+              {data.lastLog}
+            
+ + ) : ( + `${intl.get('青龙')} ${data.lastVersion}` + ), + okText: intl.get(data.hasNewVersion ? '下载更新' : '重新下载'), + cancelText: intl.get('以后再说'), + onOk: startUpdate, }); + } catch { + message.error(intl.get('检查更新失败,请检查网络后重试')); + } finally { + upgradeStore.finishMutation(); + } }; - const showReloadModal = () => { + const reloadSystem = async (apply: boolean) => { + if (!upgradeStore.beginMutation()) return; + try { + const result = await request.put( + `${config.apiPrefix}update/${apply ? 'system' : 'reload'}`, + ); + if (result.code !== 200) throw new Error(result.message); + if (apply) { + upgradeStore.setJob(result.data); + sessionStorage.setItem(reloadKey, result.data.id); + } else { + message.success(intl.get('系统将在30秒后自动刷新')); + setTimeout(() => window.location.reload(), 30000); + } + } catch { + message.error(intl.get('升级任务启动失败,请检查日志后重试')); + } finally { + upgradeStore.finishMutation(); + } + }; + + const showReloadModal = () => Modal.confirm({ - width: 600, - maskClosable: false, title: intl.get('确认重启'), - centered: true, - content: intl.get('系统安装包下载成功,确认重启'), - okText: intl.get('重启'), - onOk() { - reloadSystem('system'); - }, - onCancel() { - modalRef.current?.update({ - maskClosable: true, - closable: true, - okButtonProps: { disabled: false }, - }); - }, + content: intl.get('更新已就绪,重启后应用新版本'), + okText: intl.get('重启并更新'), + cancelText: intl.get('以后再说'), + onOk: () => reloadSystem(true), }); - }; - useEffect(() => { - if (!modalRef.current) return; - const updateFailed = updateStatus === 'failed'; - - modalRef.current.update({ - maskClosable: updateFailed, - closable: updateFailed, - okButtonProps: { disabled: !updateFailed }, - content: ( - <> -
-            {value}
-          
-
- - ), - }); - }, [value, updateStatus]); - - const handleMessage = useCallback((payload: any) => { - if (!modalRef.current || lastStatusRef.current !== 'running') return; - const { message: _message, status } = payload; - - if (status === 'failed') { - lastStatusRef.current = 'failed'; - setUpdateStatus('failed'); - message.error(intl.get('更新失败,请检查网络及日志或稍后再试')); - } - - if (status === 'success') { - lastStatusRef.current = 'success'; - setUpdateStatus('success'); - setTimeout(() => { - if (modalRef.current && lastStatusRef.current === 'success') { - showReloadModal(); - } - }, 1000); - } - - setTimeout(() => { - document - .querySelector('#log-identifier') - ?.scrollIntoView({ behavior: 'smooth' }); - }, 600); - - if (_message) { - setValue((p) => `${p}${_message}`); - } - }, []); - - useEffect(() => { - const ws = WebSocketManager.getInstance(); - ws.subscribe('updateSystemVersion', handleMessage); - - return () => { - ws.unsubscribe('updateSystemVersion', handleMessage); - modalRef.current?.destroy(); - modalRef.current = undefined; - }; - }, []); - - return ( - <> - + const label = + job.status === 'failed' + ? intl.get('更新失败') + : ready + ? intl.get('下载完成,等待重启') + : completed + ? intl.get('升级成功') + : intl.get(phaseLabels[job.phase || ''] || '正在准备更新'); + const hasJob = job.status !== 'idle' && job.status !== 'loading'; + const actions = !active && ( + - + {!ready && !completed && ( + + )} + + ); + + return ( +
+ {!connected && ( + + {intl.get('连接已断开,正在重新连接')} + + )} + {!hasJob && actions} + {hasJob && ( +
+ + {label} + {job.version ? ` · v${job.version}` : ''} + + + {job.status === 'failed' + ? intl.get(job.error || '更新失败,请检查网络及日志或稍后再试') || + job.error + : intl.get( + completed + ? '已重新启动并运行新版本,无需再次重启。' + : ready + ? '更新已就绪,重启后应用新版本' + : '更新在后台进行,可以继续使用面板', + )} + + {job.phase === 'downloading' && active && ( +
+
+ + {intl.get( + job.artifact === 'qinglong-static' + ? '下载构建产物' + : '下载源码', + )} + + + {sizeLabel(job.downloadedBytes || 0)} + {job.totalBytes ? ` / ${sizeLabel(job.totalBytes)}` : ''} + +
+ +
+ )} +
+ {actions} + +
+
+ )} + setDetails(false)} + width={700} + > +
+          {job.log || intl.get('暂无日志')}
+        
+
+
); }; diff --git a/test/back/panel-upgrade.test.cjs b/test/back/panel-upgrade.test.cjs new file mode 100644 index 00000000..e38394ea --- /dev/null +++ b/test/back/panel-upgrade.test.cjs @@ -0,0 +1,469 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const syncFs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const http = require('node:http'); +const { execFileSync } = require('node:child_process'); +const { randomUUID } = require('node:crypto'); +const { setTimeout: delay } = require('node:timers/promises'); +const { + startJob, + getStatus, + runJob, +} = require('../../cli/src/internal/maintenance/panelUpgrade.cjs'); +const { downloadWithProgress } = require('../../shell/upgrade.cjs'); +const { + writeManifest, + sourceCommit, +} = require('../helpers/upgrade-fixture.cjs'); + +async function fixture(t) { + const base = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-panel-job-')); + const root = path.join(base, 'panel'), + tmp = path.join(root, '.tmp'); + const source = path.join(base, `qinglong-${sourceCommit}`); + const staticRoot = path.join(base, 'qinglong-static-master'); + const write = async (name, contents) => { + await fs.mkdir(path.dirname(name), { recursive: true }); + await fs.writeFile(name, contents); + }; + for (const dir of [root, source]) { + await write(path.join(dir, 'package.json'), '{"version":"2.24.0"}'); + await write(path.join(dir, 'pnpm-lock.yaml'), 'lock'); + await write(path.join(dir, 'sample/config.sample.sh'), '# config'); + } + await write( + path.join(root, 'node_modules/dependency'), + 'installed dependency', + ); + await write(path.join(root, 'version.yaml'), 'version: 2.23.0'); + await write(path.join(source, 'version.yaml'), 'version: 2.24.0'); + await write(path.join(staticRoot, 'build/app.js'), 'new backend'); + writeManifest(source, staticRoot); + for (const repo of ['qinglong', 'qinglong-static']) + execFileSync( + 'zip', + [ + '-qr', + path.join(base, `${repo}.zip`), + repo === 'qinglong' ? path.basename(source) : path.basename(staticRoot), + ], + { cwd: base }, + ); + await write( + path.join(root, 'shell/share.sh'), + ` +import_config() { :; } +delete_pm2() { printf stopped >> "$QL_DIR/stops"; } +reload_pm2() { printf started >> "$QL_DIR/starts"; } +npm_install_2() { mkdir -p "$1/node_modules"; printf installed > "$1/node_modules/dependency"; exit_status=0; } +`, + ); + await write(path.join(root, 'shell/env.sh'), ''); + await write( + path.join(base, 'bin/curl'), + `#!${process.execPath} +const fs = require('node:fs'), path = require('node:path'); +const args = process.argv.slice(2); +if (process.env.CHECK_WORKER_PARENT === 'true') { + const { execFileSync } = require('node:child_process'); + const parent = Number(execFileSync('ps', ['-o', 'ppid=', '-p', String(process.ppid)], { encoding: 'utf8' }).trim()); + fs.writeFileSync(path.join(process.env.ARCHIVES, 'worker-parent.json'), JSON.stringify({ worker: process.ppid, parent })); +} +const repo = args.at(-1).includes('/qinglong-static/') ? 'qinglong-static' : 'qinglong'; +if (process.env.FAIL_DOWNLOAD === 'true') process.exit(22); +const buffer = fs.readFileSync(path.join(process.env.ARCHIVES, repo + '.zip')); +fs.writeFileSync(args[args.indexOf('--dump-header') + 1], 'HTTP/1.1 200 OK\\r\\nContent-Length: ' + buffer.length + '\\r\\n\\r\\n'); +fs.writeFileSync(args[args.indexOf('--output') + 1], buffer); +`, + ); + await fs.chmod(path.join(base, 'bin/curl'), 0o700); + const runtime = { + version: '2.24.0', + sourceCommit, + instanceId: 'after-restart', + parentPid: 100002, + backendSha256: JSON.parse( + await fs.readFile(path.join(staticRoot, 'build-info.json'), 'utf8'), + ).files['build/app.js'], + }; + const server = http.createServer((req, res) => { + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ + code: 200, + data: req.url.endsWith('/api/system') + ? { version: runtime.version } + : { + status: 'ok', + build: { + ...runtime, + instanceId: syncFs.existsSync(path.join(root, 'starts')) + ? runtime.instanceId + : 'before-restart', + parentPid: syncFs.existsSync(path.join(root, 'starts')) + ? runtime.parentPid + : 100001, + }, + }, + }), + ); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const env = { + ...process.env, + PATH: `${base}/bin:${process.env.PATH}`, + ARCHIVES: base, + QL_BRANCH: 'master', + QlPort: String(server.address().port), + }; + t.after(async () => { + server.close(); + await fs.rm(base, { recursive: true, force: true }); + }); + return { root, tmp, env, base, runtime }; +} + +async function finished(tmp) { + for (let i = 0; i < 200; i++) { + const status = await getStatus(tmp); + if (['ready', 'completed', 'failed'].includes(status.status)) return status; + await delay(25); + } + assert.fail('Worker did not finish'); +} + +test('panel jobs are reparented before running outside the initiating backend process tree', async (t) => { + const f = await fixture(t); + f.env.CHECK_WORKER_PARENT = 'true'; + await startJob(f, 'download'); + const ready = await finished(f.tmp); + assert.equal(ready.status, 'ready', ready.log); + const relationship = JSON.parse( + await fs.readFile(path.join(f.base, 'worker-parent.json'), 'utf8'), + ); + assert.equal(relationship.worker, ready.pid); + assert.notEqual( + relationship.parent, + process.pid, + 'PM2 must not find the upgrade worker in the backend process tree', + ); +}); + +test('detached download survives its initiating request and is idempotent; applying is explicit', async (t) => { + const f = await fixture(t); + const [first, duplicate] = await Promise.all([ + startJob(f, 'download'), + startJob(f, 'download'), + ]); + assert.equal(first.id, duplicate.id); + const ready = await finished(f.tmp); + assert.equal(ready.status, 'ready', ready.log); + assert.equal(ready.version, '2.24.0'); + assert.equal(ready.sourceCommit, sourceCommit); + assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false); + assert.equal((await startJob(f, 'download')).id, ready.id); + const applying = await startJob(f, 'apply'); + assert.notEqual(applying.id, ready.id); + const complete = await finished(f.tmp); + assert.equal(complete.status, 'completed', complete.log); + assert.equal( + await fs.readFile(path.join(f.root, 'stops'), 'utf8'), + 'stopped', + ); + assert.equal( + await fs.readFile(path.join(f.root, 'version.yaml'), 'utf8'), + 'version: 2.24.0', + ); + assert.equal( + await fs.readFile(path.join(f.root, 'node_modules/dependency'), 'utf8'), + 'installed dependency', + ); +}); + +test('invalid staged payload is rejected before spawning a restart', async (t) => { + const f = await fixture(t); + await startJob(f, 'download'); + assert.equal((await finished(f.tmp)).status, 'ready'); + const pointer = JSON.parse( + await fs.readFile(path.join(f.tmp, 'upgrade-ready-master.json'), 'utf8'), + ); + await fs.writeFile( + path.join(f.tmp, pointer.directory, 'qinglong-static-master/build/app.js'), + 'corrupted', + ); + await assert.rejects(startJob(f, 'apply'), /checksum|损坏/); + assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false); + assert.equal((await getStatus(f.tmp)).status, 'failed'); +}); + +test('failed download persists its failure without stopping the panel and can be retried', async (t) => { + const f = await fixture(t); + await startJob( + { ...f, env: { ...f.env, FAIL_DOWNLOAD: 'true' } }, + 'download', + ); + const failed = await finished(f.tmp); + assert.equal(failed.status, 'failed'); + assert.match(failed.log, /curl failed/); + assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false); + await startJob(f, 'download'); + assert.equal((await finished(f.tmp)).status, 'ready'); +}); + +test('orphaned jobs become retryable and status log reads are bounded', async (t) => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-orphan-job-')); + t.after(() => fs.rm(tmp, { recursive: true, force: true })); + await fs.writeFile( + path.join(tmp, 'panel-upgrade.json'), + JSON.stringify({ status: 'starting', startedAt: 0 }), + ); + await fs.writeFile(path.join(tmp, 'panel-upgrade.log'), 'x'.repeat(100000)); + const result = await getStatus(tmp); + assert.equal(result.status, 'failed'); + assert.equal(Buffer.byteLength(result.log), 32768); +}); + +test('a healthy backend with the wrong version rolls the update back', async (t) => { + const f = await fixture(t); + await startJob(f, 'download'); + assert.equal((await finished(f.tmp)).status, 'ready'); + f.runtime.version = '2.23.0'; + await startJob(f, 'apply'); + const failed = await finished(f.tmp); + assert.equal(failed.status, 'failed', failed.log); + assert.match(failed.error, /版本/); + assert.equal( + await fs.readFile(path.join(f.root, 'version.yaml'), 'utf8'), + 'version: 2.23.0', + ); + assert.equal( + await fs.readFile(path.join(f.root, 'starts'), 'utf8'), + 'startedstarted', + ); +}); + +for (const [name, patch] of [ + ['same version but old commit', { sourceCommit: 'b'.repeat(40) }], + ['same commit but wrong backend', { backendSha256: '0'.repeat(64) }], + ['process did not restart', { instanceId: 'before-restart' }], + ['only the HTTP worker restarted', { parentPid: 100001 }], +]) + test(`a healthy backend cannot claim update success: ${name}`, async (t) => { + const f = await fixture(t); + await startJob(f, 'download'); + assert.equal((await finished(f.tmp)).status, 'ready'); + Object.assign(f.runtime, patch); + await startJob(f, 'apply'); + const status = await finished(f.tmp); + assert.equal(status.status, 'failed', status.log); + assert.match(status.error, /版本/); + assert.equal( + await fs.readFile(path.join(f.root, 'version.yaml'), 'utf8'), + 'version: 2.23.0', + ); + }); + +for (const name of ['package.json', 'pnpm-lock.yaml', 'node_modules']) + test(`reused dependencies are checked again before stopping: ${name}`, async (t) => { + const f = await fixture(t); + await startJob(f, 'download'); + assert.equal((await finished(f.tmp)).status, 'ready'); + if (name === 'node_modules') + await fs.rm(path.join(f.root, name), { recursive: true }); + else await fs.writeFile(path.join(f.root, name), 'changed after download'); + await assert.rejects(startJob(f, 'apply'), /依赖已变化/); + assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false); + }); + +test('changed dependencies are fully staged and installed instead of reusing the current tree', async (t) => { + const f = await fixture(t); + await fs.writeFile(path.join(f.root, 'pnpm-lock.yaml'), 'old lock'); + await startJob(f, 'download'); + assert.equal((await finished(f.tmp)).status, 'ready'); + await startJob(f, 'apply'); + const status = await finished(f.tmp); + assert.equal(status.status, 'completed', status.log); + assert.equal( + await fs.readFile(path.join(f.root, 'node_modules/dependency'), 'utf8'), + 'installed', + ); + assert.equal( + await fs.readFile(path.join(f.root, 'pnpm-lock.yaml'), 'utf8'), + 'lock', + ); +}); + +async function replaceStage(f) { + const pointer = path.join(f.tmp, 'upgrade-ready-master.json'); + const selected = JSON.parse(await fs.readFile(pointer, 'utf8')); + const directory = `upgrade-${randomUUID()}`; + const workspace = path.join(f.tmp, directory); + await fs.cp(path.join(f.tmp, selected.directory), workspace, { + recursive: true, + }); + const readyFile = path.join(workspace, 'ready.json'); + const ready = JSON.parse(await fs.readFile(readyFile, 'utf8')); + ready.source = path.join(workspace, 'qinglong-master'); + ready.static = path.join(workspace, 'qinglong-static-master'); + await fs.writeFile(readyFile, JSON.stringify(ready)); + await fs.writeFile(pointer, JSON.stringify({ directory })); +} + +test('replacing even a same-commit stage requires explicit preparation again', async (t) => { + const f = await fixture(t); + await startJob(f, 'download'); + assert.equal((await finished(f.tmp)).status, 'ready'); + await replaceStage(f); + await assert.rejects(startJob(f, 'apply'), /暂存更新已变化/); + assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false); +}); + +test('the worker revalidates its accepted target under the upgrade lock', async (t) => { + const f = await fixture(t); + await startJob(f, 'download'); + const ready = await finished(f.tmp); + assert.equal(ready.status, 'ready'); + const accepted = { + ...ready, + id: randomUUID(), + status: 'starting', + action: 'apply', + }; + delete accepted.log; + await fs.writeFile( + path.join(f.tmp, 'panel-upgrade.json'), + JSON.stringify(accepted), + ); + await replaceStage(f); + await runJob(f, accepted.id); + const failed = await getStatus(f.tmp); + assert.equal(failed.status, 'failed'); + assert.match(failed.error, /暂存更新已变化/); + assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false); +}); + +test('an in-progress download cannot be reported as an accepted restart', async (t) => { + const f = await fixture(t); + await fs.mkdir(f.tmp, { recursive: true }); + await fs.writeFile( + path.join(f.tmp, 'panel-upgrade.json'), + JSON.stringify({ + id: randomUUID(), + status: 'running', + action: 'download', + pid: process.pid, + }), + ); + await assert.rejects(startJob(f, 'apply'), /已有更新/); + assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false); +}); + +async function selectCli(f) { + const cliRoot = path.join(f.base, 'selected-cli'); + const write = async (relative, contents) => { + const file = path.join(cliRoot, 'dist/internal', relative); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, contents); + }; + await write( + 'runtime/context.js', + `exports.createContext = (values, env) => ({ root: values.root, env, paths: { file_config_user: values.root + '/data/config/config.sh', dir_preload: values.root + '/shell/preload' } }); exports.sourceEnvironment = async env => env;`, + ); + await write( + 'runtime/cancellation.js', + `exports.cancellableOperation = async (signal, operation) => { signal?.throwIfAborted(); return operation(); };`, + ); + await write( + 'maintenance/operator.js', + ` +const fs = require('node:fs/promises'), path = require('node:path'); +exports.stopPanel = ctx => fs.appendFile(path.join(ctx.root, 'cli-stops'), 'stopped'); +exports.startPanel = ctx => fs.appendFile(path.join(ctx.root, 'starts'), 'started'); +exports.installPanelDependencies = async (ctx, source) => { + await fs.mkdir(path.join(source, 'node_modules'), { recursive: true }); + await fs.writeFile(path.join(source, 'node_modules/dependency'), 'selected-cli'); +};`, + ); + await write( + 'maintenance/upgradeSelection.js', + ` +const fs = require('node:fs/promises'), path = require('node:path'); +exports.prepareUpgradeSelection = async (ctx) => { + const directory = await fs.mkdtemp(path.join(ctx.root, '.tmp/selected-loader-')); + const target = path.join(ctx.root, 'static/build/loaders/deps.js'); + const source = path.join(directory, 'deps.js'); + await fs.copyFile(target, source); + return { directory, source, target }; +};`, + ); + const loader = path.join(f.root, 'static/build/loaders/deps.js'); + await fs.mkdir(path.dirname(loader), { recursive: true }); + await fs.writeFile(loader, '// selected CLI loader'); + f.env.QL_CLI_ROOT = cliRoot; + return loader; +} + +for (const fail of [false, true]) + test(`selected CLI lifecycle and loader survive panel updating; rollback=${fail}`, async (t) => { + const f = await fixture(t); + const loader = await selectCli(f); + await fs.writeFile(path.join(f.root, 'pnpm-lock.yaml'), 'old lock'); + await startJob(f, 'download'); + const ready = await finished(f.tmp); + assert.equal(ready.status, 'ready', ready.log); + if (fail) f.runtime.sourceCommit = 'b'.repeat(40); + await startJob(f, 'apply'); + const status = await finished(f.tmp); + assert.equal(status.status, fail ? 'failed' : 'completed', status.log); + assert.equal(await fs.readFile(loader, 'utf8'), '// selected CLI loader'); + assert.equal( + syncFs.existsSync(path.join(f.root, 'stops')), + false, + 'legacy broad shell stop was not used', + ); + assert.match( + await fs.readFile(path.join(f.root, 'cli-stops'), 'utf8'), + /stopped/, + ); + if (!fail) + assert.equal( + await fs.readFile(path.join(f.root, 'node_modules/dependency'), 'utf8'), + 'selected-cli', + ); + }); + +for (const knownSize of [true, false]) + test(`download progress measures real bytes and handles unknown totals; known=${knownSize}`, async (t) => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-progress-')); + t.after(() => fs.rm(tmp, { recursive: true, force: true })); + const reports = []; + await downloadWithProgress( + async (program, args) => { + assert.equal(program, 'curl'); + await fs.writeFile( + args[args.indexOf('--dump-header') + 1], + `HTTP/1.1 302 Found\r\nContent-Length: 1\r\n\r\nHTTP/2 200 OK\r\n${ + knownSize ? 'Content-Length: 100\r\n' : '' + }\r\n`, + ); + const archive = args[args.indexOf('--output') + 1]; + await fs.writeFile(archive, Buffer.alloc(50)); + await delay(1150); + await fs.writeFile(archive, Buffer.alloc(100)); + }, + path.join(tmp, 'file.zip'), + 'https://example.test/file.zip', + 'qinglong', + async (report) => reports.push(report), + ); + assert.ok(reports.some((r) => r.downloadedBytes === 50)); + assert.equal(reports[0].percent, knownSize ? 50 : null); + assert.equal(reports.at(-1).downloadedBytes, 100); + assert.equal(reports.at(-1).percent, knownSize ? 100 : null); + }); diff --git a/test/back/runtime-build.test.cjs b/test/back/runtime-build.test.cjs new file mode 100644 index 00000000..0b74450a --- /dev/null +++ b/test/back/runtime-build.test.cjs @@ -0,0 +1,71 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { createHash } = require('node:crypto'); +const ts = require('typescript'); + +function load(root) { + const module = { exports: {} }; + const output = ts.transpileModule( + fs.readFileSync('back/shared/runtimeBuild.ts', 'utf8'), + { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + }, + ).outputText; + new Function('require', 'module', 'exports', '__dirname', 'process', output)( + require, + module, + module.exports, + path.join(root, 'static/build/shared'), + { env: { QL_DIR: root } }, + ); + return module.exports.runtimeBuild; +} + +test('a running process retains its own build identity when installed files change', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-runtime-build-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + fs.mkdirSync(path.join(root, 'static/build'), { recursive: true }); + fs.writeFileSync(path.join(root, 'version.yaml'), 'version: 2.24.0'); + fs.writeFileSync(path.join(root, 'static/build/app.js'), 'old backend'); + fs.writeFileSync( + path.join(root, 'static/build-info.json'), + JSON.stringify({ sourceCommit: 'a'.repeat(40) }), + ); + const running = load(root); + fs.writeFileSync(path.join(root, 'static/build/app.js'), 'new backend'); + fs.writeFileSync( + path.join(root, 'static/build-info.json'), + JSON.stringify({ sourceCommit: 'b'.repeat(40) }), + ); + fs.writeFileSync(path.join(root, 'version.yaml'), 'version: 2.25.0'); + assert.equal(running.version, '2.24.0'); + assert.equal(running.sourceCommit, 'a'.repeat(40)); + assert.equal( + running.backendSha256, + createHash('sha256').update('old backend').digest('hex'), + ); + const restarted = load(root); + assert.equal(restarted.version, '2.25.0'); + assert.equal(restarted.sourceCommit, 'b'.repeat(40)); + assert.notEqual(restarted.backendSha256, running.backendSha256); + assert.notEqual(restarted.instanceId, running.instanceId); + assert.equal(Object.isFrozen(running), true); +}); + +test('unbuilt development environments keep health available without claiming a verified build', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-runtime-unbuilt-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + assert.equal(load(root).sourceCommit, undefined); + fs.mkdirSync(path.join(root, 'static'), { recursive: true }); + for (const content of ['broken', 'null', '42']) { + fs.writeFileSync(path.join(root, 'static/build-info.json'), content); + assert.equal(load(root).backendSha256, undefined); + } +}); diff --git a/test/back/upgrade-download-info.test.cjs b/test/back/upgrade-download-info.test.cjs new file mode 100644 index 00000000..8af2dba1 --- /dev/null +++ b/test/back/upgrade-download-info.test.cjs @@ -0,0 +1,245 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); +const { createHash } = require('node:crypto'); +const { + publishUpgradeInfo, +} = require('../../scripts/publish-upgrade-info.cjs'); +const { + stageUpgrade, + downloadWithProgress, +} = require('../../shell/upgrade.cjs'); +const { writeManifest } = require('../helpers/upgrade-fixture.cjs'); +const digest = (bytes) => createHash('sha256').update(bytes).digest('hex'); + +function fixture(t) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-download-info-')); + t.after(() => fs.rmSync(tmp, { recursive: true, force: true })); + const git = (...args) => + execFileSync('git', args, { encoding: 'utf8', stdio: 'pipe' }).trim(); + const source = path.join(tmp, 'source'), + built = path.join(tmp, 'built'), + remote = path.join(tmp, 'remote.git'); + for (const dir of [source]) { + git('init', '-b', 'develop', dir); + git('-C', dir, 'config', 'user.name', 'Fixture'); + git('-C', dir, 'config', 'user.email', 'fixture@example.invalid'); + } + fs.mkdirSync(path.join(source, 'sample')); + fs.writeFileSync(path.join(source, 'package.json'), '{"version":"2.24.0"}'); + fs.writeFileSync(path.join(source, 'pnpm-lock.yaml'), 'lock'); + fs.writeFileSync(path.join(source, 'sample/config.sample.sh'), '# config'); + git('-C', source, 'add', '.'); + git('-C', source, 'commit', '-m', 'source'); + const sourceCommit = git('-C', source, 'rev-parse', 'HEAD'); + fs.mkdirSync(path.join(built, 'build'), { recursive: true }); + fs.writeFileSync(path.join(built, 'build/app.js'), 'backend'); + writeManifest(source, built); + // Fixture helper defaults to its fixed source revision. + const manifest = JSON.parse( + fs.readFileSync(path.join(built, 'build-info.json')), + ); + manifest.sourceCommit = sourceCommit; + fs.writeFileSync( + path.join(built, 'build-info.json'), + JSON.stringify(manifest), + ); + git('init', '-b', 'develop', built); + git('-C', built, 'config', 'user.name', 'Fixture'); + git('-C', built, 'config', 'user.email', 'fixture@example.invalid'); + git('-C', built, 'add', '.'); + git('-C', built, 'commit', '-m', 'built'); + const staticCommit = git('-C', built, 'rev-parse', 'HEAD'); + git('init', '--bare', remote); + git('-C', built, 'push', remote, 'develop'); + const archives = {}; + for (const [repo, dir, commit] of [ + ['qinglong', source, sourceCommit], + ['qinglong-static', built, staticCommit], + ]) + archives[repo] = execFileSync('git', [ + '-C', + dir, + 'archive', + '--format=zip', + `--prefix=${repo}-${commit}/`, + commit, + ]); + const execute = (program, args, options) => { + if (program !== 'curl') return execFileSync(program, args, options); + const url = args.at(-1), + repo = url.includes('/qinglong-static/') ? 'qinglong-static' : 'qinglong'; + assert.ok(url.endsWith(repo === 'qinglong' ? sourceCommit : staticCommit)); + fs.writeFileSync(args[args.indexOf('--output') + 1], archives[repo]); + return ''; + }; + return { + tmp, + source, + built, + remote, + git, + sourceCommit, + staticCommit, + archives, + execute, + }; +} + +test('published archive metadata retains history and supplies percentages without Content-Length', async (t) => { + const f = fixture(t); + const info = publishUpgradeInfo( + f.remote, + 'develop', + f.sourceCommit, + f.execute, + ); + for (const [repo, bytes] of Object.entries(f.archives)) { + assert.equal(info.archives[repo].size, bytes.length); + assert.equal(info.archives[repo].sha256, digest(bytes)); + } + const stored = JSON.parse( + f.git('-C', f.remote, 'show', `upgrade-info:${f.sourceCommit}.json`), + ); + assert.deepEqual(stored, info); + const first = f.git('-C', f.remote, 'rev-parse', 'upgrade-info'); + publishUpgradeInfo(f.remote, 'develop', f.sourceCommit, f.execute); + assert.equal(f.git('-C', f.remote, 'rev-parse', 'upgrade-info^'), first); + const root = path.join(f.tmp, 'panel'), + tmp = path.join(root, '.tmp'); + fs.mkdirSync(tmp, { recursive: true }); + fs.mkdirSync(path.join(root, 'node_modules')); + for (const name of ['package.json', 'pnpm-lock.yaml']) + fs.copyFileSync(path.join(f.source, name), path.join(root, name)); + const reports = [], + urls = []; + const run = async (program, args) => { + if (program !== 'curl') + return { stdout: execFileSync(program, args, { encoding: 'utf8' }) }; + const url = args.at(-1); + urls.push(url); + const out = args[args.indexOf('--output') + 1]; + if (new URL(url).pathname.endsWith('/build-info.json')) + fs.writeFileSync(out, JSON.stringify({ sourceCommit: f.sourceCommit })); + else if (url.endsWith('.json')) fs.writeFileSync(out, JSON.stringify(info)); + else { + const repo = url.includes('/qinglong-static/') + ? 'qinglong-static' + : 'qinglong'; + fs.writeFileSync(out, f.archives[repo]); + } + fs.writeFileSync( + args[args.indexOf('--dump-header') + 1], + 'HTTP/2 200 OK\r\n\r\n', + ); + }; + await stageUpgrade({ + root, + tmp, + env: { QL_BRANCH: 'develop' }, + mirror: 'github', + run, + install: () => assert.fail('no changed dependencies'), + onProgress: async (r) => reports.push(r), + }); + assert.ok(urls.some((url) => url.endsWith(`/zip/${f.staticCommit}`))); + for (const artifact of ['qinglong', 'qinglong-static']) { + assert.ok(reports.some((r) => r.artifact === artifact && r.percent === 0)); + assert.ok( + reports.some((r) => r.artifact === artifact && r.percent === 100), + ); + } + const pointer = JSON.parse( + fs.readFileSync(path.join(tmp, 'upgrade-ready-develop.json')), + ); + assert.ok( + fs.existsSync( + path.join( + tmp, + pointer.directory, + 'qinglong-static-develop/build-info.json', + ), + ), + ); +}); + +test('publisher rejects a static artifact from a different source before publishing metadata', (t) => { + const f = fixture(t); + assert.throws( + () => + publishUpgradeInfo( + f.remote, + 'develop', + 'a'.repeat(40), + (program, args, options) => { + if (program === 'curl') { + fs.writeFileSync( + args[args.indexOf('--output') + 1], + f.archives['qinglong-static'], + ); + return ''; + } + return execFileSync(program, args, options); + }, + ), + /does not match/, + ); + assert.equal(f.git('-C', f.remote, 'branch', '--list', 'upgrade-info'), ''); +}); + +for (const mismatch of ['size', 'sha256']) + test(`archive metadata ${mismatch} mismatch never reports completion`, async (t) => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-wrong-download-')); + t.after(() => fs.rmSync(tmp, { recursive: true, force: true })); + const bytes = Buffer.alloc(100), + reports = []; + await assert.rejects( + downloadWithProgress( + async (program, args) => { + fs.writeFileSync(args[args.indexOf('--output') + 1], bytes); + }, + path.join(tmp, 'file.zip'), + 'https://example.invalid/file.zip', + 'qinglong', + async (r) => reports.push(r), + true, + { + size: mismatch === 'size' ? 101 : 100, + sha256: mismatch === 'sha256' ? '0'.repeat(64) : digest(bytes), + }, + ), + /checksum mismatch/, + ); + assert.ok(!reports.some((r) => r.percent === 100)); + }); + +test('published size gives intermediate real percentages when the response omits its length', async (t) => { + const { setTimeout: delay } = require('node:timers/promises'); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-sized-download-')); + t.after(() => fs.rmSync(tmp, { recursive: true, force: true })); + const bytes = Buffer.alloc(100), + reports = []; + await downloadWithProgress( + async (program, args) => { + const out = args[args.indexOf('--output') + 1]; + fs.writeFileSync( + args[args.indexOf('--dump-header') + 1], + 'HTTP/2 200 OK\r\n\r\n', + ); + fs.writeFileSync(out, bytes.subarray(0, 50)); + await delay(1150); + fs.writeFileSync(out, bytes); + }, + path.join(tmp, 'file.zip'), + 'https://example.invalid/file.zip', + 'qinglong', + async (r) => reports.push(r), + true, + { size: 100, sha256: digest(bytes) }, + ); + assert.ok(reports.some((r) => r.downloadedBytes === 50 && r.percent === 50)); + assert.equal(reports.at(-1).percent, 100); +}); diff --git a/test/front/update-progress.test.cjs b/test/front/update-progress.test.cjs index 1b7e2d2e..10f82e14 100644 --- a/test/front/update-progress.test.cjs +++ b/test/front/update-progress.test.cjs @@ -3,215 +3,433 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const ts = require('typescript'); -// Drive the real component's handlers and effects without a browser or network. -function panel() { - let cursor = 0, - tree, - subscriber; - const hooks = [], - effects = [], - cleanups = [], + +function fixture() { + const timers = new Map(), + responses = [], + requests = [], confirms = [], - infos = [], - timers = [], - responses = []; - const react = { - useState(initial) { - const i = cursor++; - if (!(i in hooks)) hooks[i] = initial; - return [ - hooks[i], - (v) => { - hooks[i] = typeof v === 'function' ? v(hooks[i]) : v; - }, - ]; - }, - useRef(initial) { - const i = cursor++; - return (hooks[i] ||= { current: initial }); - }, - useCallback(fn) { - cursor++; - return fn; - }, - useEffect(fn, deps) { - const i = cursor++; - if (!hooks[i] || deps.some((v, j) => !Object.is(v, hooks[i][j]))) { - hooks[i] = deps; - effects.push(fn); - } - }, + errors = [], + notifications = []; + const persistent = new Map(); + let timerId = 0, + reloads = 0, + snapshot, + fetchCount = 0; + const storage = { + getItem: (key) => persistent.get(key) || null, + setItem: (key, value) => persistent.set(key, value), + removeItem: (key) => persistent.delete(key), }; - function modal(options, list) { - const result = { - options, - update(patch) { - Object.assign(options, patch); - }, - destroy() { - options.afterClose?.(); - }, - }; - list.push(result); - return result; - } const mocks = { - '@/utils': { disableBody() {} }, - '@/utils/config': { apiPrefix: '/api/' }, - '@/utils/http': { - request: { - put: async (url) => - responses.length - ? responses.shift()(url) - : { - code: 200, - data: { hasNewVersion: false, lastVersion: '2.23.0' }, - }, - }, - }, - '@/utils/websocket': { - getInstance: () => ({ - subscribe(topic, fn) { - subscriber = fn; - }, - unsubscribe() { - subscriber = undefined; - }, - }), - }, - 'ansi-to-react': () => null, + '@/utils/config': { apiPrefix: '/panel/api/', authKey: 'token' }, antd: { - Button: 'button', - Statistic: { Countdown: 'span' }, - message: { loading() {}, destroy() {}, error() {}, success() {} }, - Modal: { - confirm: (o) => modal(o, confirms), - info: (o) => modal(o, infos), - }, + notification: { success: (options) => notifications.push(options) }, }, - react, 'react-intl-universal': { get: (key) => key }, + react: { useSyncExternalStore: (subscribe, getSnapshot) => getSnapshot() }, }; - const module = { exports: {} }; - const compiled = ts.transpileModule( - fs.readFileSync( - path.join(__dirname, '../../src/pages/setting/checkUpdate.tsx'), - 'utf8', - ), - { - compilerOptions: { - module: ts.ModuleKind.CommonJS, - target: ts.ScriptTarget.ES2020, - jsx: ts.JsxEmit.ReactJSX, - esModuleInterop: true, + function load(file, extra = {}) { + const module = { exports: {} }; + const output = ts.transpileModule( + fs.readFileSync(path.join(__dirname, '../..', file), 'utf8'), + { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + jsx: ts.JsxEmit.ReactJSX, + esModuleInterop: true, + }, }, - }, - ).outputText; - new Function( - 'require', - 'module', - 'exports', - 'setTimeout', - 'document', - compiled, - )( - (name) => mocks[name] || require(name), - module, - module.exports, - (fn) => { - timers.push(fn); - }, - { querySelector: () => null }, - ); - function render() { - cursor = 0; - tree = module.exports.default({ systemInfo: { version: '2.23.0' } }); - while (effects.length) { - const cleanup = effects.shift()(); - if (cleanup) cleanups.push(cleanup); + ).outputText; + new Function( + 'require', + 'module', + 'exports', + 'setTimeout', + 'clearTimeout', + 'fetch', + 'localStorage', + 'sessionStorage', + 'window', + output, + )( + (name) => extra[name] || mocks[name] || require(name), + module, + module.exports, + (fn, ms) => { + const id = ++timerId; + timers.set(id, { fn, ms }); + return id; + }, + (id) => timers.delete(id), + async (url, options) => { + fetchCount++; + requests.push({ url, options }); + const next = responses.shift(); + if (typeof next === 'function') return next(options); + if (next instanceof Error) throw next; + return { + ok: true, + json: async () => ({ code: 200, data: next || { status: 'idle' } }), + }; + }, + storage, + storage, + { location: { reload: () => reloads++ } }, + ); + return module.exports; + } + const hooks = load('src/hooks/useUpgrade.ts'); + const store = hooks.upgradeStore; + function component(file) { + let cursor = 0; + const values = []; + const Modal = () => null; + Modal.confirm = (options) => { + confirms.push(options); + return { destroy() {} }; + }; + const Component = load(file, { + '@/hooks/useUpgrade': { + __esModule: true, + ...hooks, + default: () => store.getSnapshot(), + }, + '@/utils/http': { + request: { + put: async (url) => { + requests.push({ url }); + if (url.endsWith('update-check')) + return { + code: 200, + data: { + hasNewVersion: true, + lastVersion: '2.24.0', + lastLog: 'Notes', + }, + }; + const result = responses.shift(); + if (result instanceof Error) throw result; + return ( + result || { + code: 200, + data: { + id: 'apply', + status: 'starting', + action: 'apply', + phase: 'verifying', + }, + } + ); + }, + }, + }, + '@/pages/setting/checkUpdate': () => null, + '@ant-design/icons': { CloudDownloadOutlined: 'DownloadIcon' }, + 'ansi-to-react': 'Ansi', + 'react-intl-universal': { get: (key) => key }, + antd: { + Alert: 'Alert', + Button: 'Button', + Modal, + Progress: 'Progress', + Space: 'Space', + Tooltip: 'Tooltip', + Typography: { Text: 'Text' }, + message: { error: (e) => errors.push(e), success() {} }, + }, + react: { + useState(initial) { + const index = cursor++; + if (!(index in values)) values[index] = initial; + return [values[index], (v) => (values[index] = v)]; + }, + }, + }).default; + let tree; + function render(props = {}) { + cursor = 0; + tree = Component({ systemInfo: { version: '2.23.0' }, ...props }); + return tree; } + function nodes(type) { + const found = []; + const visit = (item) => { + if (!item || typeof item !== 'object') return; + if (Array.isArray(item)) return item.forEach(visit); + if (item.type === type) found.push(item); + visit(item.props?.children); + visit(item.props?.description); + }; + visit(tree); + return found; + } + return { render, nodes, Modal }; } - async function settle() { - await new Promise(setImmediate); - render(); - } - render(); return { - confirms, - infos, + store, + hooks, responses, + requests, + confirms, + errors, + notifications, + component, + storage, timers, - settle, - async start() { - tree.props.children[0].props.onClick(); - await settle(); - confirms.at(-1).options.onOk(); - await settle(); - return infos.at(-1); + get fetchCount() { + return fetchCount; }, - async send(payload) { - subscriber?.(payload); - await settle(); + get reloads() { + return reloads; }, - close() { - infos.at(-1).destroy(); + async settle() { + await new Promise(setImmediate); + snapshot = store.getSnapshot(); + return snapshot; }, - unmount() { - cleanups.forEach((fn) => fn()); + async tick(ms = 1000) { + const entry = [...timers].find(([, v]) => v.ms === ms); + assert.ok(entry, `timer ${ms} exists`); + timers.delete(entry[0]); + entry[1].fn(); + return this.settle(); }, }; } -for (const logged of [false, true]) - test(`failed final update event unlocks the modal without an additional log; logged=${logged}`, async () => { - const p = panel(), - m = await p.start(); - if (logged) await p.send({ message: 'download failed\n' }); - assert.equal(m.options.okButtonProps.disabled, true); - await p.send({ message: '', status: 'failed' }); - assert.equal(m.options.okButtonProps.disabled, false); - assert.equal(m.options.closable, true); + +test('sidebar and settings share one poller; mounting restores progress and the last unsubscribe stops it', async () => { + const f = fixture(); + f.storage.setItem('token', 'secret'); + f.responses.push({ status: 'running', phase: 'downloading', percent: 40 }); + const off1 = f.store.subscribe(() => {}), + off2 = f.store.subscribe(() => {}); + assert.equal((await f.settle()).job.percent, 40); + assert.equal(f.fetchCount, 1); + assert.equal(f.requests[0].url, '/panel/api/system/update-status'); + assert.equal(f.requests[0].options.headers.Authorization, 'Bearer secret'); + off1(); + f.responses.push({ status: 'ready' }); + assert.equal((await f.tick()).job.status, 'ready'); + off2(); + assert.equal(f.timers.size, 0); +}); + +test('polling reconnects after downtime and recovers ready without forcing a restart', async () => { + const f = fixture(); + f.responses.push(new Error('offline')); + const off = f.store.subscribe(() => {}); + assert.equal((await f.settle()).connected, false); + f.responses.push({ status: 'ready', version: '2.24.0' }); + const recovered = await f.tick(); + assert.equal(recovered.connected, true); + assert.equal(recovered.job.status, 'ready'); + assert.equal(f.reloads, 0); + off(); +}); + +test('a timed out status request is aborted and polling continues', async () => { + const f = fixture(); + f.responses.push( + ({ signal }) => + new Promise((resolve, reject) => + signal.addEventListener('abort', () => reject(new Error('aborted'))), + ), + ); + const off = f.store.subscribe(() => {}); + assert.equal((await f.tick(5000)).connected, false); + assert.equal((await f.tick()).connected, true); + off(); +}); + +test('stale poll responses cannot overwrite a newly accepted update job', async () => { + const f = fixture(); + let release; + f.responses.push(() => new Promise((resolve) => (release = resolve))); + const off = f.store.subscribe(() => {}); + f.store.beginMutation(); + f.store.setJob({ status: 'starting', id: 'new' }); + f.store.finishMutation(); + release({ + ok: true, + json: async () => ({ code: 200, data: { status: 'idle' } }), }); -for (const rejected of [false, true]) - test(`update start request failure unlocks modal; rejected=${rejected}`, async () => { - const p = panel(); - p.responses.push( - async () => ({ code: 200, data: { hasNewVersion: false } }), - async () => { - if (rejected) throw new Error('offline'); - return { code: 400, message: 'busy' }; - }, - ); - const m = await p.start(); - assert.equal(m.options.okButtonProps.disabled, false); - assert.equal(m.options.closable, true); + assert.equal((await f.settle()).job.id, 'new'); + off(); +}); + +test('completed application reloads the initiating tab once, including after a page refresh', async () => { + const f = fixture(); + f.storage.setItem(f.hooks.reloadKey, 'update-1'); + f.responses.push({ id: 'update-1', status: 'completed' }); + const off = f.store.subscribe(() => {}); + await f.settle(); + assert.equal(f.reloads, 1); + f.responses.push({ id: 'update-1', status: 'completed' }); + await f.tick(15000); + assert.equal(f.reloads, 1); + off(); +}); + +test('download is nonblocking, progress survives closing the log, and ready exposes explicit apply', async () => { + const f = fixture(), + p = f.component('src/pages/setting/checkUpdate.tsx'); + f.store.setJob({ status: 'idle' }); + p.render(); + await p.nodes('Button')[0].props.onClick(); + await f.confirms.at(-1).onOk(); + f.store.setJob({ + status: 'running', + phase: 'downloading', + percent: 35, + downloadedBytes: 35, + totalBytes: 100, }); -test('retry clears the previous failure and ignores notifications without an active modal', async () => { - const p = panel(); - await p.send({ status: 'success' }); - assert.equal(p.timers.length, 0); - await p.start(); - await p.send({ status: 'failed', message: '' }); - p.close(); - const retry = await p.start(); - assert.equal(retry.options.okButtonProps.disabled, true); - assert.equal(retry.options.closable, false); + p.render(); + assert.equal(p.nodes('Progress')[0].props.percent, 35); + assert.deepEqual( + p.nodes('Button').map((node) => node.props.children), + ['查看更新日志'], + ); + p.nodes('Button').at(-1).props.onClick(); + p.render(); + p.nodes(p.Modal)[0].props.onCancel(); + p.render(); + assert.equal(p.nodes(p.Modal)[0].props.open, false); + f.store.setJob({ status: 'restarting', phase: 'restarting' }); + p.render(); + assert.deepEqual( + p.nodes('Button').map((node) => node.props.children), + ['查看更新日志'], + ); + f.store.setJob({ status: 'ready' }); + p.render(); + p.nodes('Button')[0].props.onClick(); + assert.equal(f.confirms.at(-1).okText, '重启并更新'); + await f.confirms.at(-1).onOk(); + assert.ok(f.requests.some((r) => r.url.endsWith('update/system'))); + assert.equal(f.storage.getItem(f.hooks.reloadKey), 'apply'); }); -test('successful completion prompts restart once and cancel leaves the log dismissible', async () => { - const p = panel(), - m = await p.start(); - await p.send({ status: 'success', message: '' }); - await p.send({ status: 'success', message: '' }); - p.timers.splice(0).forEach((fn) => fn()); - assert.equal(p.confirms.length, 2); - p.confirms.at(-1).options.onCancel(); - assert.equal(m.options.okButtonProps.disabled, false); - assert.equal(m.options.closable, true); + +test('start failure leaves retry enabled and unknown file size has no fake percentage', async () => { + const f = fixture(), + p = f.component('src/pages/setting/checkUpdate.tsx'); + f.store.setJob({ status: 'idle' }); + p.render(); + await p.nodes('Button')[0].props.onClick(); + f.responses.push(new Error('offline')); + await f.confirms.at(-1).onOk(); + assert.equal(f.store.getSnapshot().busy, false); + assert.equal(f.errors.length, 1); + f.store.setJob({ status: 'running', phase: 'downloading', percent: null }); + p.render(); + assert.equal(p.nodes('Progress')[0].props.showInfo, false); }); -test('unmount removes the modal and suppresses a pending restart prompt', async () => { - const p = panel(); - await p.start(); - await p.send({ status: 'success' }); - p.unmount(); - p.timers.splice(0).forEach((fn) => fn()); - assert.equal(p.confirms.length, 1); + +test('the persistent yellow sidebar entry shows live progress and remains accessible when collapsed', () => { + const f = fixture(), + p = f.component('src/components/UpgradeNotice.tsx'); + f.store.setJob({ status: 'running', phase: 'downloading', percent: 62 }); + p.render({ collapsed: false }); + assert.match(p.nodes('button')[0].props['aria-label'], /62%/); + p.nodes('button')[0].props.onClick(); + p.render({ collapsed: true }); + assert.match(p.nodes('button')[0].props.className, /collapsed/); + assert.equal(p.nodes(p.Modal)[0].props.open, true); + f.store.setJob({ status: 'ready', phase: 'ready' }); + p.render(); + assert.equal(p.nodes('button')[0].props['aria-label'], '更新已就绪'); +}); + +test('completed update refreshes once and reminds once after reconnecting the refreshed page', async () => { + const f = fixture(); + f.storage.setItem(f.hooks.reloadKey, 'job'); + f.responses.push({ id: 'job', status: 'completed', phase: 'completed' }); + const off = f.store.subscribe(() => {}); + await f.settle(); + assert.equal(f.reloads, 1); + assert.equal(f.notifications.length, 0); + assert.equal(f.storage.getItem(f.hooks.completionKey), 'job'); + off(); + f.responses.push({ id: 'job', status: 'completed', phase: 'completed' }); + const off2 = f.store.subscribe(() => {}); + await f.settle(); + assert.equal(f.notifications.length, 1); + assert.equal(f.storage.getItem(f.hooks.completionKey), null); + f.responses.push({ id: 'job', status: 'completed', phase: 'completed' }); + await f.tick(15000); + assert.equal(f.notifications.length, 1); + off2(); +}); + +test('completed sidebar disappears while an open result dialog and completed history remain available', () => { + const f = fixture(), + p = f.component('src/components/UpgradeNotice.tsx'); + f.store.setJob({ status: 'running', phase: 'downloading', percent: 12 }); + p.render(); + p.nodes('button')[0].props.onClick(); + f.store.setJob({ + id: 'done', + status: 'completed', + phase: 'completed', + log: 'verified', + }); + p.render({ collapsed: false }); + assert.equal(p.nodes('button').length, 0); + assert.equal(p.nodes(p.Modal)[0].props.open, true); + assert.equal(f.store.getSnapshot().job.status, 'completed'); + assert.equal(f.store.getSnapshot().job.log, 'verified'); + p.nodes(p.Modal)[0].props.onCancel(); + assert.equal(p.render({ collapsed: true }), null); +}); + +test('sidebar only shows updates that are in progress, ready or failed', () => { + const f = fixture(), + p = f.component('src/components/UpgradeNotice.tsx'); + for (const status of ['loading', 'idle', 'completed']) { + f.store.setJob({ status, phase: status }); + assert.equal(p.render(), null); + } + for (const status of [ + 'starting', + 'running', + 'restarting', + 'ready', + 'failed', + ]) { + f.store.setJob({ + status, + phase: status === 'running' ? 'downloading' : status, + percent: 12, + }); + p.render({ collapsed: true }); + assert.equal(p.nodes('button').length, 1); + } +}); + +test('update modal uses plain aligned content and explains restart state', () => { + const f = fixture(), + p = f.component('src/pages/setting/checkUpdate.tsx'); + f.store.setJob({ status: 'ready', phase: 'ready' }); + p.render(); + assert.equal(p.nodes('Alert').length, 0); + assert.ok( + p + .nodes('Text') + .some((n) => n.props.children.includes?.('下载完成,等待重启')), + ); + f.store.setJob({ status: 'completed', phase: 'completed' }); + p.render(); + assert.deepEqual( + p.nodes('Button').map((n) => n.props.children), + ['检查更新', '查看更新日志'], + ); + assert.ok( + p + .nodes('Text') + .some( + (n) => n.props.children === '已重新启动并运行新版本,无需再次重启。', + ), + ); });