Show found desktop updates after manual checks (#1815)

* fix(desktop): report found app updates during manual checks

Manual checks could reuse cached update state while the renderer discarded pending update details. Keep found update versions visible while downloads prepare, and reserve install affordances for ready updates.

* test(desktop): cover manual update retry after errors

* fix(desktop): keep ready updates ready on recheck
This commit is contained in:
Mohamed Boudra
2026-06-30 08:12:15 +02:00
committed by GitHub
parent 5fc53c576e
commit 7c6152663e
16 changed files with 209 additions and 16 deletions

View File

@@ -4,8 +4,11 @@ import { getServerId } from "./helpers/server-id";
import {
loadRealDaemonState,
injectDesktopBridge,
openDesktopAboutSettings,
openDesktopSettings,
expectUpdateBanner,
clickCheckForUpdates,
expectPendingUpdateCheckResult,
clickInstallUpdate,
expectInstallInProgress,
interceptDaemonManagementConfirmDialog,
@@ -45,6 +48,21 @@ test.describe("Desktop updates", () => {
await clickInstallUpdate(page);
await expectInstallInProgress(page);
});
test("manual check reports a found update while it downloads", async ({ page }) => {
await injectDesktopBridge(page, {
serverId: getServerId(),
updateAvailable: true,
latestVersion: "1.2.3",
updateReadyToInstall: false,
});
await gotoAppShell(page);
await openDesktopAboutSettings(page);
await clickCheckForUpdates(page);
await expectPendingUpdateCheckResult(page, "1.2.3");
});
});
test.describe("Desktop daemon management", () => {

View File

@@ -3,7 +3,8 @@ import { appendFile } from "node:fs/promises";
import { expect, type Page } from "@playwright/test";
import { openSettings } from "./app";
import { getE2EDaemonPort } from "./daemon-port";
import { openSettingsHost, openSettingsHostSection } from "./settings";
import { escapeRegex } from "./regex";
import { openSettingsHost, openSettingsHostSection, openSettingsSection } from "./settings";
interface DaemonApiStatus {
version: string;
@@ -52,6 +53,7 @@ export interface DesktopBridgeConfig {
serverId: string;
updateAvailable?: boolean;
latestVersion?: string;
updateReadyToInstall?: boolean;
slowInstall?: boolean;
/** Initial PID reported by desktop_daemon_status. Defaults to null. */
daemonPid?: number | null;
@@ -169,7 +171,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
return cfg.updateAvailable
? {
hasUpdate: true,
readyToInstall: true,
readyToInstall: cfg.updateReadyToInstall ?? true,
currentVersion: "1.0.0",
latestVersion: cfg.latestVersion ?? "1.2.3",
body: null,
@@ -276,12 +278,33 @@ export async function openDesktopSettings(page: Page, serverId: string): Promise
});
}
export async function openDesktopAboutSettings(page: Page): Promise<void> {
await openSettings(page);
await openSettingsSection(page, "about");
await expect(page.getByText("App updates", { exact: true })).toBeVisible();
}
export async function expectUpdateBanner(page: Page, version: string): Promise<void> {
const callout = page.getByTestId("update-callout");
await expect(callout).toBeVisible({ timeout: 15_000 });
await expect(callout).toContainText(`v${version.replace(/^v/i, "")}`);
}
export async function clickCheckForUpdates(page: Page): Promise<void> {
await page.getByRole("button", { name: "Check" }).click();
}
export async function expectPendingUpdateCheckResult(page: Page, version: string): Promise<void> {
const normalizedVersion = `v${version.replace(/^v/i, "")}`;
await expect(
page.getByText(
new RegExp(`Update found: ${escapeRegex(normalizedVersion)}\\. Downloading\\.\\.\\.`),
),
).toBeVisible();
await expect(page.getByText(`Ready to install: ${normalizedVersion}`)).toHaveCount(0);
await expect(page.getByRole("button", { name: "Update" })).toBeDisabled();
}
export async function clickInstallUpdate(page: Page): Promise<void> {
await page.getByRole("button", { name: "Install & restart" }).click();
}

View File

@@ -114,15 +114,17 @@ describe("desktop app updater — check", () => {
});
});
it("reports 'pending' when the check resolves with an update that is not yet downloaded", async () => {
it("reports the found update while it is still preparing", async () => {
const { updater, port } = createUpdater();
port.nextCheckResult(buildFakeCheckResult({ hasUpdate: true, readyToInstall: false }));
port.nextCheckResult(
buildFakeCheckResult({ hasUpdate: true, readyToInstall: false, latestVersion: "1.2.3" }),
);
await updater.checkForUpdates({ releaseChannel: "stable" });
expect(updater.getSnapshot()).toMatchObject({
status: "pending",
availableUpdate: null,
availableUpdate: { latestVersion: "1.2.3", readyToInstall: false },
});
});
@@ -408,17 +410,17 @@ describe("formatStatusText", () => {
).toBe("Update ready: v1.2.3");
});
it("keeps manual check feedback visible while an update is pending", () => {
it("shows the found version while an update is pending", () => {
expect(
formatStatusText({
status: "pending",
availableUpdate: null,
availableUpdate: buildFakeCheckResult({ latestVersion: "1.2.3" }),
installMessage: null,
lastCheckedAt: 42,
formatVersion,
formatLastCheckedAt,
}),
).toBe("We'll let you know when the update is ready. Last checked at time-42.");
).toBe("Update found: v1.2.3. Downloading... Last checked at time-42.");
});
it("keeps manual check feedback visible when an update is available", () => {

View File

@@ -137,6 +137,18 @@ export function formatStatusText(input: {
}
if (status === "pending") {
if (availableUpdate?.latestVersion) {
return i18n.t(
lastCheckedAt != null
? "desktop.updates.status.pendingWithVersionAndLastChecked"
: "desktop.updates.status.pendingWithVersion",
{
version: formatVersion(availableUpdate.latestVersion),
time: lastCheckedAt != null ? formatLastCheckedAt(lastCheckedAt) : undefined,
},
);
}
if (lastCheckedAt != null) {
return i18n.t("desktop.updates.status.pendingWithLastChecked", {
time: formatLastCheckedAt(lastCheckedAt),
@@ -244,7 +256,7 @@ export function createDesktopAppUpdater(deps: DesktopAppUpdaterDeps): DesktopApp
nextAvailable = result;
} else if (result.hasUpdate) {
nextStatus = "pending";
nextAvailable = null;
nextAvailable = result;
} else {
nextStatus = "up-to-date";
nextAvailable = null;

View File

@@ -524,6 +524,12 @@ describe("translation resources", () => {
expect(en.desktop.updates.status.pendingWithLastChecked).toBe(
"We'll let you know when the update is ready. Last checked at {{time}}.",
);
expect(en.desktop.updates.status.pendingWithVersion).toBe(
"Update found: {{version}}. Downloading...",
);
expect(en.desktop.updates.status.pendingWithVersionAndLastChecked).toBe(
"Update found: {{version}}. Downloading... Last checked at {{time}}.",
);
expect(en.desktop.updates.status.availableWithVersion).toBe("Update ready: {{version}}");
expect(en.desktop.updates.status.availableWithVersionAndLastChecked).toBe(
"Update ready: {{version}}. Last checked at {{time}}.",

View File

@@ -939,6 +939,9 @@ export const ar: TranslationResources = {
upToDateWithLastChecked: "Up to date. Last checked at {{time}}.",
pending: "سنخبرك عندما يصبح التحديث جاهزًا.",
pendingWithLastChecked: "سنخبرك عندما يصبح التحديث جاهزًا. آخر فحص في {{time}}.",
pendingWithVersion: "تم العثور على تحديث: {{version}}. جارٍ التنزيل...",
pendingWithVersionAndLastChecked:
"تم العثور على تحديث: {{version}}. جارٍ التنزيل... آخر فحص في {{time}}.",
availableWithVersion: "التحديث جاهز:{{version}}",
availableWithVersionAndLastChecked: "التحديث جاهز:{{version}}. آخر فحص في {{time}}.",
available: "تحديث التطبيق جاهز للتثبيت.",

View File

@@ -947,6 +947,9 @@ export const en = {
pending: "We'll let you know when the update is ready.",
pendingWithLastChecked:
"We'll let you know when the update is ready. Last checked at {{time}}.",
pendingWithVersion: "Update found: {{version}}. Downloading...",
pendingWithVersionAndLastChecked:
"Update found: {{version}}. Downloading... Last checked at {{time}}.",
availableWithVersion: "Update ready: {{version}}",
availableWithVersionAndLastChecked: "Update ready: {{version}}. Last checked at {{time}}.",
available: "An app update is ready to install.",

View File

@@ -967,6 +967,9 @@ export const es: TranslationResources = {
pending: "Le avisaremos cuando la actualización esté lista.",
pendingWithLastChecked:
"Le avisaremos cuando la actualización esté lista. Última comprobación a las {{time}}.",
pendingWithVersion: "Actualización encontrada: {{version}}. Descargando...",
pendingWithVersionAndLastChecked:
"Actualización encontrada: {{version}}. Descargando... Última comprobación a las {{time}}.",
availableWithVersion: "Actualización lista:{{version}}",
availableWithVersionAndLastChecked:
"Actualización lista:{{version}}. Última comprobación a las {{time}}.",

View File

@@ -966,6 +966,9 @@ export const fr: TranslationResources = {
pending: "Nous vous informerons lorsque la mise à jour sera prête.",
pendingWithLastChecked:
"Nous vous informerons lorsque la mise à jour sera prête. Dernière vérification à {{time}}.",
pendingWithVersion: "Mise à jour trouvée : {{version}}. Téléchargement...",
pendingWithVersionAndLastChecked:
"Mise à jour trouvée : {{version}}. Téléchargement... Dernière vérification à {{time}}.",
availableWithVersion: "Mise à jour prête:{{version}}",
availableWithVersionAndLastChecked:
"Mise à jour prête:{{version}}. Dernière vérification à {{time}}.",

View File

@@ -951,6 +951,9 @@ export const ja: TranslationResources = {
upToDateWithLastChecked: "最新の状態です。最終確認: {{time}}。",
pending: "更新の準備ができたらお知らせします。",
pendingWithLastChecked: "更新の準備ができたらお知らせします。最終確認: {{time}}。",
pendingWithVersion: "更新が見つかりました: {{version}}。ダウンロード中...",
pendingWithVersionAndLastChecked:
"更新が見つかりました: {{version}}。ダウンロード中... 最終確認: {{time}}。",
availableWithVersion: "更新の準備ができました: {{version}}",
availableWithVersionAndLastChecked:
"更新の準備ができました: {{version}}。最終確認: {{time}}。",

View File

@@ -958,6 +958,9 @@ export const ptBR: TranslationResources = {
pending: "Avisaremos quando a atualização estiver pronta.",
pendingWithLastChecked:
"Avisaremos quando a atualização estiver pronta. Última verificação às {{time}}.",
pendingWithVersion: "Atualização encontrada: {{version}}. Baixando...",
pendingWithVersionAndLastChecked:
"Atualização encontrada: {{version}}. Baixando... Última verificação às {{time}}.",
availableWithVersion: "Atualização pronta: {{version}}",
availableWithVersionAndLastChecked:
"Atualização pronta: {{version}}. Última verificação às {{time}}.",

View File

@@ -959,6 +959,9 @@ export const ru: TranslationResources = {
pending: "Мы сообщим вам, когда обновление будет готово.",
pendingWithLastChecked:
"Мы сообщим вам, когда обновление будет готово. Последняя проверка в {{time}}.",
pendingWithVersion: "Найдено обновление: {{version}}. Загрузка...",
pendingWithVersionAndLastChecked:
"Найдено обновление: {{version}}. Загрузка... Последняя проверка в {{time}}.",
availableWithVersion: "Обновление готово:{{version}}",
availableWithVersionAndLastChecked:
"Обновление готово:{{version}}. Последняя проверка в {{time}}.",

View File

@@ -928,6 +928,9 @@ export const zhCN: TranslationResources = {
upToDateWithLastChecked: "已是最新版本。上次检查时间:{{time}}。",
pending: "更新准备好后会通知你。",
pendingWithLastChecked: "更新准备好后会通知你。上次检查时间:{{time}}。",
pendingWithVersion: "发现更新:{{version}}。正在下载...",
pendingWithVersionAndLastChecked:
"发现更新:{{version}}。正在下载... 上次检查时间:{{time}}。",
availableWithVersion: "更新已就绪:{{version}}",
availableWithVersionAndLastChecked: "更新已就绪:{{version}}。上次检查时间:{{time}}。",
available: "有 app 更新可安装。",

View File

@@ -701,6 +701,9 @@ function DesktopAppUpdateRow() {
});
}, [installUpdate, isDesktopApp, t]);
const isUpdateReady = availableUpdate?.readyToInstall === true;
const readyUpdateVersion = isUpdateReady ? availableUpdate?.latestVersion : null;
if (!isDesktopApp) {
return null;
}
@@ -725,10 +728,10 @@ function DesktopAppUpdateRow() {
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.about.updates.label")}</Text>
<Text style={settingsStyles.rowHint}>{statusText}</Text>
{availableUpdate?.latestVersion ? (
{readyUpdateVersion ? (
<Text style={settingsStyles.rowHint}>
{t("settings.about.updates.readyToInstall", {
version: formatVersionWithPrefix(availableUpdate.latestVersion),
version: formatVersionWithPrefix(readyUpdateVersion),
})}
</Text>
) : null}
@@ -747,9 +750,9 @@ function DesktopAppUpdateRow() {
variant="default"
size="sm"
onPress={handleInstallUpdate}
disabled={isChecking || isInstalling || !availableUpdate}
disabled={isChecking || isInstalling || !isUpdateReady}
>
{getUpdateButtonLabel(t, isInstalling, availableUpdate?.latestVersion)}
{getUpdateButtonLabel(t, isInstalling, readyUpdateVersion)}
</Button>
</View>
</View>

View File

@@ -58,6 +58,10 @@ class FakeAppUpdateRuntime implements AppUpdateRuntime {
this.configuration?.onUpdateAvailable(info);
}
finishUpdateDownload(info: RuntimeUpdateInfo): void {
this.configuration?.onUpdateDownloaded(info);
}
async checkForUpdates(): Promise<{
isUpdateAvailable: boolean;
updateInfo: RuntimeUpdateInfo;
@@ -144,6 +148,37 @@ describe("app update service", () => {
});
});
it("performs a fresh manual check when an update is already cached", async () => {
const { runtime, service } = createService({ bucket: async () => 0 });
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
await service.checkForAppUpdate({
currentVersion: "1.2.3",
releaseChannel: "stable",
intent: "automatic",
});
runtime.nextCheck({
isUpdateAvailable: true,
updateInfo: { ...rolledOutUpdate, version: "1.2.5" },
});
const result = await service.checkForAppUpdate({
currentVersion: "1.2.3",
releaseChannel: "stable",
intent: "manual",
});
expect(result).toEqual({
hasUpdate: true,
readyToInstall: false,
currentVersion: "1.2.3",
latestVersion: "1.2.5",
body: null,
date: "2026-04-28T00:00:00.000Z",
errorMessage: null,
});
});
it("trusts the runtime availability decision before comparing versions", async () => {
const { runtime, service } = createService({ bucket: async () => 0 });
runtime.nextCheck({ isUpdateAvailable: false, updateInfo: rolledOutUpdate });
@@ -347,6 +382,70 @@ describe("app update service", () => {
});
});
it("performs a fresh manual check after an update preparation error", async () => {
const { runtime, service } = createService();
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
await service.checkForAppUpdate({
currentVersion: "1.2.3",
releaseChannel: "stable",
intent: "manual",
});
runtime.failRuntime(new Error("sha512 checksum mismatch"));
runtime.nextCheck({
isUpdateAvailable: true,
updateInfo: { ...rolledOutUpdate, version: "1.2.5" },
});
const result = await service.checkForAppUpdate({
currentVersion: "1.2.3",
releaseChannel: "stable",
intent: "manual",
});
expect(result).toEqual({
hasUpdate: true,
readyToInstall: false,
currentVersion: "1.2.3",
latestVersion: "1.2.5",
body: null,
date: "2026-04-28T00:00:00.000Z",
errorMessage: null,
});
});
it("keeps a downloaded update ready when a manual check re-announces it", async () => {
const { runtime, service } = createService();
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
await service.checkForAppUpdate({
currentVersion: "1.2.3",
releaseChannel: "stable",
intent: "manual",
});
runtime.finishUpdateDownload(rolledOutUpdate);
const recheck = runtime.deferNextCheck();
const pending = service.checkForAppUpdate({
currentVersion: "1.2.3",
releaseChannel: "stable",
intent: "manual",
});
runtime.prepareUpdate(rolledOutUpdate);
recheck.resolve({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
const result = await pending;
expect(result).toEqual({
hasUpdate: true,
readyToInstall: true,
currentVersion: "1.2.3",
latestVersion: "1.2.4",
body: null,
date: "2026-04-28T00:00:00.000Z",
errorMessage: null,
});
});
it("returns runtime update errors to multiple automatic checks before a manual retry clears them", async () => {
const { runtime, service } = createService();
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });

View File

@@ -163,9 +163,10 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
});
},
onUpdateAvailable(info) {
const alreadyReady = downloadedUpdateVersion === info.version;
cachedUpdateInfo = info;
downloadedUpdateVersion = null;
downloading = true;
downloadedUpdateVersion = alreadyReady ? info.version : null;
downloading = !alreadyReady;
runtimeErrorMessage = null;
},
onUpdateDownloaded(info) {
@@ -212,7 +213,12 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
}
const cachedVersion = cachedUpdateInfo?.version ?? null;
if (!runtimeErrorResult && cachedVersion && cachedVersion !== currentVersion) {
if (
!runtimeErrorResult &&
intent === "automatic" &&
cachedVersion &&
cachedVersion !== currentVersion
) {
return buildCheckResult({
currentVersion,
hasUpdate: true,