mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}}.",
|
||||
|
||||
@@ -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: "تحديث التطبيق جاهز للتثبيت.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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}}.",
|
||||
|
||||
@@ -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}}.",
|
||||
|
||||
@@ -951,6 +951,9 @@ export const ja: TranslationResources = {
|
||||
upToDateWithLastChecked: "最新の状態です。最終確認: {{time}}。",
|
||||
pending: "更新の準備ができたらお知らせします。",
|
||||
pendingWithLastChecked: "更新の準備ができたらお知らせします。最終確認: {{time}}。",
|
||||
pendingWithVersion: "更新が見つかりました: {{version}}。ダウンロード中...",
|
||||
pendingWithVersionAndLastChecked:
|
||||
"更新が見つかりました: {{version}}。ダウンロード中... 最終確認: {{time}}。",
|
||||
availableWithVersion: "更新の準備ができました: {{version}}",
|
||||
availableWithVersionAndLastChecked:
|
||||
"更新の準備ができました: {{version}}。最終確認: {{time}}。",
|
||||
|
||||
@@ -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}}.",
|
||||
|
||||
@@ -959,6 +959,9 @@ export const ru: TranslationResources = {
|
||||
pending: "Мы сообщим вам, когда обновление будет готово.",
|
||||
pendingWithLastChecked:
|
||||
"Мы сообщим вам, когда обновление будет готово. Последняя проверка в {{time}}.",
|
||||
pendingWithVersion: "Найдено обновление: {{version}}. Загрузка...",
|
||||
pendingWithVersionAndLastChecked:
|
||||
"Найдено обновление: {{version}}. Загрузка... Последняя проверка в {{time}}.",
|
||||
availableWithVersion: "Обновление готово:{{version}}",
|
||||
availableWithVersionAndLastChecked:
|
||||
"Обновление готово:{{version}}. Последняя проверка в {{time}}.",
|
||||
|
||||
@@ -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 更新可安装。",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user