diff --git a/build/TianShanOS.bin b/build/TianShanOS.bin index 86014c9..a838615 100644 Binary files a/build/TianShanOS.bin and b/build/TianShanOS.bin differ diff --git a/build/bootloader/bootloader.bin b/build/bootloader/bootloader.bin index cc201e2..2437942 100644 Binary files a/build/bootloader/bootloader.bin and b/build/bootloader/bootloader.bin differ diff --git a/build/firmware-manifest.json b/build/firmware-manifest.json index 3108017..4d313cc 100644 --- a/build/firmware-manifest.json +++ b/build/firmware-manifest.json @@ -1,17 +1,17 @@ { "version": "0.6.2", - "build_version": "0.6.2+774c3a97.10130158", + "build_version": "0.6.2+028e6330.10132311", "idf_version": "5.5.2", - "source_base_commit": "774c3a975311604fe629c34ed6600d7fcebcc93b", - "source_inputs_sha256": "8a4a1fdf9f1f6bd492866af1d88620502e2965ce31bdb512798196d5dc8e3836", + "source_base_commit": "028e63309255045957e86fcb73cc328cf5d95997", + "source_inputs_sha256": "a0c0f9b6b138c17f4cedeb3d902135882c9e28204987ec6338f7ece0459aac0e", "files": { "TianShanOS.bin": { "bytes": 2226000, - "sha256": "064564898345b1bc2adaac04650fe5290a74a0ef5e5c1d1953d0c5d7a06ab17f" + "sha256": "f6e9f7ca1c22dd6aeaabfa6a36c0c38511ef013dbde999596c150dcbc20e382f" }, "www.bin": { "bytes": 3145728, - "sha256": "a33d3be59eb2dd293735c633052e53aa14fd0cddc62f621a49326f0056c23a3b" + "sha256": "3fbbd6a641be069593e00f3cebf4034feefb0fc5322f41c3145accbeb2d996b5" }, "ota_data_initial.bin": { "bytes": 8192, @@ -23,7 +23,7 @@ }, "bootloader/bootloader.bin": { "bytes": 22496, - "sha256": "106e473278fbefdce277341a2e77a097deea877ccc525ec2b7623a650397b851" + "sha256": "3f2254ead6037716cf2c7d2b6ac9ed8414b87779ea64ee52c63282274fbf0f24" }, "partition_table/partition-table.bin": { "bytes": 3072, diff --git a/build/www.bin b/build/www.bin index 0280142..7091518 100644 Binary files a/build/www.bin and b/build/www.bin differ diff --git a/components/ts_webui/web/js/app.js b/components/ts_webui/web/js/app.js index ddd0f96..ababb77 100644 --- a/components/ts_webui/web/js/app.js +++ b/components/ts_webui/web/js/app.js @@ -13165,6 +13165,51 @@ let otaStep = 'idle'; // 'idle' | 'app' | 'www' let wwwOtaEnabled = true; // 是否启用 WebUI 升级 let sdcardOtaSource = ''; // SD卡升级时的文件路径,用于推导 www.bin 路径 let sdcardWwwSource = ''; // SD卡升级时实际匹配到的 www.bin 路径 +// Keep the last attempt until a new one starts, so late replies cannot belong to +// a later upgrade (or overwrite a finished upgrade's outcome). +let otaUpgrade = null; +let otaProgressRequest = null; + +function beginOtaUpgrade(mode) { + if (otaUpgrade && otaUpgrade.phase !== 'done') { + showToast(t('promptRepair.otaBusy'), 'warning'); + return null; + } + clearInterval(refreshInterval); + refreshInterval = null; + clearInterval(rebootCheckInterval); + rebootCheckInterval = null; + otaStep = 'idle'; + sdcardOtaSource = ''; + sdcardWwwSource = ''; + otaUpgrade = { mode, phase: 'app', firmwareWritten: false, target: null }; + return otaUpgrade; +} + +function finishOtaUpgrade(upgrade) { + if (otaUpgrade !== upgrade) return; + clearInterval(refreshInterval); + refreshInterval = null; + clearInterval(rebootCheckInterval); + rebootCheckInterval = null; + upgrade.phase = 'done'; + otaStep = 'idle'; +} + +function showOtaUpgradeFailure(upgrade, spec, title = '') { + if (otaUpgrade !== upgrade || upgrade.phase === 'done') return; + const failure = reportFailure(spec); + const www = upgrade.firmwareWritten && (spec.op === 'ota.upgradewww' || spec.op === 'ota.startwww'); + const stateEl = document.getElementById('ota-state-text'); + if (stateEl) stateEl.textContent = title || (failure.uncertain + ? (www ? t('failure.ops.ota.upgradewww.unknown') : t('failure.ops.ota.upgrade.unknown')) + : (www ? t('failure.ops.ota.upgradewww.failed') : t('ui.upgradeFailed'))); + const messageEl = document.getElementById('ota-message'); + if (messageEl) messageEl.textContent = failure.message; + const abortBtn = document.getElementById('ota-abort-btn'); + if (abortBtn) abortBtn.style.display = 'none'; + finishOtaUpgrade(upgrade); +} function inferSdcardWwwPath(source) { if (!source) return ''; @@ -13235,20 +13280,28 @@ async function resolveSdcardOtaPaths(input, includeWww) { async function refreshOtaProgress() { const pageCurrent = capturePageValidity(); + const upgrade = otaUpgrade; + const step = otaStep; + const phase = upgrade?.phase; + if (upgrade?.mode === 'proxy' || phase === 'reboot' || phase === 'aborting' || phase === 'done') return; + if (otaProgressRequest?.upgrade === upgrade && otaProgressRequest.step === step) return; + const request = { upgrade, step }; + otaProgressRequest = request; + const current = () => pageCurrent() && otaUpgrade === upgrade && otaStep === step && upgrade?.phase === phase; try { // 根据当前步骤获取不同的进度 let result; if (otaStep === 'www') { result = await api.call('ota.www.progress'); - if (!pageCurrent()) return; } else { result = await api.call('ota.progress'); - if (!pageCurrent()) return; } + if (!current()) return; if (result.code === 0 && result.data) { const data = result.data; const state = data.state || 'idle'; + const completed = state === 'pending_reboot' || state === 'completed'; const percent = data.percent || 0; const received = data.received_size || data.received || 0; const total = data.total_size || data.total || 0; @@ -13296,7 +13349,8 @@ async function refreshOtaProgress() { } // 处理 App OTA 完成 - 开始 WWW OTA - if (otaStep === 'app' && (state === 'pending_reboot' || state === 'completed') && wwwOtaEnabled) { + if (upgrade?.mode === 'device' && otaStep === 'app' && completed && wwwOtaEnabled) { + upgrade.firmwareWritten = true; stateEl.textContent = typeof t === 'function' ? t('ui.firmwareUpgradeComplete') : '固件升级完成,准备升级 WebUI...'; await startWwwOta(); if (!pageCurrent()) return; @@ -13304,37 +13358,14 @@ async function refreshOtaProgress() { } // 处理 WWW OTA 完成或 App OTA 完成(无 www 升级) - if ((otaStep === 'www' && (state === 'pending_reboot' || state === 'completed')) || - (otaStep === 'app' && (state === 'pending_reboot' || state === 'completed') && !wwwOtaEnabled)) { - clearInterval(refreshInterval); - refreshInterval = null; - otaStep = 'idle'; - - // 显示重启倒计时 - stateEl.textContent = typeof t === 'function' ? t('ui.allUpgradeComplete') : '全部升级完成'; - document.getElementById('ota-message').innerHTML = ` -
-

${t('otaPage.upgradeCompleteRebooting')}

-

${t('otaPage.triggeringReboot')}

-
- `; - - // 触发设备重启 - try { - requireApiSuccess(await api.call('system.reboot', { delay: 1 }), 'call'); - if (!pageCurrent()) return; - } catch (e) { - if (!pageCurrent()) return; - showToast(t('promptRepair.rebootUnknown'), 'warning', 10000); - } - - // 开始检测设备重启 - startRebootDetection(); + if (upgrade?.mode === 'device' && completed && ((otaStep === 'www' && data.running !== true) || + (otaStep === 'app' && !wwwOtaEnabled))) { + upgrade.firmwareWritten = true; + await completeOtaUpgrade(upgrade); } else if (state === 'error') { - reportFailure({ op: 'ota.upgrade', token: 'ota_failed', result: { code: 7, data: { detail: message } }, endpoint: 'ota.progress', write: true }); - clearInterval(refreshInterval); - refreshInterval = null; - otaStep = 'idle'; + const failure = { op: step === 'www' ? 'ota.upgradewww' : 'ota.upgrade', token: 'ota_failed', result: { code: 7, data: { detail: message } }, endpoint: step === 'www' ? 'ota.www.progress' : 'ota.progress', write: true }; + if (upgrade) showOtaUpgradeFailure(upgrade, failure); + else reportFailure(failure); } } else { // 如果 app OTA 是 idle 但我们在 www 步骤,检查 www 进度 @@ -13344,13 +13375,17 @@ async function refreshOtaProgress() { } } } catch (error) { - if (!pageCurrent()) return; + if (!current()) return; console.error('Failed to get OTA status:', error); + } finally { + if (otaProgressRequest === request) otaProgressRequest = null; } } // 启动 WWW OTA(第二步) async function startWwwOta() { + let upgrade = otaUpgrade?.phase !== 'done' ? otaUpgrade : null; + if (upgrade && (upgrade.mode !== 'device' || upgrade.phase !== 'app')) return; try { let wwwSource = ''; let isFromSdcard = false; @@ -13386,6 +13421,19 @@ async function startWwwOta() { sdcardWwwSource = ''; return; } + + // A retry keeps the previously selected source, but owns a new attempt. + if (!upgrade) { + const firmwareSource = sdcardOtaSource; + upgrade = beginOtaUpgrade('device'); + if (!upgrade) return; + if (isFromSdcard) { + sdcardOtaSource = firmwareSource; + sdcardWwwSource = wwwSource; + } + } + upgrade.firmwareWritten = true; + upgrade.phase = 'www'; otaStep = 'www'; @@ -13408,14 +13456,10 @@ async function startWwwOta() { skip_verify: skipVerify }); } + if (otaUpgrade !== upgrade || upgrade.phase !== 'www') return; if (result.code !== 0) { - reportFailure({ op: 'ota.startwww', result, endpoint: 'ota.www.start', write: true }); - otaStep = 'idle'; - clearInterval(refreshInterval); - refreshInterval = null; - - document.getElementById('ota-state-text').textContent = typeof t === 'function' ? t('otaPage.stateError') : '错误'; + showOtaUpgradeFailure(upgrade, { op: 'ota.startwww', result, endpoint: isFromSdcard ? 'ota.www.start_sdcard' : 'ota.www.start', write: true }); document.getElementById('ota-message').innerHTML = `

${t('promptRepair.wwwPartial')}

@@ -13435,10 +13479,7 @@ async function startWwwOta() { await refreshOtaProgress(); } catch (error) { console.error('Failed to start WWW OTA:', error); - otaStep = 'idle'; - document.getElementById('ota-state-text').textContent = typeof t === 'function' ? t('otaPage.stateError') : '错误'; - document.getElementById('ota-message').textContent = failureText({ op: 'ota.startwww', error, endpoint: 'ota.www.start', write: true }); - document.getElementById('ota-abort-btn').style.display = 'none'; + if (upgrade) showOtaUpgradeFailure(upgrade, { op: 'ota.startwww', error, endpoint: 'ota.www.start', write: true }); } } @@ -13446,65 +13487,119 @@ async function startWwwOta() { let rebootCheckInterval = null; let rebootStartTime = null; -function startRebootDetection() { +async function completeOtaUpgrade(upgrade) { + if (otaUpgrade !== upgrade || upgrade.phase === 'reboot' || upgrade.phase === 'done') return; + clearInterval(refreshInterval); + refreshInterval = null; + otaStep = 'idle'; + upgrade.phase = 'reboot'; + + // The written inactive partition is the target for every source, including + // local files, downgrades and same-version reflashes. No server metadata is needed. + try { + const result = requireApiSuccess(await api.call('ota.partitions'), 'ota.partitions'); + if (otaUpgrade !== upgrade || upgrade.phase !== 'reboot') return; + const next = result.data?.next; + if (!next?.is_bootable || !next.version || !next.label || next.label === result.data?.running?.label) { + throw pageFailure('ota_target_unavailable'); + } + upgrade.target = { label: next.label, version: next.version, project: next.project }; + } catch (error) { + showOtaUpgradeFailure(upgrade, { op: 'ota.verify', error, endpoint: 'ota.partitions', write: false }, t('promptRepair.otaWrittenNoReboot')); + return; + } + + const stateEl = document.getElementById('ota-state-text'); + if (stateEl) stateEl.textContent = t('promptRepair.otaConfirming'); + const progressBar = document.getElementById('ota-progress-bar'); + if (progressBar) progressBar.style.width = '100%'; + const abortBtn = document.getElementById('ota-abort-btn'); + if (abortBtn) abortBtn.style.display = 'none'; + const messageEl = document.getElementById('ota-message'); + if (messageEl) messageEl.innerHTML = ` +
+

${t('promptRepair.upgradeWritten')}

+

${t('otaPage.triggeringReboot')}

+
+ `; + + try { + // system.reboot's delay is in milliseconds. + requireApiSuccess(await api.call('system.reboot', { delay: 1000 }), 'system.reboot'); + } catch (error) { + if (otaUpgrade !== upgrade || upgrade.phase !== 'reboot') return; + const failure = { op: 'device.reboot', error, endpoint: 'system.reboot', write: true }; + if (!describeFailure(failure).uncertain) { + showOtaUpgradeFailure(upgrade, failure, t('promptRepair.otaRebootRefused')); + return; + } + showToast(t('promptRepair.rebootUnknown'), 'warning', 10000); + } + if (otaUpgrade === upgrade && upgrade.phase === 'reboot') startRebootDetection(upgrade); +} + +function startRebootDetection(upgrade) { + clearInterval(rebootCheckInterval); rebootStartTime = Date.now(); - let checkCount = 0; + let checking = false; + let lastRunning = null; + const current = () => otaUpgrade === upgrade && upgrade.phase === 'reboot'; // 每 2 秒检测一次设备是否恢复 rebootCheckInterval = setInterval(async () => { - checkCount++; + if (!current()) return; const elapsed = Math.floor((Date.now() - rebootStartTime) / 1000); const countdownEl = document.getElementById('reboot-countdown'); - + if (elapsed >= 120) { + const detail = t('promptRepair.otaTargetDetails', { expected: upgrade.target.version, actual: lastRunning?.version || t('common.unknown') }); + showOtaUpgradeFailure(upgrade, { op: 'ota.verify', token: 'result_unknown', endpoint: 'ota.partitions', write: true }, t('promptRepair.otaRebootUnconfirmed')); + const messageEl = document.getElementById('ota-message'); + if (messageEl) messageEl.innerHTML = ` + ${t('otaPage.waitTimeout')} +
${escapeHtml(detail)} +
+ `; + return; + } + if (checking) return; if (countdownEl) { countdownEl.textContent = typeof t === 'function' ? t('ui.waitedSeconds', { elapsed }) : `已等待 ${elapsed} 秒...`; } - + checking = true; try { - // 尝试连接设备 - const result = await api.call('ota.version'); - if (result.code === 0) { - // 设备恢复了! - clearInterval(rebootCheckInterval); - rebootCheckInterval = null; - - const newVersion = result.data?.version || t('common.unknown'); - - if (countdownEl) { - countdownEl.innerHTML = ` - ${t('otaPage.deviceRecovered')} -
${t('ota.currentVersion')}: ${newVersion} - `; - } - - showToast(typeof t === 'function' ? t('toast.otaUpgradeSuccess', { version: newVersion }) : `OTA 升级成功!当前版本: ${newVersion}`, 'success'); - - // 3 秒后刷新页面 - setTimeout(() => { - window.location.reload(); - }, 3000); - } - } catch (e) { - // 设备还在重启,继续等待 - if (checkCount > 60) { - // 超过 2 分钟,提示用户手动检查 - clearInterval(rebootCheckInterval); - rebootCheckInterval = null; - - if (countdownEl) { - countdownEl.innerHTML = ` - ${typeof t === 'function' ? t('otaPage.waitTimeout') : '等待超时'} -
${typeof t === 'function' ? t('otaPage.checkDeviceManually') : '请手动检查设备状态并刷新页面'} -
- `; - } + const result = requireApiSuccess(await api.call('ota.partitions'), 'ota.partitions'); + if (!current()) return; + const running = result.data?.running; + lastRunning = running || null; + // The partition change proves a reboot even if the disconnected + // interval was missed, or the uploaded image has the same version. + if (running?.label !== upgrade.target.label) return; + if (running.version !== upgrade.target.version || running.project !== upgrade.target.project) { + showOtaUpgradeFailure(upgrade, { op: 'ota.verify', token: 'ota_target_mismatch', params: { expected: upgrade.target.version, actual: running.version || t('common.unknown') }, endpoint: 'ota.partitions', write: false }, t('promptRepair.otaRebootMismatch')); + return; } + if (countdownEl) countdownEl.innerHTML = ` + ${t('otaPage.deviceRecovered')} +
${t('ota.currentVersion')}: ${escapeHtml(running.version)} + `; + const stateEl = document.getElementById('ota-state-text'); + if (stateEl) stateEl.textContent = t('ui.allUpgradeComplete'); + showToast(t('toast.otaUpgradeSuccess', { version: running.version }), 'success', 3000, { isCurrent: current }); + finishOtaUpgrade(upgrade); + setTimeout(() => { + if (otaUpgrade === upgrade) window.location.reload(); + }, 3000); + } catch (error) { + // An unavailable device during reboot is expected. The elapsed-time + // deadline also covers readable old firmware and malformed replies. + } finally { + checking = false; } }, 2000); } async function otaFromUrl() { + const pageCurrent = capturePageValidity(); const url = document.getElementById('ota-url-input').value.trim(); if (!url) { showToast(typeof t === 'function' ? t('toast.enterFirmwareUrl') : '请输入固件 URL', 'error'); @@ -13525,6 +13620,8 @@ async function otaFromUrl() { no_reboot: true, // 不自动重启,由前端控制流程 skip_verify: skipVerify }; + const upgrade = beginOtaUpgrade('device'); + if (!upgrade) return; // 设置 OTA 步骤 otaStep = 'app'; @@ -13543,9 +13640,12 @@ async function otaFromUrl() { try { showToast(typeof t === 'function' ? t('toast.twoStepUpgrade') : '开始两步升级:固件 + WebUI', 'info'); const result = await api.call('ota.upgrade_url', params); + if (otaUpgrade !== upgrade || upgrade.phase !== 'app') return; if (result.code === 0) { showToast(typeof t === 'function' ? t('toast.firmwareUpgradeStarted') : '固件升级已启动', 'success'); + // The device task survives navigation; the next OTA page resumes polling. + if (!pageCurrent()) return; document.getElementById('ota-state-text').textContent = typeof t === 'function' ? t('otaPage.downloading') : '下载中...'; // 开始刷新进度 if (!refreshInterval) { @@ -13554,21 +13654,15 @@ async function otaFromUrl() { // 立即刷新一次 await refreshOtaProgress(); } else { - reportFailure({ op: 'ota.start', result, endpoint: 'ota.upgrade_url', write: true }); - // 显示错误状态 - document.getElementById('ota-state-text').textContent = typeof t === 'function' ? t('otaPage.stateError') : '错误'; - document.getElementById('ota-message').textContent = failureText({ op: 'ota.start', result, endpoint: 'ota.upgrade_url', write: true }); - document.getElementById('ota-abort-btn').style.display = 'none'; + showOtaUpgradeFailure(upgrade, { op: 'ota.start', result, endpoint: 'ota.upgrade_url', write: true }); } } catch (error) { - reportFailure({ op: 'ota.start', error, endpoint: 'ota.upgrade_url', write: true }); - document.getElementById('ota-state-text').textContent = typeof t === 'function' ? t('otaPage.stateError') : '错误'; - document.getElementById('ota-message').textContent = failureText({ op: 'ota.start', error, endpoint: 'ota.upgrade_url', write: true }); - document.getElementById('ota-abort-btn').style.display = 'none'; + showOtaUpgradeFailure(upgrade, { op: 'ota.start', error, endpoint: 'ota.upgrade_url', write: true }); } } async function otaFromFile() { + const pageCurrent = capturePageValidity(); const inputPath = document.getElementById('ota-file-input').value.trim(); if (!inputPath) { showToast(typeof t === 'function' ? t('toast.enterFilePath') : '请输入文件路径', 'error'); @@ -13581,14 +13675,17 @@ async function otaFromFile() { try { paths = await resolveSdcardOtaPaths(inputPath, includeWww); } catch (error) { - reportFailure({ op: 'ota.path', error, write: false }); + if (pageCurrent()) reportFailure({ op: 'ota.path', error, write: false }); return; } + if (!pageCurrent()) return; const params = { file: paths.firmware, no_reboot: true // 不自动重启,由前端控制流程 }; + const upgrade = beginOtaUpgrade('device'); + if (!upgrade) return; // 设置 OTA 步骤 otaStep = 'app'; @@ -13610,9 +13707,11 @@ async function otaFromFile() { try { showToast(typeof t === 'function' ? t('toast.startingFileUpgrade') : '开始从文件升级固件...', 'info'); const result = await api.call('ota.upgrade_file', params); + if (otaUpgrade !== upgrade || upgrade.phase !== 'app') return; if (result.code === 0) { showToast(typeof t === 'function' ? t('toast.firmwareUpgradeStarted') : '固件升级已启动', 'success'); + if (!pageCurrent()) return; document.getElementById('ota-state-text').textContent = typeof t === 'function' ? t('otaPage.writing') : '写入中...'; // 开始刷新进度 if (!refreshInterval) { @@ -13620,16 +13719,10 @@ async function otaFromFile() { } await refreshOtaProgress(); } else { - reportFailure({ op: 'ota.start', result, endpoint: 'ota.upgrade_file', write: true }); - document.getElementById('ota-state-text').textContent = typeof t === 'function' ? t('otaPage.stateError') : '错误'; - document.getElementById('ota-message').textContent = failureText({ op: 'ota.start', result, endpoint: 'ota.upgrade_file', write: true }); - document.getElementById('ota-abort-btn').style.display = 'none'; + showOtaUpgradeFailure(upgrade, { op: 'ota.start', result, endpoint: 'ota.upgrade_file', write: true }); } } catch (error) { - reportFailure({ op: 'ota.start', error, endpoint: 'ota.upgrade_file', write: true }); - document.getElementById('ota-state-text').textContent = typeof t === 'function' ? t('otaPage.stateError') : '错误'; - document.getElementById('ota-message').textContent = failureText({ op: 'ota.start', error, endpoint: 'ota.upgrade_file', write: true }); - document.getElementById('ota-abort-btn').style.display = 'none'; + showOtaUpgradeFailure(upgrade, { op: 'ota.start', error, endpoint: 'ota.upgrade_file', write: true }); } } @@ -13677,9 +13770,17 @@ async function rollbackOta() { } async function abortOta() { + const upgrade = otaUpgrade; + if (upgrade?.mode === 'proxy' || upgrade?.phase === 'reboot' || upgrade?.phase === 'aborting') { + showToast(t('promptRepair.otaBusy'), 'warning'); + return; + } if (!await confirmAction(typeof t === 'function' ? t('ui.confirmAbortUpgrade') : '确认中止当前升级?', { primary: t('ui.abortUpgrade'), tone: 'neutral' })) { return; } + if (otaUpgrade !== upgrade || upgrade?.phase === 'reboot' || upgrade?.phase === 'done') return; + const previousPhase = upgrade?.phase; + if (upgrade) upgrade.phase = 'aborting'; try { // 根据当前步骤中止相应的 OTA @@ -13689,17 +13790,23 @@ async function abortOta() { } else { result = await api.call('ota.abort'); } + if (otaUpgrade !== upgrade) return; if (result.code === 0) { showToast(typeof t === 'function' ? t('toast.upgradeAborted') : '升级已中止', 'info'); + if (upgrade) finishOtaUpgrade(upgrade); otaStep = 'idle'; + document.getElementById('ota-progress-section').style.display = 'none'; await refreshOtaInfo(); clearInterval(refreshInterval); refreshInterval = null; } else { + if (upgrade) upgrade.phase = previousPhase; reportFailure({ op: 'ota.abort', result, write: true }); } } catch (error) { + if (otaUpgrade !== upgrade || (upgrade && upgrade.phase !== 'aborting')) return; + if (upgrade) upgrade.phase = previousPhase; reportFailure({ op: 'ota.abort', error: error, write: true }); } } @@ -13943,6 +14050,10 @@ async function upgradeFromServer() { showToast(typeof t === 'function' ? t('toast.otaServerNotSet') : 'OTA 服务器地址未设置', 'error'); return; } + if (otaUpgrade && otaUpgrade.phase !== 'done') { + showToast(t('promptRepair.otaBusy'), 'warning'); + return; + } // 立即显示进度区域,给用户即时反馈 const progressSection = document.getElementById('ota-progress-section'); @@ -13968,6 +14079,8 @@ async function upgradeFromServer() { * 优势:ESP32 无需上网,只需要浏览器能访问 OTA 服务器 */ async function upgradeViaProxy(serverUrl) { + const upgrade = beginOtaUpgrade('proxy'); + if (!upgrade) return; const includeWww = document.getElementById('ota-url-include-www')?.checked ?? true; // 获取进度区域元素(已在 upgradeFromServer 中显示) @@ -13979,10 +14092,6 @@ async function upgradeViaProxy(serverUrl) { const messageEl = document.getElementById('ota-message'); const abortBtn = document.getElementById('ota-abort-btn'); - // 设置 OTA 步骤 - otaStep = 'app'; - wwwOtaEnabled = includeWww; - // 计算总步骤数 const totalSteps = includeWww ? 4 : 2; // 下载固件、上传固件、[下载WebUI、上传WebUI] let currentStep = 0; @@ -14026,6 +14135,7 @@ async function upgradeViaProxy(serverUrl) { // 调用 ESP32 上传接口(复用现有的 /api/v1/ota/firmware) // 注意:不自动重启,等 www 也完成后再重启 await uploadFirmwareToDevice(firmwareData, false); + upgrade.firmwareWritten = true; console.log('Proxy OTA: Firmware uploaded to device'); showToast(typeof t === 'function' ? t('toast.firmwareWriteComplete') : '固件写入完成!', 'success'); @@ -14034,6 +14144,7 @@ async function upgradeViaProxy(serverUrl) { // ===== 第三步:处理 WebUI(如果启用)===== if (includeWww) { + upgrade.phase = 'www'; updateStep(3, t('otaPage.downloadingWebUI')); const wwwUrl = serverUrl.replace(/\/$/, '') + '/www.bin'; messageEl.textContent = typeof t === 'function' ? t('ui.downloadingFromServer') : '从 OTA 服务器下载'; @@ -14073,39 +14184,13 @@ async function upgradeViaProxy(serverUrl) { } } - // ===== 最终步骤:升级完成,触发重启 ===== - stateEl.textContent = typeof t === 'function' ? t('ui.allUpgradeComplete') : '全部升级完成'; - progressBar.style.width = '100%'; - progressBar.style.background = 'linear-gradient(90deg, #059669, #10b981)'; - progressPercent.textContent = ''; - messageEl.innerHTML = ` -
-

${t('promptRepair.upgradeWritten')}

-

${t('otaPage.triggeringReboot')}

-
- `; - - otaStep = 'idle'; - - // 触发设备重启 - try { - requireApiSuccess(await api.call('system.reboot', { delay: 1 }), 'call'); - } catch (e) { - showToast(t('promptRepair.rebootUnknown'), 'warning', 10000); - } - - // 检测设备重启 - startRebootDetection(); + await completeOtaUpgrade(upgrade); } catch (error) { console.error('Proxy OTA failed:', error); - stateEl.textContent = typeof t === 'function' ? t('ui.upgradeFailed') : '升级失败'; const failure = { op: error.otaStage === 'www' ? 'ota.upgradewww' : 'ota.upgrade', error, endpoint: 'ota.proxy', write: true }; - messageEl.textContent = failureText(failure); - progressBar.style.width = '0%'; progressPercent.textContent = ''; - otaStep = 'idle'; - reportFailure(failure); + showOtaUpgradeFailure(upgrade, failure); } } diff --git a/components/ts_webui/web/js/lang/en-US.js b/components/ts_webui/web/js/lang/en-US.js index eb38be2..89fb19b 100644 --- a/components/ts_webui/web/js/lang/en-US.js +++ b/components/ts_webui/web/js/lang/en-US.js @@ -147,6 +147,13 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { "outputPreview": "Output: {output}", "upgradeWritten": "Upgrade files were written. Requesting a device restart.", "rebootUnknown": "Restart confirmation was not received. The device may be restarting; wait for the status check before repeating the operation.", + "otaBusy": "An upgrade or restart check is in progress. Wait for it to finish.", + "otaWrittenNoReboot": "Upgrade files were written, but the next boot could not be confirmed; no restart was requested.", + "otaRebootRefused": "Upgrade files were written, but the device refused the restart request.", + "otaConfirming": "Confirming the upgraded firmware...", + "otaRebootUnconfirmed": "Upgrade result unconfirmed", + "otaRebootMismatch": "The running firmware does not match the upgrade target", + "otaTargetDetails": "Expected version: {expected}; running version: {actual}. Check the device status.", "horizontal": "Horizontal", "vertical": "Vertical", "diagonalRight": "Diagonal ↘", @@ -3989,7 +3996,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { wifi: { scan: { failed: 'Failed to scan for WiFi networks' }, applyap: { failed: 'Failed to apply the hotspot settings', unknown: 'The result of applying the hotspot settings is unconfirmed' }, connect: { failed: 'Failed to connect to WiFi', unknown: 'The result of connecting to WiFi is unconfirmed' }, disconnect: { failed: 'Failed to disconnect from WiFi', unknown: 'The result of disconnecting from WiFi is unconfirmed' }, mode: { failed: 'Failed to change the WiFi mode', unknown: 'The result of changing the WiFi mode is unconfirmed' }, stations: { failed: 'Failed to read the list of connected devices' } }, cert: { status: { failed: 'Failed to refresh the certificate status' }, timesync: { failed: 'Failed to sync the device time', unknown: 'The result of syncing the device time is unconfirmed' }, keypair: { failed: 'Failed to generate the key pair', unknown: 'The result of generating the key pair is unconfirmed' }, csr: { failed: 'Failed to generate the certificate request (CSR)' }, install: { failed: 'Failed to install the certificate', unknown: 'The result of installing the certificate is unconfirmed' }, installca: { failed: 'Failed to install the CA chain', unknown: 'The result of installing the CA chain is unconfirmed' }, view: { failed: 'Failed to read the device certificate' }, delete: { failed: 'Failed to delete the PKI credentials', unknown: 'The result of deleting the PKI credentials is unconfirmed' } }, account: { login: { failed: 'Login failed' }, changepassword: { failed: 'Failed to change the password', unknown: 'The result of changing the password is unconfirmed' }, setroot: { failed: 'Failed to set the root password', unknown: 'The result of setting the root password is unconfirmed' }, setadmin: { failed: 'Failed to set the admin password', unknown: 'The result of setting the admin password is unconfirmed' }, resetadmin: { failed: 'Failed to reset the admin password', unknown: 'The result of resetting the admin password is unconfirmed' } }, - ota: { upgrade: { failed: 'The upgrade failed', unknown: 'The result of the upgrade is unconfirmed' }, startwww: { failed: 'The WebUI upgrade could not be started', unknown: 'The result of starting the WebUI upgrade is unconfirmed' }, check: { failed: 'Failed to check for updates' }, start: { failed: 'Failed to start the firmware upgrade', unknown: 'The result of starting the firmware upgrade is unconfirmed' }, upgradewww: { failed: 'The WebUI upgrade failed (the firmware was already written)', unknown: 'The result of the WebUI upgrade is unconfirmed (the firmware was already written)' }, path: { failed: 'Could not start the upgrade from the SD card' }, abort: { failed: 'Failed to abort the upgrade', unknown: 'The result of aborting the upgrade is unconfirmed' }, validate: { failed: 'Failed to mark the firmware as valid', unknown: 'The result of marking the firmware as valid is unconfirmed' }, rollback: { failed: 'Failed to roll back the firmware', unknown: 'The result of rolling back the firmware is unconfirmed' }, saveserver: { failed: 'Failed to save the OTA server address', unknown: 'The result of saving the OTA server address is unconfirmed' } }, + ota: { verify: { failed: 'Could not confirm the upgraded firmware', unknown: 'The result of the upgrade is unconfirmed' }, upgrade: { failed: 'The upgrade failed', unknown: 'The result of the upgrade is unconfirmed' }, startwww: { failed: 'The WebUI upgrade could not be started', unknown: 'The result of starting the WebUI upgrade is unconfirmed' }, check: { failed: 'Failed to check for updates' }, start: { failed: 'Failed to start the firmware upgrade', unknown: 'The result of starting the firmware upgrade is unconfirmed' }, upgradewww: { failed: 'The WebUI upgrade failed (the firmware was already written)', unknown: 'The result of the WebUI upgrade is unconfirmed (the firmware was already written)' }, path: { failed: 'Could not start the upgrade from the SD card' }, abort: { failed: 'Failed to abort the upgrade', unknown: 'The result of aborting the upgrade is unconfirmed' }, validate: { failed: 'Failed to mark the firmware as valid', unknown: 'The result of marking the firmware as valid is unconfirmed' }, rollback: { failed: 'Failed to roll back the firmware', unknown: 'The result of rolling back the firmware is unconfirmed' }, saveserver: { failed: 'Failed to save the OTA server address', unknown: 'The result of saving the OTA server address is unconfirmed' } }, device: { reboot: { failed: 'Failed to restart the device', unknown: 'The result of restarting the device is unconfirmed' } }, led: { list: { failed: 'Failed to read the LED device list' }, clear: { failed: 'Failed to turn off the LED', unknown: 'The result of turning off the LED is unconfirmed' }, ccapply: { failed: 'Failed to apply the color correction', unknown: 'The result of applying the color correction is unconfirmed' }, ccreset: { failed: 'Failed to reset the color correction', unknown: 'The result of resetting the color correction is unconfirmed' }, ccimport: { failed: 'Failed to import the color correction config', unknown: 'The result of importing the color correction config is unconfirmed' }, filterapply: { failed: 'Failed to apply the filter', unknown: 'The result of applying the filter is unconfirmed' }, filterstop: { failed: 'Failed to stop the filter', unknown: 'The result of stopping the filter is unconfirmed' }, brightness: { failed: 'Failed to set the brightness of {device}', unknown: 'The result of setting the brightness of {device} is unconfirmed' }, saveconfig: { failed: 'Failed to save the LED config', unknown: 'The result of saving the LED config is unconfirmed' }, starteffect: { failed: 'Failed to start the LED animation', unknown: 'The result of starting the LED animation is unconfirmed' }, stopeffect: { failed: 'Failed to stop the LED animation', unknown: 'The result of stopping the LED animation is unconfirmed' }, fill: { failed: 'Failed to fill the LED with a color', unknown: 'The result of filling the LED with a color is unconfirmed' }, image: { failed: 'Failed to display the image on the LED', unknown: 'The result of displaying the image on the LED is unconfirmed' }, qr: { failed: 'Failed to show the QR code on the LED', unknown: 'The result of showing the QR code on the LED is unconfirmed' }, text: { failed: 'Failed to show the text on the LED', unknown: 'The result of showing the text on the LED is unconfirmed' }, stoptext: { failed: 'Failed to stop the text on the LED', unknown: 'The result of stopping the text on the LED is unconfirmed' }, toggle: { failed: 'Failed to switch the LED on or off', unknown: 'The result of switching the LED on or off is unconfirmed' } }, home: { quicklist: { failed: 'Failed to read the quick actions' }, quicklog: { failed: 'Failed to read the run log' } }, @@ -4142,6 +4149,8 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { old_password_wrong: { why: 'the current password is wrong', hint: 'Check it and try again.' }, root_required: { why: 'this action needs the root account', hint: 'Log in as root and try again.' }, ota_failed: { why: 'the device reported an error during the upgrade', hint: 'Click Details to see the reason the device gave.' }, + ota_target_unavailable: { why: 'the device did not provide the next firmware information', hint: 'The files were written. Check the partition status before restarting.' }, + ota_target_mismatch: { why: 'the running firmware version {actual} does not match the target version {expected}', hint: 'Check the upgrade partition and device status before deciding whether to upgrade again.' }, ota_no_rollback: { why: 'there is no older version to roll back to', hint: 'An older version only exists after an upgrade.' }, ota_server_not_set: { why: 'no OTA server address has been set', hint: 'Save a server address on the OTA page first, or enter a full address.' }, url_invalid: { why: 'the address is not valid, or it is too long', hint: 'Enter a complete, valid address.' }, diff --git a/components/ts_webui/web/js/lang/zh-CN.js b/components/ts_webui/web/js/lang/zh-CN.js index bf48389..0dfb307 100644 --- a/components/ts_webui/web/js/lang/zh-CN.js +++ b/components/ts_webui/web/js/lang/zh-CN.js @@ -147,6 +147,13 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { "outputPreview": "输出:{output}", "upgradeWritten": "升级文件已写入,正在请求设备重启。", "rebootUnknown": "未收到重启确认。设备可能正在重启,请等待状态检查,不要重复操作。", + "otaBusy": "正在升级或确认重启,请等待当前操作结束。", + "otaWrittenNoReboot": "升级文件已写入,重启前状态未确认;尚未请求重启。", + "otaRebootRefused": "升级文件已写入,但设备拒绝了重启请求。", + "otaConfirming": "正在确认升级后的固件...", + "otaRebootUnconfirmed": "升级结果未确认", + "otaRebootMismatch": "启动的固件与升级目标不符", + "otaTargetDetails": "目标版本:{expected};当前版本:{actual}。请检查设备状态。", "horizontal": "横向", "vertical": "纵向", "diagonalRight": "对角 ↘", @@ -3951,7 +3958,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { wifi: { scan: { failed: '扫描 WiFi 失败' }, applyap: { failed: '应用热点配置失败', unknown: '应用热点配置的结果未确认' }, connect: { failed: '连接 WiFi 失败', unknown: '连接 WiFi 的结果未确认' }, disconnect: { failed: '断开 WiFi 连接失败', unknown: '断开 WiFi 连接的结果未确认' }, mode: { failed: '切换 WiFi 模式失败', unknown: '切换 WiFi 模式的结果未确认' }, stations: { failed: '读取已连接设备列表失败' } }, cert: { status: { failed: '刷新证书状态失败' }, timesync: { failed: '同步设备时间失败', unknown: '同步设备时间的结果未确认' }, keypair: { failed: '生成密钥对失败', unknown: '生成密钥对的结果未确认' }, csr: { failed: '生成证书请求(CSR)失败' }, install: { failed: '安装证书失败', unknown: '安装证书的结果未确认' }, installca: { failed: '安装 CA 证书链失败', unknown: '安装 CA 证书链的结果未确认' }, view: { failed: '读取设备证书失败' }, delete: { failed: '删除 PKI 凭证失败', unknown: '删除 PKI 凭证的结果未确认' } }, account: { login: { failed: '登录失败' }, changepassword: { failed: '修改密码失败', unknown: '修改密码的结果未确认' }, setroot: { failed: '设置 root 密码失败', unknown: '设置 root 密码的结果未确认' }, setadmin: { failed: '设置 admin 密码失败', unknown: '设置 admin 密码的结果未确认' }, resetadmin: { failed: '重置 admin 密码失败', unknown: '重置 admin 密码的结果未确认' } }, - ota: { upgrade: { failed: '升级失败', unknown: '升级的结果未确认' }, startwww: { failed: 'WebUI 升级没能启动', unknown: '启动 WebUI 升级的结果未确认' }, check: { failed: '检查更新失败' }, start: { failed: '启动固件升级失败', unknown: '启动固件升级的结果未确认' }, upgradewww: { failed: 'WebUI 升级失败(固件已经写入)', unknown: 'WebUI 升级的结果未确认(固件已经写入)' }, path: { failed: '无法开始从 SD 卡升级' }, abort: { failed: '中止升级失败', unknown: '中止升级的结果未确认' }, validate: { failed: '标记固件有效失败', unknown: '标记固件有效的结果未确认' }, rollback: { failed: '回滚固件失败', unknown: '回滚固件的结果未确认' }, saveserver: { failed: '保存 OTA 服务器地址失败', unknown: '保存 OTA 服务器地址的结果未确认' } }, + ota: { verify: { failed: '无法确认升级后的固件', unknown: '升级结果未确认' }, upgrade: { failed: '升级失败', unknown: '升级的结果未确认' }, startwww: { failed: 'WebUI 升级没能启动', unknown: '启动 WebUI 升级的结果未确认' }, check: { failed: '检查更新失败' }, start: { failed: '启动固件升级失败', unknown: '启动固件升级的结果未确认' }, upgradewww: { failed: 'WebUI 升级失败(固件已经写入)', unknown: 'WebUI 升级的结果未确认(固件已经写入)' }, path: { failed: '无法开始从 SD 卡升级' }, abort: { failed: '中止升级失败', unknown: '中止升级的结果未确认' }, validate: { failed: '标记固件有效失败', unknown: '标记固件有效的结果未确认' }, rollback: { failed: '回滚固件失败', unknown: '回滚固件的结果未确认' }, saveserver: { failed: '保存 OTA 服务器地址失败', unknown: '保存 OTA 服务器地址的结果未确认' } }, device: { reboot: { failed: '重启设备失败', unknown: '重启设备的结果未确认' } }, led: { list: { failed: '读取 LED 设备列表失败' }, clear: { failed: '关闭 LED 失败', unknown: '关闭 LED 的结果未确认' }, ccapply: { failed: '应用色彩校正失败', unknown: '应用色彩校正的结果未确认' }, ccreset: { failed: '重置色彩校正失败', unknown: '重置色彩校正的结果未确认' }, ccimport: { failed: '导入色彩校正配置失败', unknown: '导入色彩校正配置的结果未确认' }, filterapply: { failed: '应用滤镜失败', unknown: '应用滤镜的结果未确认' }, filterstop: { failed: '停止滤镜失败', unknown: '停止滤镜的结果未确认' }, brightness: { failed: '设置 {device} 的亮度失败', unknown: '设置 {device} 亮度的结果未确认' }, saveconfig: { failed: '保存 LED 配置失败', unknown: '保存 LED 配置的结果未确认' }, starteffect: { failed: '启动 LED 动画失败', unknown: '启动 LED 动画的结果未确认' }, stopeffect: { failed: '停止 LED 动画失败', unknown: '停止 LED 动画的结果未确认' }, fill: { failed: '填充 LED 颜色失败', unknown: '填充 LED 颜色的结果未确认' }, image: { failed: '在 LED 上显示图像失败', unknown: '在 LED 上显示图像的结果未确认' }, qr: { failed: '在 LED 上显示 QR 码失败', unknown: '在 LED 上显示 QR 码的结果未确认' }, text: { failed: '在 LED 上显示文本失败', unknown: '在 LED 上显示文本的结果未确认' }, stoptext: { failed: '停止 LED 上的文本失败', unknown: '停止 LED 上的文本的结果未确认' }, toggle: { failed: '切换 LED 开关失败', unknown: '切换 LED 开关的结果未确认' } }, home: { quicklist: { failed: '读取快捷操作失败' }, quicklog: { failed: '读取运行日志失败' } }, @@ -4104,6 +4111,8 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { old_password_wrong: { why: '当前密码不对', hint: '请检查后重新输入。' }, root_required: { why: '这项操作需要 root 账户', hint: '请换用 root 账户登录后再操作。' }, ota_failed: { why: '设备在升级过程中报告了错误', hint: '点“详情”可以看到设备给出的原因。' }, + ota_target_unavailable: { why: '设备未能提供待启动固件的信息', hint: '文件已经写入,请检查分区状态后再重启。' }, + ota_target_mismatch: { why: '当前固件版本 {actual} 与目标版本 {expected} 不符', hint: '请检查升级分区和设备状态,再决定是否重新升级。' }, ota_no_rollback: { why: '没有可以回滚的旧版本', hint: '只有升级过之后,才会有旧版本可以回滚。' }, ota_server_not_set: { why: '还没有设置 OTA 服务器地址', hint: '请先在 OTA 页保存服务器地址,或直接填写完整的地址。' }, url_invalid: { why: '地址的格式不对,或者太长了', hint: '请填写完整、正确的地址。' }, diff --git a/tests/prompts/ota.browser.test.cjs b/tests/prompts/ota.browser.test.cjs new file mode 100644 index 0000000..f51aa36 --- /dev/null +++ b/tests/prompts/ota.browser.test.cjs @@ -0,0 +1,259 @@ +const {test} = require('node:test'); +const assert = require('node:assert/strict'); +const kit = require('./pagekit.cjs'); + +kit.install(); +const deferred = () => { + let resolve; + const promise = new Promise(done => { resolve = done; }); + return {promise, resolve}; +}; + +async function otaPage(language, mode, run) { + const target = {label: 'ota_1', address: 0x360000, size: 0x340000, version: '0.6.2+target', project: 'TianShanOS', is_bootable: true}; + const state = {app: 'idle', www: 'idle', wwwStart: 0, firmwareWritten: false, wwwWritten: false, wwwBusy: false, + running: {...target, label: 'ota_0', address: 0x20000, version: '0.6.1+old'}}; + const respond = call => { + switch (call.endpoint) { + case 'ota/server/get': return {code: 0, data: {url: 'https://ota.fixture.invalid'}}; + case 'ota/version': return {code: 0, data: {version: state.running.version, project: 'TianShanOS', compile_date: 'Oct 1 2026', compile_time: '00:00:00'}}; + case 'ota/partitions': return {code: 0, data: {running: state.running, next: target}}; + case 'ota/test_connection': return {code: 0, data: {dns_ok: true, tcp_ok: true, http_ok: true}}; + case 'ota/progress': return {code: 0, data: {state: state.app, percent: 100}}; + case 'ota/www/start': + case 'ota/www/start_sdcard': + if (state.wwwStart) return {code: state.wwwStart, message: 'WWW start refused'}; + state.wwwBusy = true; + state.www = 'writing'; + return {code: 0, data: {status: 'started'}}; + case 'ota/www/progress': return {code: 0, data: {state: state.www, running: state.wwwBusy, percent: 20}}; + case 'ota/upgrade_url': + case 'ota/upgrade_file': state.app = 'writing'; return {code: 0, data: {status: 'started'}}; + case 'storage/info': return {code: 0, data: {type: 'file'}}; + case 'ota/abort': state.app = 'idle'; return {code: 0}; + case 'ota/firmware': + state.firmwareWritten = true; + state.app = 'pending_reboot'; + return {status: 'success'}; + case 'ota/www': + if (mode === 'upload-drop') return kit.DROP; + if (state.wwwBusy) return {status: 'error', error: 'OTA already in progress'}; + state.wwwWritten = true; + return {status: 'success'}; + case 'system/reboot': + if (mode !== 'old-firmware') state.running = {...target}; + if (mode === 'wrong-build') state.running.version = '0.6.2+other'; + return {code: 0, data: {status: 'rebooting'}}; + } + }; + await kit.withPage(language, respond, async d => { + const gate = deferred(), reached = deferred(); + await d.page.route('https://ota.fixture.invalid/**', async route => { + const url = route.request().url(); + if (url.endsWith('/version')) { + await route.fulfill({json: {version: target.version, compile_date: 'Oct 13 2026', compile_time: '00:00:00', size: 32}}); + return; + } + if (mode === 'download-paused' && url.endsWith('/www.bin')) { + reached.resolve(); + await gate.promise; + } + await route.fulfill({body: Buffer.alloc(32), contentType: 'application/octet-stream', headers: {'Content-Length': '32'}}); + }); + await d.page.evaluate(() => { + api.token = 'fixture'; api.username = 'root'; api.level = 2; + closeLoginModal(); + router.navigate('/ota'); + }); + await d.page.waitForSelector('#ota-server-input'); + await d.page.locator('#ota-server-input').fill('https://ota.fixture.invalid'); + if (!mode.startsWith('manual-')) { + await d.page.locator('[onclick="checkForUpdates()"]').click(); + await d.page.locator('[onclick="upgradeFromServer()"]').click(); + } + try { await run({...d, state, gate, reached}); } + finally { gate.resolve(); } + }); +} + +async function manualInput(page, source, includeWww) { + await page.waitForFunction(() => document.getElementById('ota-current-version')?.textContent.includes('0.6.')); + await page.locator('#ota-manual').evaluate(section => { section.open = true; }); + const prefix = source === 'URL' ? 'ota-url' : 'ota-file'; + await page.locator(`#${prefix}-input`).fill(source === 'URL' + ? 'https://ota.fixture.invalid/firmware.bin' : '/sdcard/update/TianShanOS.bin'); + await page.locator(`#${prefix}-include-www`).setChecked(includeWww); +} + +async function leaveOtaPage(page) { + await page.evaluate(() => { + router.register('/ota-test-away', async () => { + document.getElementById('page-content').innerHTML = '
Another page
'; + }); + router.navigate('/ota-test-away'); + }); + await page.locator('#ota-test-away').waitFor(); +} + +async function returnToOtaPage(page) { + await page.evaluate(() => { router.navigate('/ota'); }); + await page.waitForFunction(() => document.getElementById('ota-current-version')?.textContent.includes('0.6.')); +} + +for (const language of ['zh-CN', 'en-US']) { + test(`${language}: repeated SD WWW retries preserve the source until the successful write`, async () => { + await otaPage(language, 'manual-retry', async ({page, calls, state}) => { + await manualInput(page, 'SD card', true); + await page.evaluate(() => otaFromFile()); + state.app = 'pending_reboot'; + state.wwwStart = 7; + await page.evaluate(() => refreshOtaProgress()); + const secondReply = page.waitForResponse(response => response.url().endsWith('/api/v1/ota/www/start_sdcard')); + await page.locator('[onclick="startWwwOta()"]').click(); + await secondReply; + await page.waitForFunction(() => otaUpgrade.phase === 'done'); + state.wwwStart = 0; + const thirdReply = page.waitForResponse(response => /\/api\/v1\/ota\/www\/start(?:_sdcard)?$/.test(response.url())); + await page.locator('[onclick="startWwwOta()"]').click(); + await thirdReply; + await page.waitForFunction(() => otaUpgrade.phase === 'www'); + const starts = calls.filter(call => call.endpoint === 'ota/www/start_sdcard'); + assert.equal(starts.length, 3); + assert(starts.every(call => call.params.file === '/sdcard/update/www.bin')); + assert.equal(calls.filter(call => call.endpoint === 'ota/www/start').length, 0); + assert.equal(calls.filter(call => call.endpoint === 'system/reboot').length, 0); + state.www = 'completed'; + state.wwwBusy = false; + await page.waitForFunction(() => document.getElementById('ota-state-text').textContent === t('ui.allUpgradeComplete')); + assert.equal(calls.filter(call => call.endpoint === 'system/reboot').length, 1); + }); + }); + + test(`${language}: leaving during SD path lookup does not block the next upgrade`, async () => { + await otaPage(language, 'manual-path-navigation', async ({page, calls, gate, reached}) => { + await manualInput(page, 'SD card', true); + await page.route('**/api/v1/storage/info', async route => { + reached.resolve(); + await gate.promise; + await route.fulfill({json: {code: 0, data: {type: 'file'}}}); + }); + await page.evaluate(() => { window.otaTestStart = otaFromFile(); }); + await reached.promise; + await leaveOtaPage(page); + gate.resolve(); + await page.evaluate(() => window.otaTestStart); + assert.equal(calls.filter(call => call.endpoint === 'ota/upgrade_file').length, 0); + assert.equal(await page.evaluate(() => otaUpgrade), null); + await returnToOtaPage(page); + await manualInput(page, 'URL', false); + await page.evaluate(() => otaFromUrl()); + assert.equal(calls.filter(call => call.endpoint === 'ota/upgrade_url').length, 1); + assert.equal(await page.evaluate(() => otaUpgrade.phase), 'app'); + }); + }); + + for (const source of ['URL', 'SD card']) for (const includeWww of [true, false]) { + test(`${language}: ${source} acknowledgement after navigation resumes ${includeWww ? 'both images' : 'firmware-only'}`, async () => { + await otaPage(language, 'manual-ack-navigation', async ({page, calls, state, gate, reached}) => { + await manualInput(page, source, includeWww); + const startEndpoint = source === 'URL' ? 'ota/upgrade_url' : 'ota/upgrade_file'; + await page.route(`**/api/v1/${startEndpoint}`, async route => { + calls.push({endpoint: startEndpoint, params: route.request().postDataJSON()}); + state.app = 'writing'; + reached.resolve(); + await gate.promise; + await route.fulfill({json: {code: 0, data: {status: 'started'}}}); + }); + await page.evaluate(source => { + window.otaTestStart = source === 'URL' ? otaFromUrl() : otaFromFile(); + }, source); + await reached.promise; + await leaveOtaPage(page); + gate.resolve(); + await page.evaluate(() => window.otaTestStart); + assert.equal(await page.evaluate(() => otaUpgrade.phase), 'app'); + assert.equal(calls.filter(call => call.endpoint === 'system/reboot').length, 0); + state.app = 'pending_reboot'; + const wwwEndpoint = source === 'URL' ? 'ota/www/start' : 'ota/www/start_sdcard'; + const wwwReply = includeWww ? page.waitForResponse(response => response.url().endsWith(`/api/v1/${wwwEndpoint}`)) : null; + await returnToOtaPage(page); + if (includeWww) { + await wwwReply; + await page.waitForFunction(() => otaUpgrade.phase === 'www'); + const starts = calls.filter(call => call.endpoint === wwwEndpoint); + assert.equal(starts.length, 1); + assert.equal(starts[0].params[source === 'URL' ? 'url' : 'file'], source === 'URL' + ? 'https://ota.fixture.invalid/www.bin' : '/sdcard/update/www.bin'); + assert.equal(calls.filter(call => call.endpoint === 'system/reboot').length, 0); + state.www = 'completed'; + state.wwwBusy = false; + } else assert.equal(calls.filter(call => call.endpoint === 'ota/www/start' || call.endpoint === 'ota/www/start_sdcard').length, 0); + await page.waitForFunction(() => document.getElementById('ota-state-text').textContent === t('ui.allUpgradeComplete')); + assert.equal(calls.filter(call => call.endpoint === startEndpoint).length, 1); + assert.equal(calls.filter(call => call.endpoint === 'system/reboot').length, 1); + }); + }); + } + + test(`${language}: acknowledged manual abort hides progress and permits a new upgrade`, async () => { + await otaPage(language, 'manual-abort', async ({page, calls}) => { + await page.locator('#ota-manual').evaluate(section => { section.open = true; }); + await page.locator('#ota-url-input').fill('https://ota.fixture.invalid/firmware.bin'); + await page.locator('#ota-url-include-www').uncheck(); + await page.locator('[onclick="otaFromUrl()"]').click(); + await page.locator('#ota-abort-btn').click(); + await kit.confirmSheet(page); + await page.locator('#ota-progress-section').waitFor({state: 'hidden'}); + assert.equal(calls.filter(call => call.endpoint === 'ota/abort').length, 1); + assert.equal(calls.filter(call => call.endpoint === 'system/reboot').length, 0); + await page.locator('[onclick="otaFromUrl()"]').click(); + await page.waitForFunction(() => document.getElementById('ota-abort-btn').style.display !== 'none'); + assert.equal(calls.filter(call => call.endpoint === 'ota/upgrade_url').length, 2); + }); + }); + + test(`${language}: real OTA page keeps one owner while WWW download is slow`, async () => { + await otaPage(language, 'download-paused', async ({page, calls, reached, gate, state}) => { + await reached.promise; + await page.waitForTimeout(1500); + assert.equal(calls.filter(call => call.endpoint === 'ota/www/start').length, 0); + assert.equal(calls.filter(call => call.endpoint === 'system/reboot').length, 0); + gate.resolve(); + await page.waitForFunction(() => document.getElementById('ota-state-text').textContent === t('ui.allUpgradeComplete')); + assert.equal(state.firmwareWritten, true); + assert.equal(state.wwwWritten, true); + assert.equal(calls.filter(call => call.endpoint === 'system/reboot').length, 1); + const request = calls.find(call => call.endpoint === 'system/reboot'); + assert.equal(request.params.delay, 1000); + }); + }); + + test(`${language}: a lost WWW upload answer shows partial, unconfirmed success without reboot`, async () => { + await otaPage(language, 'upload-drop', async ({page, calls, state}) => { + await page.waitForFunction(() => document.getElementById('ota-state-text').textContent === t('failure.ops.ota.upgradewww.unknown')); + assert.equal(state.firmwareWritten, true); + assert.equal(state.wwwWritten, false); + assert.equal(calls.filter(call => call.endpoint === 'system/reboot').length, 0); + await kit.toastIs(page, 'warning'); + }); + }); + + test(`${language}: a different build in the target partition is never announced as successful`, async () => { + await otaPage(language, 'wrong-build', async ({page}) => { + await page.waitForFunction(() => document.getElementById('ota-state-text').textContent === t('promptRepair.otaRebootMismatch')); + assert.match(await page.locator('#ota-message').textContent(), /0\.6\.2\+other/); + assert.equal(await page.locator('#toast').getAttribute('class'), 'toast toast-error show rich'); + }); + }); + + test(`${language}: readable old firmware times out with an actionable unconfirmed result`, async () => { + await otaPage(language, 'old-firmware', async ({page}) => { + await page.waitForSelector('#reboot-countdown'); + await page.evaluate(() => { rebootStartTime -= 121000; }); + await page.waitForFunction(() => document.getElementById('ota-state-text').textContent === t('promptRepair.otaRebootUnconfirmed')); + assert.match(await page.locator('#ota-message').textContent(), /0\.6\.2\+target/); + await page.locator('#ota-message [onclick="window.location.reload()"]', {strict: true}).waitFor(); + await kit.toastIs(page, 'warning'); + }); + }); +} diff --git a/tests/prompts/ota.test.cjs b/tests/prompts/ota.test.cjs new file mode 100644 index 0000000..29c1170 --- /dev/null +++ b/tests/prompts/ota.test.cjs @@ -0,0 +1,465 @@ +const {test} = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const {harness} = require('./harness.cjs'); + +const deferred = () => { + let resolve, reject; + const promise = new Promise((done, fail) => { resolve = done; reject = fail; }); + return {promise, resolve, reject}; +}; +const answer = (data, status = 200) => ({ok: status === 200, status, json: async () => data}); + +async function device(language = 'zh-CN', includeWww = true) { + const h = harness(language); + await h.ready(); + if (process.env.OTA_WEB_ROOT) { + for (const name of ['js/lang/zh-CN.js', 'js/lang/en-US.js', 'js/api.js', 'js/terminal.js', 'js/app.js']) { + h.run(fs.readFileSync(path.join(process.env.OTA_WEB_ROOT, name), 'utf8')); + } + } else h.load(); + for (const id of ['ota-progress-section', 'ota-state-text', 'ota-progress-bar', + 'ota-progress-percent', 'ota-progress-size', 'ota-message', 'ota-abort-btn', + 'ota-url-include-www', 'ota-server-input', 'ota-url-input', 'ota-url-skip-verify', + 'ota-file-input', 'ota-file-include-www', 'ota-manual', 'reboot-countdown']) h.el(id); + h.el('ota-url-include-www').checked = includeWww; + h.el('ota-file-include-www').checked = includeWww; + h.el('ota-server-input').value = 'https://ota.fixture.invalid'; + h.el('ota-url-input').value = 'https://ota.fixture.invalid/firmware.bin'; + h.el('ota-file-input').value = '/sdcard/update/TianShanOS.bin'; + const calls = [], toasts = []; + const state = { + app: 'idle', www: 'idle', firmwareWritten: false, wwwWritten: false, + wwwBusy: false, wwwUpload: 'success', wwwStart: 0, + running: {label: 'ota_0', address: 0x20000, size: 0x340000, version: '0.6.1+old', project: 'TianShanOS', is_bootable: true}, + next: {label: 'ota_1', address: 0x360000, size: 0x340000, version: '0.6.2+target', project: 'TianShanOS', is_bootable: true} + }; + h.ctx.recordToast = (text, type) => toasts.push({text, type}); + h.run('showToast = recordToast'); + h.ctx.location.reload = () => calls.push({endpoint: 'page.reload'}); + const apiCall = async (endpoint, params) => { + calls.push({endpoint, params}); + if (endpoint === 'ota.progress') return {code: 0, data: {state: state.app, percent: 100, received_size: 16, total_size: 16}}; + if (endpoint === 'ota.www.progress') return {code: 0, data: {state: state.www, running: state.wwwBusy, percent: 100, received: 16, total: 16}}; + if (endpoint === 'ota.www.start' || endpoint === 'ota.www.start_sdcard') { + if (state.wwwStart) return {code: state.wwwStart, message: 'WWW start refused'}; + state.wwwBusy = true; + state.www = 'writing'; + return {code: 0, data: {status: 'started'}}; + } + if (endpoint === 'ota.upgrade_url' || endpoint === 'ota.upgrade_file') { + state.app = 'writing'; + return {code: 0, data: {status: 'started'}}; + } + if (endpoint === 'ota.partitions') { + if (state.partitionsError) throw state.partitionsError; + return {code: 0, data: {running: {...state.running}, next: {...state.next}}}; + } + if (endpoint === 'ota.version') return {code: 0, data: {version: state.running.version}}; + if (endpoint === 'system.reboot') { + if (state.rebootError) throw state.rebootError; + return state.rebootResult || {code: 0, data: {status: 'rebooting'}}; + } + if (endpoint === 'storage.info') return {code: 0, data: {type: 'file'}}; + if (endpoint === 'ota.abort' || endpoint === 'ota.www.abort') return {code: 0, data: {}}; + if (endpoint === 'ota.server.get') return {code: 0, data: {url: 'https://ota.fixture.invalid'}}; + throw new Error(`Unexpected API ${endpoint}`); + }; + h.ctx.apiCall = apiCall; + h.run('api.call = apiCall'); + const fetch = async url => { + calls.push({endpoint: url}); + if (url.startsWith('https://ota.fixture.invalid/')) { + if (url.endsWith('/www.bin') && state.wwwDownloadGate) { + state.wwwDownloadReached.resolve(); + await state.wwwDownloadGate.promise; + } + let sent = false; + return {ok: true, headers: {get: () => '16'}, body: {getReader: () => ({read: async () => { + if (sent) return {done: true}; + sent = true; + return {done: false, value: new Uint8Array(16)}; + }})}}; + } + if (url.includes('/ota/firmware?auto_reboot=false')) { + state.firmwareWritten = true; + state.app = 'pending_reboot'; + if (state.firmwareReplyGate) { + state.firmwareUploadReached.resolve(); + await state.firmwareReplyGate.promise; + } + return answer({status: 'success'}); + } + if (url.endsWith('/ota/www')) { + if (state.wwwUpload === 'offline') throw new TypeError('Failed to fetch'); + if (state.wwwBusy || state.wwwUpload === 'refused') return answer({error: 'OTA already in progress'}, 409); + state.wwwWritten = true; + state.www = 'completed'; + return answer({status: 'success'}); + } + throw new Error(`Unexpected download/upload ${url}`); + }; + h.ctx.fetch = fetch; + h.run('refreshInterval = setInterval(refreshOtaProgress, 1000)'); + return {h, state, calls, toasts, apiCall, + count: endpoint => calls.filter(call => call.endpoint === endpoint).length, + boot: () => { state.running = {...state.next}; }}; +} + +test('proxy owns both images while normal progress polling remains active', async () => { + const d = await device(); + d.state.wwwDownloadGate = deferred(); + d.state.wwwDownloadReached = deferred(); + const upgrade = d.h.run("upgradeViaProxy('https://ota.fixture.invalid')"); + await d.state.wwwDownloadReached.promise; + await d.h.advance(3000); + assert.equal(d.count('ota.www.start'), 0, 'the device must not also download WWW'); + assert.equal(d.count('system.reboot'), 0, 'WWW is still downloading in the browser'); + d.state.wwwDownloadGate.resolve(); + await upgrade; + assert.equal(d.state.wwwWritten, true); + assert.equal(d.count('system.reboot'), 1); +}); + +test('a progress response requested before proxy OTA cannot advance the new upgrade', async () => { + const d = await device(); + const reply = deferred(); + d.h.ctx.apiCall = async (endpoint, params) => endpoint === 'ota.progress' ? reply.promise : d.apiCall(endpoint, params); + d.h.run('api.call = apiCall'); + const oldPoll = d.h.run('refreshOtaProgress()'); + d.state.wwwDownloadGate = deferred(); + d.state.wwwDownloadReached = deferred(); + const upgrade = d.h.run("upgradeViaProxy('https://ota.fixture.invalid')"); + await d.state.wwwDownloadReached.promise; + reply.resolve({code: 0, data: {state: 'pending_reboot'}}); + await oldPoll; + assert.equal(d.count('ota.www.start'), 0); + assert.equal(d.count('system.reboot'), 0); + d.state.wwwDownloadGate.resolve(); + await upgrade; +}); + +test('firmware-only proxy waits for the firmware write acknowledgement before rebooting', async () => { + const d = await device('zh-CN', false); + d.state.firmwareReplyGate = deferred(); + d.state.firmwareUploadReached = deferred(); + const upgrade = d.h.run("upgradeViaProxy('https://ota.fixture.invalid')"); + await d.state.firmwareUploadReached.promise; + await d.h.advance(2000); + assert.equal(d.count('system.reboot'), 0); + d.state.firmwareReplyGate.resolve(); + await upgrade; + assert.equal(d.count('system.reboot'), 1); + assert.equal(d.count('/api/v1/ota/www'), 0); +}); + +for (const source of ['URL', 'SD card']) for (const includeWww of [true, false]) { + test(`${source} upgrade preserves ${includeWww ? 'both images' : 'firmware-only'} sequencing`, async () => { + const d = await device('zh-CN', includeWww); + await d.h.run(source === 'URL' ? 'otaFromUrl()' : 'otaFromFile()'); + const start = d.calls.find(call => call.endpoint === (source === 'URL' ? 'ota.upgrade_url' : 'ota.upgrade_file')); + assert.equal(start.params.no_reboot, true); + assert.equal(d.count('system.reboot'), 0); + d.state.firmwareWritten = true; + d.state.app = 'pending_reboot'; + await d.h.run('refreshOtaProgress()'); + if (includeWww) { + const wwwEndpoint = source === 'URL' ? 'ota.www.start' : 'ota.www.start_sdcard'; + assert.equal(d.count(wwwEndpoint), 1); + const www = d.calls.find(call => call.endpoint === wwwEndpoint); + assert.equal(www.params[source === 'URL' ? 'url' : 'file'], source === 'URL' + ? 'https://ota.fixture.invalid/www.bin' : '/sdcard/update/www.bin'); + assert.equal(d.count('system.reboot'), 0); + d.state.www = 'completed'; + d.state.wwwWritten = true; + d.state.wwwBusy = false; + await d.h.run('refreshOtaProgress()'); + } else assert.equal(d.count('ota.www.start') + d.count('ota.www.start_sdcard'), 0); + assert.equal(d.count('system.reboot'), 1); + d.boot(); + await d.h.advance(2000); + assert(d.toasts.some(toast => toast.type === 'success' && toast.text.includes('0.6.2+target'))); + }); +} + +for (const language of ['zh-CN', 'en-US']) for (const failure of ['offline', 'refused']) { + test(`${language}: ${failure} WWW upload retains the written-firmware outcome without rebooting`, async () => { + const d = await device(language); + d.state.wwwUpload = failure; + await d.h.run("upgradeViaProxy('https://ota.fixture.invalid')"); + assert.equal(d.state.firmwareWritten, true); + assert.equal(d.count('system.reboot'), 0); + const title = d.h.el('ota-state-text').textContent; + assert(title.includes(language === 'zh-CN' ? '固件' : 'firmware')); + if (failure === 'offline') assert(title.includes(language === 'zh-CN' ? '未确认' : 'unconfirmed')); + d.state.wwwUpload = 'success'; + await d.h.run("upgradeViaProxy('https://ota.fixture.invalid')"); + assert.equal(d.state.wwwWritten, true, 'the failed attempt must not leave the next attempt blocked'); + assert.equal(d.count('system.reboot'), 1); + }); +} + +test('reboot confirmation accepts a fast reboot to the written target without observing a disconnect', async () => { + const d = await device(); + await d.h.run("upgradeViaProxy('https://ota.fixture.invalid')"); + d.boot(); + await d.h.advance(2000); + assert(d.toasts.some(toast => toast.type === 'success' && toast.text.includes('0.6.2+target'))); + assert.equal(d.h.run('rebootCheckInterval'), null); +}); + +test('same-version reflash must actually switch to the written OTA partition', async () => { + const d = await device(); + d.state.next.version = d.state.running.version; + await d.h.run("upgradeViaProxy('https://ota.fixture.invalid')"); + await d.h.advance(2000); + assert(!d.toasts.some(toast => toast.text.includes('OTA 升级成功'))); + d.boot(); + await d.h.advance(2000); + assert(d.toasts.some(toast => toast.text.includes('OTA 升级成功'))); +}); + +test('an old or rolled-back firmware never passes reboot confirmation and eventually stops polling', async () => { + const d = await device(); + await d.h.run("upgradeViaProxy('https://ota.fixture.invalid')"); + await d.h.advance(122000); + assert(!d.toasts.some(toast => toast.text.includes('OTA 升级成功'))); + assert.equal(d.h.run('rebootCheckInterval'), null); + assert(d.h.el('ota-state-text').textContent.includes('未确认')); +}); + +test('the correct partition running the wrong build is rejected', async () => { + const d = await device(); + await d.h.run("upgradeViaProxy('https://ota.fixture.invalid')"); + d.boot(); + d.state.running.version = '0.6.2+other-build'; + await d.h.advance(2000); + assert(!d.toasts.some(toast => toast.text.includes('OTA 升级成功'))); + assert(d.h.el('ota-state-text').textContent.includes('不符')); +}); + +test('unreadable boot target preserves the written-files outcome and does not issue a reboot', async () => { + const d = await device(); + d.state.partitionsError = new Error('status request interrupted'); + await d.h.run("upgradeViaProxy('https://ota.fixture.invalid')"); + assert.equal(d.state.firmwareWritten, true); + assert.equal(d.state.wwwWritten, true); + assert.equal(d.count('system.reboot'), 0); + assert(d.h.el('ota-state-text').textContent.includes('尚未请求重启')); +}); + +test('repeated SD-card WWW start failures keep the sibling path through the successful retry', async () => { + const d = await device(); + await d.h.run('otaFromFile()'); + d.state.app = 'pending_reboot'; + d.state.wwwStart = 7; + await d.h.run('refreshOtaProgress()'); + assert.equal(d.count('system.reboot'), 0); + await d.h.run('startWwwOta()'); + d.state.wwwStart = 0; + await d.h.run('startWwwOta()'); + const starts = d.calls.filter(call => call.endpoint === 'ota.www.start_sdcard'); + assert.equal(starts.length, 3); + assert.equal(d.count('ota.www.start'), 0); + assert(starts.every(call => call.params.file === '/sdcard/update/www.bin')); + assert.equal(d.count('system.reboot'), 0); + d.state.www = 'completed'; + d.state.wwwBusy = false; + await d.h.run('refreshOtaProgress()'); + assert.equal(d.count('system.reboot'), 1); +}); + +for (const pathExists of [true, false]) { + test(`leaving the OTA page during ${pathExists ? 'successful' : 'failed'} SD path lookup cannot acquire an upgrade lock`, async () => { + const d = await device(); + const reply = deferred(), reached = deferred(); + d.h.ctx.router = {navigation: {isCurrent: () => true}}; + d.h.ctx.apiCall = async (endpoint, params) => { + if (endpoint === 'storage.info') { reached.resolve(); return reply.promise; } + return d.apiCall(endpoint, params); + }; + d.h.run('api.call = apiCall'); + const start = d.h.run('otaFromFile()'); + await reached.promise; + d.h.ctx.router.navigation = {isCurrent: () => true}; + d.h.el('ota-progress-section').remove(); + reply.resolve(pathExists ? {code: 0, data: {type: 'file'}} : {code: 2}); + await start; + assert.equal(d.count('ota.upgrade_file'), 0); + assert.equal(d.h.run('otaUpgrade'), null); + assert.equal(d.h.doc.getElementById('toast')?.classList.contains('show') ?? false, false, + 'a disposed page must not report its path lookup'); + d.h.el('ota-progress-section'); + await d.h.run('otaFromUrl()'); + assert.equal(d.count('ota.upgrade_url'), 1, 'the next attempt must remain available'); + }); +} + +for (const source of ['URL', 'SD card']) for (const includeWww of [true, false]) { + test(`${source} acknowledgement off the OTA page resumes ${includeWww ? 'both images' : 'firmware-only'} on return`, async () => { + const d = await device('zh-CN', includeWww); + const reply = deferred(), reached = deferred(); + const startEndpoint = source === 'URL' ? 'ota.upgrade_url' : 'ota.upgrade_file'; + d.h.ctx.router = {navigation: {isCurrent: () => true}}; + d.h.ctx.apiCall = async (endpoint, params) => { + const result = await d.apiCall(endpoint, params); + if (endpoint === startEndpoint) { reached.resolve(); await reply.promise; } + return result; + }; + d.h.run('api.call = apiCall'); + const start = d.h.run(source === 'URL' ? 'otaFromUrl()' : 'otaFromFile()'); + await reached.promise; + d.h.ctx.router.navigation = {isCurrent: () => true}; + d.h.el('ota-state-text').remove(); + d.h.el('ota-progress-section').remove(); + reply.resolve(); + await start; + assert.equal(d.h.run('otaUpgrade.phase'), 'app', 'a successful device start must stay active'); + assert.equal(d.count('system.reboot'), 0); + assert(!d.toasts.some(toast => toast.type === 'error' || toast.type === 'warning')); + d.h.ctx.router.navigation = {isCurrent: () => true}; + d.h.el('ota-state-text'); + d.h.el('ota-progress-section'); + d.state.app = 'pending_reboot'; + await d.h.run('refreshOtaProgress()'); + if (includeWww) { + assert.equal(d.count(source === 'URL' ? 'ota.www.start' : 'ota.www.start_sdcard'), 1); + assert.equal(d.count('system.reboot'), 0, 'WWW is still being written'); + d.state.www = 'completed'; + d.state.wwwBusy = false; + await d.h.run('refreshOtaProgress()'); + } else assert.equal(d.count('ota.www.start') + d.count('ota.www.start_sdcard'), 0); + assert.equal(d.count('system.reboot'), 1); + d.boot(); + await d.h.advance(2000); + assert(d.toasts.some(toast => toast.text.includes('OTA 升级成功'))); + }); +} + +test('double clicks and another upgrade source cannot start a second active upgrade', async () => { + const d = await device(); + d.state.wwwDownloadGate = deferred(); + d.state.wwwDownloadReached = deferred(); + const first = d.h.run("upgradeViaProxy('https://ota.fixture.invalid')"); + await d.state.wwwDownloadReached.promise; + await d.h.run("upgradeViaProxy('https://ota.fixture.invalid')"); + await d.h.run('otaFromUrl()'); + assert.equal(d.count('https://ota.fixture.invalid/firmware'), 1); + assert.equal(d.count('ota.upgrade_url'), 0); + d.state.wwwDownloadGate.resolve(); + await first; + assert.equal(d.count('system.reboot'), 1); +}); + +test('URL upgrade after a failed SD-card attempt does not reuse the SD-card WWW path', async () => { + const d = await device(); + await d.h.run('otaFromFile()'); + d.state.app = 'pending_reboot'; + d.state.wwwStart = 7; + await d.h.run('refreshOtaProgress()'); + d.state.wwwStart = 0; + await d.h.run('otaFromUrl()'); + d.state.app = 'pending_reboot'; + await d.h.run('refreshOtaProgress()'); + assert.equal(d.count('ota.www.start'), 1); + assert.equal(d.count('ota.www.start_sdcard'), 1); +}); + +test('a lost reboot acknowledgement waits for the actual target and then succeeds', async () => { + const d = await device(); + d.state.rebootError = d.h.run("new ApiOperationError({code: 10}, 'system.reboot', {kind: 'network', uncertain: true})"); + await d.h.run("upgradeViaProxy('https://ota.fixture.invalid')"); + assert.equal(d.count('system.reboot'), 1); + assert(d.toasts.some(toast => toast.type === 'warning')); + assert(!d.toasts.some(toast => toast.text.includes('OTA 升级成功'))); + d.boot(); + await d.h.advance(2000); + assert(d.toasts.some(toast => toast.text.includes('OTA 升级成功'))); +}); + +test('an explicit reboot rejection reports the written files and stops verification', async () => { + const d = await device(); + d.state.rebootResult = {code: 3, message: 'Permission denied'}; + await d.h.run("upgradeViaProxy('https://ota.fixture.invalid')"); + assert(d.h.el('ota-state-text').textContent.includes('拒绝')); + assert.equal(d.h.run('rebootCheckInterval'), null); + assert.equal(d.state.wwwWritten, true); +}); + +test('manual abort releases the upgrade and allows the next attempt', async () => { + const d = await device(); + d.h.run('confirmAction = async () => true; refreshOtaInfo = async () => {}'); + await d.h.run('otaFromUrl()'); + await d.h.run('abortOta()'); + assert.equal(d.count('ota.abort'), 1); + assert.equal(d.h.el('ota-progress-section').style.display, 'none'); + assert.equal(d.count('system.reboot'), 0); + await d.h.run("upgradeViaProxy('https://ota.fixture.invalid')"); + assert.equal(d.count('system.reboot'), 1); +}); + +test('WWW completion cannot reboot while the WWW writer is still running', async () => { + const d = await device(); + await d.h.run('otaFromUrl()'); + d.state.app = 'pending_reboot'; + await d.h.run('refreshOtaProgress()'); + d.state.www = 'completed'; + await d.h.run('refreshOtaProgress()'); + assert.equal(d.count('system.reboot'), 0); + d.state.wwwBusy = false; + await d.h.run('refreshOtaProgress()'); + assert.equal(d.count('system.reboot'), 1); +}); + +test('pending manual abort prevents polling from requesting a reboot', async () => { + const d = await device('zh-CN', false); + d.h.run('confirmAction = async () => true; refreshOtaInfo = async () => {}'); + await d.h.run('otaFromUrl()'); + const reply = deferred(), reached = deferred(); + d.h.ctx.apiCall = async (endpoint, params) => { + if (endpoint === 'ota.abort') { reached.resolve(); return reply.promise; } + return d.apiCall(endpoint, params); + }; + d.h.run('api.call = apiCall'); + const abort = d.h.run('abortOta()'); + await reached.promise; + d.state.app = 'pending_reboot'; + await d.h.run('refreshOtaProgress()'); + assert.equal(d.count('system.reboot'), 0); + reply.resolve({code: 0}); + await abort; +}); + +test('a rejected manual abort resumes the original upgrade', async () => { + const d = await device('zh-CN', false); + d.h.run('confirmAction = async () => true'); + await d.h.run('otaFromUrl()'); + d.h.ctx.apiCall = async (endpoint, params) => endpoint === 'ota.abort' ? {code: 7, message: 'Abort refused'} : d.apiCall(endpoint, params); + d.h.run('api.call = apiCall'); + await d.h.run('abortOta()'); + d.state.app = 'pending_reboot'; + await d.h.run('refreshOtaProgress()'); + assert.equal(d.count('system.reboot'), 1); +}); + +test('a late WWW start error cannot overwrite an acknowledged manual abort', async () => { + const d = await device(); + d.h.run('confirmAction = async () => true; refreshOtaInfo = async () => {}'); + await d.h.run('otaFromUrl()'); + const reply = deferred(), reached = deferred(); + d.h.ctx.apiCall = async (endpoint, params) => { + if (endpoint === 'ota.www.start') { reached.resolve(); return reply.promise; } + return d.apiCall(endpoint, params); + }; + d.h.run('api.call = apiCall'); + d.state.app = 'pending_reboot'; + const poll = d.h.run('refreshOtaProgress()'); + await reached.promise; + await d.h.run('abortOta()'); + const title = d.h.el('ota-state-text').textContent; + reply.reject(d.h.run("new ApiOperationError({code: 10}, 'ota.www.start', {kind: 'network', uncertain: true})")); + await poll; + assert.equal(d.h.el('ota-state-text').textContent, title); + assert.equal(d.count('system.reboot'), 0); +}); diff --git a/tests/prompts/package.json b/tests/prompts/package.json index 1f66301..8ef3be5 100644 --- a/tests/prompts/package.json +++ b/tests/prompts/package.json @@ -5,8 +5,8 @@ "node": ">=22" }, "scripts": { - "test": "cd ../.. && node --test tests/prompts/static.test.cjs tests/prompts/regression.test.cjs tests/prompts/service-ownership.test.cjs tests/prompts/widgets-structure.test.cjs tests/prompts/delete-protection.test.cjs tests/prompts/key-generation.test.cjs tests/prompts/failure-catalog.test.cjs tests/prompts/delete-reference.test.cjs", - "test:browser": "cd ../.. && node --test tests/prompts/browser.test.cjs tests/prompts/ownership.browser.test.cjs tests/prompts/widgets-layout.test.cjs tests/prompts/delete-protection.browser.test.cjs tests/prompts/condition-editor.browser.test.cjs tests/prompts/input-repair.browser.test.cjs tests/prompts/ssh-copyid-cache.browser.test.cjs tests/prompts/ssh-fingerprint.browser.test.cjs tests/prompts/failure-messages.browser.test.cjs tests/prompts/flows.browser.test.cjs tests/prompts/key-generation.browser.test.cjs tests/prompts/key-generation-cache.browser.test.cjs tests/prompts/lpmu-lifecycle.browser.test.cjs tests/prompts/delete-reference.browser.test.cjs", + "test": "cd ../.. && node --test tests/prompts/static.test.cjs tests/prompts/regression.test.cjs tests/prompts/service-ownership.test.cjs tests/prompts/widgets-structure.test.cjs tests/prompts/delete-protection.test.cjs tests/prompts/key-generation.test.cjs tests/prompts/failure-catalog.test.cjs tests/prompts/delete-reference.test.cjs tests/prompts/ota.test.cjs", + "test:browser": "cd ../.. && node --test tests/prompts/browser.test.cjs tests/prompts/ownership.browser.test.cjs tests/prompts/widgets-layout.test.cjs tests/prompts/delete-protection.browser.test.cjs tests/prompts/condition-editor.browser.test.cjs tests/prompts/input-repair.browser.test.cjs tests/prompts/ssh-copyid-cache.browser.test.cjs tests/prompts/ssh-fingerprint.browser.test.cjs tests/prompts/failure-messages.browser.test.cjs tests/prompts/flows.browser.test.cjs tests/prompts/key-generation.browser.test.cjs tests/prompts/key-generation-cache.browser.test.cjs tests/prompts/lpmu-lifecycle.browser.test.cjs tests/prompts/delete-reference.browser.test.cjs tests/prompts/ota.browser.test.cjs", "test:xterm": "cd ../.. && node --test tests/prompts/xterm-vendor.test.cjs tests/prompts/xterm-offline.test.cjs" }, "devDependencies": {