From bbcafe63f4871712c733ed9fbb05da40956ebde1 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 24 Jul 2026 10:19:09 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(update):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20Windows=20=E5=9C=A8=E7=BA=BF=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=A4=9A=E5=AE=9E=E4=BE=8B=E7=A1=AE=E8=AE=A4=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 安装前获取维护锁并枚举同目录进程,仅检测到其他实例时触发关闭确认 - 改用 Windows 原生系统模态对话框并先收起下载进度窗口,避免确认提示被遮挡 - 单实例直接进入安装,取消确认或退出检查中止后恢复下载完成状态 - 多语言确认文案展示实际实例数量,补齐前后端与发布工作流回归测试 --- .github/workflows/dev-build.yml | 2 +- .github/workflows/release.yml | 2 +- frontend/src/App.tsx | 44 +++--- frontend/src/App.update-quit-guard.test.ts | 34 +++-- .../src/hooks/useAppUpdateManager.test.tsx | 33 ++++ frontend/src/hooks/useAppUpdateManager.ts | 10 ++ internal/app/methods_update.go | 106 ++++++++----- .../methods_update_windows_process_test.go | 144 ++++++++++++++++-- internal/app/windows_update_instances_test.go | 27 ++-- internal/app/windows_update_process.go | 25 ++- shared/i18n/de-DE.json | 4 +- shared/i18n/en-US.json | 4 +- shared/i18n/ja-JP.json | 4 +- shared/i18n/ru-RU.json | 4 +- shared/i18n/zh-CN.json | 4 +- shared/i18n/zh-TW.json | 4 +- tools/windows-release-artifacts.test.py | 1 + 17 files changed, 342 insertions(+), 110 deletions(-) diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml index ebc7d9e7..d64a9f53 100644 --- a/.github/workflows/dev-build.yml +++ b/.github/workflows/dev-build.yml @@ -533,7 +533,7 @@ jobs: - name: Test Windows Online Updater (Windows AMD64) if: ${{ matrix.platform == 'windows/amd64' }} shell: pwsh - run: go test . ./internal/app -run '^(TestShouldEnableWindowsMSISingleInstanceOnlyForInstalledMainGUI|TestPrimaryWindowActivatorQueuesRequestsUntilRuntimeStartup|TestAcquireWindowsMSISingleInstance|TestResolveWindowsUpdateMaintenanceName|TestAcquireWindowsUpdateMaintenance|TestPrepareWindowsUpdateHandoff|TestWindowsPowerShellUpdater|TestBuildWindowsLaunchCommandUsesHiddenPowerShellFile|TestExpectedAssetNameForExecutableUsesWindowsPortableSuffix|TestShouldWindowsUpdateLaunchDownloadedAssetDirectly|TestResolveUpdateInstallMode|TestExpectedAssetNameForWindowsInstallMode|TestPortableUpdatePackageAcceptsZipAndLegacyExe|TestResolveReusableStagedUpdateForPlatformReusesPortableZipInsideStagedDir|TestResolveWindowsUpdateFinalTargetPathMapsDownloadedPortableZipToExe|TestResolveUpdateWorkspaceDirForPlatformSeparatesMSIFromInstallDirectory|TestValidateUpdatePackageForCurrentInstallMode|TestWindowsUpdateRequiresExplicitCloseConfirmation|TestInstallUpdateAndRestartRequiresCloseConfirmationOnWindows|TestInstallUpdateAndRestartMSI|TestFindOtherWindowsUpdateInstances|TestCloseWindowsUpdateInstances|TestInstallUpdateAndRestartClosesOtherTargetInstances|TestBuildWindowsMSIUpdatePowerShellScript|TestBuildWindowsMSILaunchCommand|TestResolveWindowsMSIExecPath)' -count=1 -timeout=3m + run: go test . ./internal/app -run '^(TestShouldEnableWindowsMSISingleInstanceOnlyForInstalledMainGUI|TestPrimaryWindowActivatorQueuesRequestsUntilRuntimeStartup|TestAcquireWindowsMSISingleInstance|TestResolveWindowsUpdateMaintenanceName|TestAcquireWindowsUpdateMaintenance|TestPrepareWindowsUpdateHandoff|TestWindowsPowerShellUpdater|TestBuildWindowsLaunchCommandUsesHiddenPowerShellFile|TestExpectedAssetNameForExecutableUsesWindowsPortableSuffix|TestShouldWindowsUpdateLaunchDownloadedAssetDirectly|TestResolveUpdateInstallMode|TestExpectedAssetNameForWindowsInstallMode|TestPortableUpdatePackageAcceptsZipAndLegacyExe|TestResolveReusableStagedUpdateForPlatformReusesPortableZipInsideStagedDir|TestResolveWindowsUpdateFinalTargetPathMapsDownloadedPortableZipToExe|TestResolveUpdateWorkspaceDirForPlatformSeparatesMSIFromInstallDirectory|TestValidateUpdatePackageForCurrentInstallMode|TestWindowsUpdateRequiresExplicitCloseConfirmation|TestInstallUpdateAndRestartRequiresCloseConfirmationOnWindows|TestInstallUpdateAndRestartSkipsCloseConfirmationForSingleWindowsInstance|TestInstallUpdateAndRestartMSI|TestFindOtherWindowsUpdateInstances|TestCloseWindowsUpdateInstances|TestInstallUpdateAndRestartClosesOtherTargetInstances|TestBuildWindowsMSIUpdatePowerShellScript|TestBuildWindowsMSILaunchCommand|TestResolveWindowsMSIExecPath)' -count=1 -timeout=3m # ---- 生成 dev 版本号 ---- - name: Generate Dev Version diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e54b209d..380c8f58 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -490,7 +490,7 @@ jobs: - name: Test Windows Online Updater (Windows AMD64) if: ${{ matrix.platform == 'windows/amd64' }} shell: pwsh - run: go test . ./internal/app -run '^(TestShouldEnableWindowsMSISingleInstanceOnlyForInstalledMainGUI|TestPrimaryWindowActivatorQueuesRequestsUntilRuntimeStartup|TestAcquireWindowsMSISingleInstance|TestResolveWindowsUpdateMaintenanceName|TestAcquireWindowsUpdateMaintenance|TestPrepareWindowsUpdateHandoff|TestWindowsPowerShellUpdater|TestBuildWindowsLaunchCommandUsesHiddenPowerShellFile|TestExpectedAssetNameForExecutableUsesWindowsPortableSuffix|TestShouldWindowsUpdateLaunchDownloadedAssetDirectly|TestResolveUpdateInstallMode|TestExpectedAssetNameForWindowsInstallMode|TestPortableUpdatePackageAcceptsZipAndLegacyExe|TestResolveReusableStagedUpdateForPlatformReusesPortableZipInsideStagedDir|TestResolveWindowsUpdateFinalTargetPathMapsDownloadedPortableZipToExe|TestResolveUpdateWorkspaceDirForPlatformSeparatesMSIFromInstallDirectory|TestValidateUpdatePackageForCurrentInstallMode|TestWindowsUpdateRequiresExplicitCloseConfirmation|TestInstallUpdateAndRestartRequiresCloseConfirmationOnWindows|TestInstallUpdateAndRestartMSI|TestFindOtherWindowsUpdateInstances|TestCloseWindowsUpdateInstances|TestInstallUpdateAndRestartClosesOtherTargetInstances|TestBuildWindowsMSIUpdatePowerShellScript|TestBuildWindowsMSILaunchCommand|TestResolveWindowsMSIExecPath)' -count=1 -timeout=3m + run: go test . ./internal/app -run '^(TestShouldEnableWindowsMSISingleInstanceOnlyForInstalledMainGUI|TestPrimaryWindowActivatorQueuesRequestsUntilRuntimeStartup|TestAcquireWindowsMSISingleInstance|TestResolveWindowsUpdateMaintenanceName|TestAcquireWindowsUpdateMaintenance|TestPrepareWindowsUpdateHandoff|TestWindowsPowerShellUpdater|TestBuildWindowsLaunchCommandUsesHiddenPowerShellFile|TestExpectedAssetNameForExecutableUsesWindowsPortableSuffix|TestShouldWindowsUpdateLaunchDownloadedAssetDirectly|TestResolveUpdateInstallMode|TestExpectedAssetNameForWindowsInstallMode|TestPortableUpdatePackageAcceptsZipAndLegacyExe|TestResolveReusableStagedUpdateForPlatformReusesPortableZipInsideStagedDir|TestResolveWindowsUpdateFinalTargetPathMapsDownloadedPortableZipToExe|TestResolveUpdateWorkspaceDirForPlatformSeparatesMSIFromInstallDirectory|TestValidateUpdatePackageForCurrentInstallMode|TestWindowsUpdateRequiresExplicitCloseConfirmation|TestInstallUpdateAndRestartRequiresCloseConfirmationOnWindows|TestInstallUpdateAndRestartSkipsCloseConfirmationForSingleWindowsInstance|TestInstallUpdateAndRestartMSI|TestFindOtherWindowsUpdateInstances|TestCloseWindowsUpdateInstances|TestInstallUpdateAndRestartClosesOtherTargetInstances|TestBuildWindowsMSIUpdatePowerShellScript|TestBuildWindowsMSILaunchCommand|TestResolveWindowsMSIExecPath)' -count=1 -timeout=3m - name: Build shell: bash diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8e05e83c..f1325f62 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2583,12 +2583,20 @@ function App() { } }, [t]); - const handleApplicationQuitRequest = useCallback(async (confirmedAction?: ApplicationQuitConfirmedAction) => { + const handleApplicationQuitRequest = useCallback(async ( + confirmedAction?: ApplicationQuitConfirmedAction, + cancelledAction?: () => void, + ) => { if (applicationQuitHandlingRef.current) { return; } applicationQuitHandlingRef.current = true; + const cancelRequest = () => { + resetApplicationQuitRequest(); + cancelledAction?.(); + }; + const runConfirmedAction = async (): Promise => { let accepted = false; try { @@ -2601,14 +2609,14 @@ function App() { accepted = true; } } catch (error) { - resetApplicationQuitRequest(); + cancelRequest(); message.error(t('app.quit.message.quit_failed', { detail: error instanceof Error ? error.message : String(error), })); return false; } if (!accepted) { - resetApplicationQuitRequest(); + cancelRequest(); } return accepted; }; @@ -2622,7 +2630,7 @@ function App() { latestState.savedQueries, ); } catch (error) { - resetApplicationQuitRequest(); + cancelRequest(); message.error(t('app.quit.unsaved_sql.inspect_failed', { detail: error instanceof Error ? error.message : String(error), })); @@ -2663,14 +2671,14 @@ function App() { ), onCancel: () => { - resetApplicationQuitRequest(); + cancelRequest(); }, onOk: async () => { try { await saveApplicationQuitUnsavedSQLTargets(targets, saveQuery); message.success(t('app.quit.unsaved_sql.saved')); } catch (error) { - resetApplicationQuitRequest(); + cancelRequest(); message.error(t('app.quit.unsaved_sql.save_failed_cancel_exit', { detail: error instanceof Error ? error.message : String(error), })); @@ -2684,24 +2692,12 @@ function App() { }, [applicationQuitModalZIndex, ensureSavedQueriesLoaded, forceQuitApplication, resetApplicationQuitRequest, saveQuery, t]); const handleInstallUpdateRequest = useCallback(async () => { - if (installMode === 'portable' || installMode === 'msi') { - Modal.confirm({ - title: t('app.about.update_install_confirm.close_instances_title'), - content: t('app.about.update_install_confirm.close_instances_content'), - okText: t('app.about.update_install_confirm.close_instances_ok'), - cancelText: t('common.cancel'), - closable: true, - maskClosable: false, - zIndex: applicationQuitModalZIndex, - okButtonProps: { danger: true, type: 'primary' }, - onOk: async () => { - await handleApplicationQuitRequest(() => handleInstallFromProgress(true)); - }, - }); - return; - } - await handleApplicationQuitRequest(() => handleInstallFromProgress(false)); - }, [applicationQuitModalZIndex, handleApplicationQuitRequest, handleInstallFromProgress, installMode, t]); + hideUpdateDownloadProgress(); + await handleApplicationQuitRequest( + () => handleInstallFromProgress(false), + showUpdateDownloadProgress, + ); + }, [handleApplicationQuitRequest, handleInstallFromProgress, hideUpdateDownloadProgress, showUpdateDownloadProgress]); useEffect(() => { const offBeforeClose = EventsOn('app:before-close-request', () => { diff --git a/frontend/src/App.update-quit-guard.test.ts b/frontend/src/App.update-quit-guard.test.ts index e33285e4..89851959 100644 --- a/frontend/src/App.update-quit-guard.test.ts +++ b/frontend/src/App.update-quit-guard.test.ts @@ -10,15 +10,18 @@ const appSource = readFileSync( describe('restart-to-update unsaved SQL guard', () => { it('runs the confirmed update action through every application quit path', () => { expect(appSource).toContain('type ApplicationQuitConfirmedAction = () => Promise;'); - expect(appSource).toContain('const handleApplicationQuitRequest = useCallback(async (confirmedAction?: ApplicationQuitConfirmedAction) => {'); + expect(appSource).toContain('const handleApplicationQuitRequest = useCallback(async ('); + expect(appSource).toContain('confirmedAction?: ApplicationQuitConfirmedAction,'); + expect(appSource).toContain('cancelledAction?: () => void,'); expect(appSource).toContain('const runConfirmedAction = async (): Promise => {'); const runnerStart = appSource.indexOf('const runConfirmedAction = async (): Promise => {'); const runnerEnd = appSource.indexOf('\n let targets;', runnerStart); const runnerSource = appSource.slice(runnerStart, runnerEnd); expect(runnerSource).toContain('accepted = await confirmedAction();'); expect(runnerSource).toContain('await forceQuitApplication();\n accepted = true;'); - expect(runnerSource).toContain('} catch (error) {\n resetApplicationQuitRequest();'); - expect(runnerSource).toContain('if (!accepted) {\n resetApplicationQuitRequest();'); + expect(runnerSource).toContain('} catch (error) {\n cancelRequest();'); + expect(runnerSource).toContain('if (!accepted) {\n cancelRequest();'); + expect(appSource).toContain('resetApplicationQuitRequest();\n cancelledAction?.();'); expect(runnerSource).toContain('return accepted;'); expect(appSource).toContain('if (targets.length === 0) {\n await runConfirmedAction();'); expect(appSource).toContain('void runConfirmedAction();'); @@ -57,15 +60,20 @@ describe('restart-to-update unsaved SQL guard', () => { expect(loaderSource).not.toContain('await reloadSavedQueryGroups();'); }); - it('confirms closing every Windows instance before entering the unsaved SQL guard', () => { + it('lets the backend confirm only actually running Windows instances after the unsaved SQL guard', () => { expect(appSource).toContain('const handleInstallUpdateRequest = useCallback(async () => {'); - expect(appSource).toContain("if (installMode === 'portable' || installMode === 'msi') {"); - expect(appSource).toContain("title: t('app.about.update_install_confirm.close_instances_title')"); - expect(appSource).toContain("content: t('app.about.update_install_confirm.close_instances_content')"); - expect(appSource).toContain("okText: t('app.about.update_install_confirm.close_instances_ok')"); - expect(appSource).toContain("cancelText: t('common.cancel')"); - expect(appSource).toContain('await handleApplicationQuitRequest(() => handleInstallFromProgress(true));'); - expect(appSource).toContain('await handleApplicationQuitRequest(() => handleInstallFromProgress(false));'); + const installRequestStart = appSource.indexOf('const handleInstallUpdateRequest = useCallback(async () => {'); + const installRequestEnd = appSource.indexOf('\n\n useEffect(() => {', installRequestStart); + const installRequestSource = appSource.slice(installRequestStart, installRequestEnd); + expect(installRequestSource.indexOf('hideUpdateDownloadProgress();')).toBeGreaterThan(-1); + expect(installRequestSource.indexOf('await handleApplicationQuitRequest(')).toBeGreaterThan(-1); + expect(installRequestSource.indexOf('hideUpdateDownloadProgress();')).toBeLessThan( + installRequestSource.indexOf('await handleApplicationQuitRequest('), + ); + expect(installRequestSource).toContain('() => handleInstallFromProgress(false),'); + expect(installRequestSource).toContain('showUpdateDownloadProgress,'); + expect(appSource).not.toContain("title: t('app.about.update_install_confirm.close_instances_title')"); + expect(appSource).not.toContain('handleInstallFromProgress(true)'); expect(appSource.match(/void handleInstallUpdateRequest\(\);/g)).toHaveLength(2); expect(appSource).not.toContain('onClick={handleInstallFromProgress}'); expect(appSource).toContain("updateInstallAction === 'install-and-restart'"); @@ -73,7 +81,7 @@ describe('restart-to-update unsaved SQL guard', () => { expect(appSource.match(/\{updateInstallActionLabel\}/g)).toHaveLength(2); }); - it('keeps every update quit confirmation above active settings and update dialogs', () => { + it('keeps the unsaved SQL quit confirmation above active settings and update dialogs', () => { const unsavedConfirmStart = appSource.indexOf('const confirmRef = Modal.confirm({'); const installRequestStart = appSource.indexOf('const handleInstallUpdateRequest = useCallback', unsavedConfirmStart); const installRequestEnd = appSource.indexOf('\n\n useEffect(() => {', installRequestStart); @@ -87,6 +95,6 @@ describe('restart-to-update unsaved SQL guard', () => { expect(appSource).toContain('const applicationQuitModalZIndex = Math.max('); expect(appSource).toContain('settingsChildModalZIndex + 100,'); expect(unsavedConfirmSource).toContain('zIndex: applicationQuitModalZIndex'); - expect(installRequestSource).toContain('zIndex: applicationQuitModalZIndex'); + expect(installRequestSource).not.toContain('Modal.confirm({'); }); }); diff --git a/frontend/src/hooks/useAppUpdateManager.test.tsx b/frontend/src/hooks/useAppUpdateManager.test.tsx index f6544327..88dea93d 100644 --- a/frontend/src/hooks/useAppUpdateManager.test.tsx +++ b/frontend/src/hooks/useAppUpdateManager.test.tsx @@ -568,6 +568,39 @@ describe('useAppUpdateManager', () => { ); }); + it('restores the ready state without an error toast when Windows instance confirmation is cancelled', async () => { + backendApp.CheckForUpdates.mockResolvedValue({ + success: true, + data: { + hasUpdate: true, + currentVersion: '0.8.1', + latestVersion: '0.8.2', + downloaded: true, + assetSize: 2048, + installMode: 'portable', + packageType: 'portable', + }, + }); + backendApp.InstallUpdateAndRestart.mockResolvedValue({ + success: false, + data: { cancelled: true }, + }); + + renderHook(); + await act(async () => { + await hook?.checkForUpdates(false); + }); + + let accepted = true; + await act(async () => { + accepted = await hook!.handleInstallFromProgress(); + }); + + expect(accepted).toBe(false); + expect(hook?.updateDownloadProgress.status).toBe('done'); + expect(messageApi.error).not.toHaveBeenCalled(); + }); + it('returns false without calling the backend when no update is ready', async () => { renderHook(); diff --git a/frontend/src/hooks/useAppUpdateManager.ts b/frontend/src/hooks/useAppUpdateManager.ts index 954a6e5e..3eccbc71 100644 --- a/frontend/src/hooks/useAppUpdateManager.ts +++ b/frontend/src/hooks/useAppUpdateManager.ts @@ -414,6 +414,16 @@ export const useAppUpdateManager = ({ res = { success: false, message: error?.message || t('common.unknown') }; } if (!res?.success) { + if (res?.data?.cancelled === true) { + setUpdateDownloadProgress((prev) => ({ + ...prev, + open: true, + status: 'done', + percent: 100, + message: '', + })); + return false; + } setUpdateDownloadProgress((prev) => ({ ...prev, open: true, diff --git a/internal/app/methods_update.go b/internal/app/methods_update.go index 810ecb60..f266531c 100644 --- a/internal/app/methods_update.go +++ b/internal/app/methods_update.go @@ -47,18 +47,19 @@ type cachedGitHubRelease struct { var updateReleaseCache sync.Map // apiURL -> cachedGitHubRelease var ( - updateFetchLatestRelease = fetchLatestRelease - updateFetchDevRelease = fetchDevRelease - updateFetchReleaseSHA256 = fetchReleaseSHA256 - updateLogCheckError = func(err error) { logger.Error(err, "检查更新失败") } - updateResolveInstallTarget = resolveUpdateInstallTarget - updateResolveInstallMode = resolveCurrentUpdateInstallMode - updateLaunchInstallScript = launchUpdateScript - updateFindOtherWindowsInstances = findOtherWindowsUpdateInstances - updateCloseWindowsInstances = closeWindowsUpdateInstances - updateAcquireWindowsMaintenance = acquireWindowsUpdateMaintenance - updateQuitSleep = time.Sleep - updateExitProcess = os.Exit + updateFetchLatestRelease = fetchLatestRelease + updateFetchDevRelease = fetchDevRelease + updateFetchReleaseSHA256 = fetchReleaseSHA256 + updateLogCheckError = func(err error) { logger.Error(err, "检查更新失败") } + updateResolveInstallTarget = resolveUpdateInstallTarget + updateResolveInstallMode = resolveCurrentUpdateInstallMode + updateLaunchInstallScript = launchUpdateScript + updateFindOtherWindowsInstances = findOtherWindowsUpdateInstances + updateCloseWindowsInstances = closeWindowsUpdateInstances + updateConfirmCloseWindowsInstances = showWindowsUpdateCloseConfirmation + updateAcquireWindowsMaintenance = acquireWindowsUpdateMaintenance + updateQuitSleep = time.Sleep + updateExitProcess = os.Exit ) var errUpdateChecksumMismatch = errors.New("update package checksum mismatch") @@ -310,18 +311,6 @@ func (a *App) InstallUpdateAndRestart(closeAllWindowsInstancesConfirmed bool) co }), } } - if windowsUpdateCloseConfirmationRequired(stdRuntime.GOOS, closeAllWindowsInstancesConfirmed) { - return connection.QueryResult{ - Success: false, - Message: a.appText("app.update.backend.message.install_launch_failed", map[string]any{ - "detail": a.appText("app.update.backend.error.close_instances_confirmation_required", nil), - }), - Data: map[string]any{ - "requiresCloseConfirmation": true, - }, - } - } - if stdRuntime.GOOS == "windows" { installTarget := updateResolveInstallTarget() maintenanceLease, err := updateAcquireWindowsMaintenance(installTarget) @@ -339,6 +328,44 @@ func (a *App) InstallUpdateAndRestart(closeAllWindowsInstancesConfirmed bool) co } }() staged.MaintenanceEventName = maintenanceLease.Name + + finalTarget := resolveWindowsUpdateFinalTargetPath(installTarget, staged.FilePath) + runningInstances, err := updateFindOtherWindowsInstances([]string{installTarget, finalTarget}, os.Getpid()) + if err != nil { + return connection.QueryResult{ + Success: false, + Message: a.appText("app.update.backend.message.install_launch_failed", map[string]any{ + "detail": a.appText("app.update.backend.error.close_instances_failed", map[string]any{"detail": err.Error()}), + }), + } + } + if windowsUpdateCloseConfirmationRequired(stdRuntime.GOOS, closeAllWindowsInstancesConfirmed, len(runningInstances)) { + confirmationParams := map[string]any{"count": len(runningInstances)} + confirmed, confirmErr := updateConfirmCloseWindowsInstances( + a.ctx, + a.appText("app.about.update_install_confirm.close_instances_title", confirmationParams), + a.appText("app.about.update_install_confirm.close_instances_content", confirmationParams), + ) + if confirmErr != nil { + return connection.QueryResult{ + Success: false, + Message: a.appText("app.update.backend.message.install_launch_failed", map[string]any{ + "detail": confirmErr.Error(), + }), + } + } + if !confirmed { + return connection.QueryResult{ + Success: false, + Data: map[string]any{ + "cancelled": true, + "runningPids": otherWindowsUpdateProcessIDs(runningInstances), + }, + } + } + closeAllWindowsInstancesConfirmed = true + } + if staged.InstallMode == updateInstallModePortable { if err := ensureWindowsUpdateTargetWritable(installTarget); err != nil { return connection.QueryResult{ @@ -350,22 +377,23 @@ func (a *App) InstallUpdateAndRestart(closeAllWindowsInstancesConfirmed bool) co } } - finalTarget := resolveWindowsUpdateFinalTargetPath(installTarget, staged.FilePath) - closedPIDs, err := closeOtherWindowsUpdateInstancesForInstall([]string{installTarget, finalTarget}, os.Getpid()) - if err != nil { - logger.Warnf("关闭 Windows 更新相关实例失败 current=%s target=%s pids=%v error=%v", installTarget, finalTarget, closedPIDs, err) - return connection.QueryResult{ - Success: false, - Message: a.appText("app.update.backend.message.install_launch_failed", map[string]any{ - "detail": a.appText("app.update.backend.error.close_instances_failed", map[string]any{"detail": err.Error()}), - }), - Data: map[string]any{ - "runningPids": closedPIDs, - }, + if closeAllWindowsInstancesConfirmed { + closedPIDs, closeErr := closeOtherWindowsUpdateInstancesForInstall([]string{installTarget, finalTarget}, os.Getpid()) + if closeErr != nil { + logger.Warnf("关闭 Windows 更新相关实例失败 current=%s target=%s pids=%v error=%v", installTarget, finalTarget, closedPIDs, closeErr) + return connection.QueryResult{ + Success: false, + Message: a.appText("app.update.backend.message.install_launch_failed", map[string]any{ + "detail": a.appText("app.update.backend.error.close_instances_failed", map[string]any{"detail": closeErr.Error()}), + }), + Data: map[string]any{ + "runningPids": closedPIDs, + }, + } + } + if len(closedPIDs) > 0 { + logger.Infof("Windows 更新已关闭其他 GoNavi 实例 current=%s target=%s pids=%v", installTarget, finalTarget, closedPIDs) } - } - if len(closedPIDs) > 0 { - logger.Infof("Windows 更新已关闭其他 GoNavi 实例 current=%s target=%s pids=%v", installTarget, finalTarget, closedPIDs) } } diff --git a/internal/app/methods_update_windows_process_test.go b/internal/app/methods_update_windows_process_test.go index e1ef2c58..a7eb53ce 100644 --- a/internal/app/methods_update_windows_process_test.go +++ b/internal/app/methods_update_windows_process_test.go @@ -3,6 +3,7 @@ package app import ( + "context" "os" "os/exec" "path/filepath" @@ -136,6 +137,7 @@ func TestInstallUpdateAndRestartClosesOtherTargetInstances(t *testing.T) { originalResolveInstallTarget := updateResolveInstallTarget originalFindOtherInstances := updateFindOtherWindowsInstances originalCloseInstances := updateCloseWindowsInstances + originalConfirmCloseInstances := updateConfirmCloseWindowsInstances originalAcquireMaintenance := updateAcquireWindowsMaintenance originalLaunchInstallScript := updateLaunchInstallScript originalQuitSleep := updateQuitSleep @@ -144,29 +146,43 @@ func TestInstallUpdateAndRestartClosesOtherTargetInstances(t *testing.T) { updateResolveInstallTarget = originalResolveInstallTarget updateFindOtherWindowsInstances = originalFindOtherInstances updateCloseWindowsInstances = originalCloseInstances + updateConfirmCloseWindowsInstances = originalConfirmCloseInstances updateAcquireWindowsMaintenance = originalAcquireMaintenance updateLaunchInstallScript = originalLaunchInstallScript updateQuitSleep = originalQuitSleep updateExitProcess = originalExitProcess }) updateResolveInstallTarget = func() string { return currentTarget } + maintenanceAcquired := false updateAcquireWindowsMaintenance = func(string) (windowsUpdateMaintenanceLease, error) { + maintenanceAcquired = true return windowsUpdateMaintenanceLease{Name: `Global\GoNavi-Update-Test`}, nil } var checkedTargets []string findCalls := 0 updateFindOtherWindowsInstances = func(targets []string, currentPID int) ([]windowsUpdateProcess, error) { + if !maintenanceAcquired { + t.Fatal("other instances must be discovered after acquiring update maintenance") + } findCalls++ checkedTargets = append([]string(nil), targets...) if currentPID != os.Getpid() { t.Fatalf("current PID = %d, want %d", currentPID, os.Getpid()) } - if findCalls == 1 { + if findCalls <= 2 { return []windowsUpdateProcess{{PID: 4321, Executable: newTarget}}, nil } return nil, nil } + confirmCalls := 0 + updateConfirmCloseWindowsInstances = func(_ context.Context, title, message string) (bool, error) { + confirmCalls++ + if title == "" || message == "" { + t.Fatal("native close confirmation must include a localized title and message") + } + return true, nil + } closed := false updateCloseWindowsInstances = func(processes []windowsUpdateProcess) error { closed = len(processes) == 1 && processes[0].PID == 4321 && processes[0].Executable == newTarget @@ -181,7 +197,7 @@ func TestInstallUpdateAndRestartClosesOtherTargetInstances(t *testing.T) { updateQuitSleep = func(time.Duration) {} updateExitProcess = func(int) { quitFinished <- struct{}{} } - result := app.InstallUpdateAndRestart(true) + result := app.InstallUpdateAndRestart(false) if !result.Success { t.Fatalf("expected confirmed update to close other instances and launch, got %#v", result) } @@ -191,11 +207,14 @@ func TestInstallUpdateAndRestartClosesOtherTargetInstances(t *testing.T) { if !launched { t.Fatal("update launcher did not start after other instances closed") } + if confirmCalls != 1 { + t.Fatalf("close confirmation calls = %d, want 1", confirmCalls) + } if len(checkedTargets) != 2 || checkedTargets[0] != currentTarget || checkedTargets[1] != newTarget { t.Fatalf("checked targets = %#v, want current and final target", checkedTargets) } - if findCalls != 2 { - t.Fatalf("find calls = %d, want discovery and post-close verification", findCalls) + if findCalls != 3 { + t.Fatalf("find calls = %d, want preflight, close discovery, and post-close verification", findCalls) } select { case <-quitFinished: @@ -223,13 +242,37 @@ func TestInstallUpdateAndRestartRequiresCloseConfirmationOnWindows(t *testing.T) InstallLogPath: filepath.Join(dir, "update.log"), } - originalResolveMode := updateResolveInstallMode + originalResolveTarget := updateResolveInstallTarget + originalFindOtherInstances := updateFindOtherWindowsInstances + originalConfirmCloseInstances := updateConfirmCloseWindowsInstances + originalAcquireMaintenance := updateAcquireWindowsMaintenance originalLaunch := updateLaunchInstallScript t.Cleanup(func() { - updateResolveInstallMode = originalResolveMode + updateResolveInstallTarget = originalResolveTarget + updateFindOtherWindowsInstances = originalFindOtherInstances + updateConfirmCloseWindowsInstances = originalConfirmCloseInstances + updateAcquireWindowsMaintenance = originalAcquireMaintenance updateLaunchInstallScript = originalLaunch }) - updateResolveInstallMode = func() updateInstallMode { return updateInstallModeMSI } + updateResolveInstallTarget = func() string { return filepath.Join(dir, "GoNavi.exe") } + maintenanceAcquired := false + updateAcquireWindowsMaintenance = func(string) (windowsUpdateMaintenanceLease, error) { + maintenanceAcquired = true + return windowsUpdateMaintenanceLease{Name: `Global\GoNavi-Update-Test`}, nil + } + findCalls := 0 + updateFindOtherWindowsInstances = func([]string, int) ([]windowsUpdateProcess, error) { + if !maintenanceAcquired { + t.Fatal("other instances must be discovered after acquiring update maintenance") + } + findCalls++ + return []windowsUpdateProcess{{PID: 4321, Executable: filepath.Join(dir, "GoNavi.exe")}}, nil + } + confirmCalls := 0 + updateConfirmCloseWindowsInstances = func(context.Context, string, string) (bool, error) { + confirmCalls++ + return false, nil + } launched := false updateLaunchInstallScript = func(*stagedUpdate) error { launched = true @@ -243,7 +286,90 @@ func TestInstallUpdateAndRestartRequiresCloseConfirmationOnWindows(t *testing.T) if launched { t.Fatal("update launcher must not start before close-all confirmation") } - if !strings.Contains(result.Message, "current GoNavi installation") { - t.Fatalf("missing close confirmation message: %q", result.Message) + data, ok := result.Data.(map[string]any) + if !ok || data["cancelled"] != true { + t.Fatalf("cancelled update data = %#v, want cancelled=true", result.Data) + } + if confirmCalls != 1 || findCalls != 1 { + t.Fatalf("finder/confirmation calls = %d/%d, want 1/1", findCalls, confirmCalls) + } +} + +func TestInstallUpdateAndRestartSkipsCloseConfirmationForSingleWindowsInstance(t *testing.T) { + dir := t.TempDir() + packagePath := filepath.Join(dir, "GoNavi-Installer.msi") + if err := os.WriteFile(packagePath, []byte("fake msi"), 0o644); err != nil { + t.Fatalf("WriteFile MSI: %v", err) + } + app := NewApp() + app.SetLanguage("en-US") + app.updateState.staged = &stagedUpdate{ + Version: "1.2.3", + AssetName: filepath.Base(packagePath), + FilePath: packagePath, + StagedDir: dir, + InstallMode: updateInstallModeMSI, + PackageType: updatePackageTypeMSI, + AutoRelaunch: true, + } + + originalResolveTarget := updateResolveInstallTarget + originalFindOtherInstances := updateFindOtherWindowsInstances + originalConfirmCloseInstances := updateConfirmCloseWindowsInstances + originalAcquireMaintenance := updateAcquireWindowsMaintenance + originalLaunch := updateLaunchInstallScript + originalQuitSleep := updateQuitSleep + originalExitProcess := updateExitProcess + t.Cleanup(func() { + updateResolveInstallTarget = originalResolveTarget + updateFindOtherWindowsInstances = originalFindOtherInstances + updateConfirmCloseWindowsInstances = originalConfirmCloseInstances + updateAcquireWindowsMaintenance = originalAcquireMaintenance + updateLaunchInstallScript = originalLaunch + updateQuitSleep = originalQuitSleep + updateExitProcess = originalExitProcess + }) + updateResolveInstallTarget = func() string { return filepath.Join(dir, "GoNavi.exe") } + maintenanceAcquired := false + findCalls := 0 + updateFindOtherWindowsInstances = func([]string, int) ([]windowsUpdateProcess, error) { + if !maintenanceAcquired { + t.Fatal("other instances must be discovered after acquiring update maintenance") + } + findCalls++ + return nil, nil + } + confirmCalls := 0 + updateConfirmCloseWindowsInstances = func(context.Context, string, string) (bool, error) { + confirmCalls++ + return false, nil + } + updateAcquireWindowsMaintenance = func(string) (windowsUpdateMaintenanceLease, error) { + maintenanceAcquired = true + return windowsUpdateMaintenanceLease{Name: `Global\GoNavi-Update-Test`}, nil + } + launched := false + updateLaunchInstallScript = func(*stagedUpdate) error { + launched = true + return nil + } + quitFinished := make(chan struct{}, 1) + updateQuitSleep = func(time.Duration) {} + updateExitProcess = func(int) { quitFinished <- struct{}{} } + + result := app.InstallUpdateAndRestart(false) + if !result.Success { + t.Fatalf("single-instance update should launch without confirmation, got %#v", result) + } + if findCalls != 1 || confirmCalls != 0 { + t.Fatalf("finder/confirmation calls = %d/%d, want 1/0", findCalls, confirmCalls) + } + if !launched { + t.Fatal("single-instance update did not launch") + } + select { + case <-quitFinished: + case <-time.After(time.Second): + t.Fatal("timed out waiting for updater-controlled quit goroutine") } } diff --git a/internal/app/windows_update_instances_test.go b/internal/app/windows_update_instances_test.go index 2f4adb05..8c7710a4 100644 --- a/internal/app/windows_update_instances_test.go +++ b/internal/app/windows_update_instances_test.go @@ -8,18 +8,27 @@ import ( func TestWindowsUpdateRequiresExplicitCloseConfirmation(t *testing.T) { tests := []struct { - goos string - confirmed bool - want bool + goos string + confirmed bool + otherInstances int + want bool }{ - {goos: "windows", confirmed: false, want: true}, - {goos: " WINDOWS ", confirmed: true, want: false}, - {goos: "darwin", confirmed: false, want: false}, - {goos: "linux", confirmed: false, want: false}, + {goos: "windows", confirmed: false, otherInstances: 1, want: true}, + {goos: "windows", confirmed: false, otherInstances: 0, want: false}, + {goos: " WINDOWS ", confirmed: true, otherInstances: 1, want: false}, + {goos: "darwin", confirmed: false, otherInstances: 1, want: false}, + {goos: "linux", confirmed: false, otherInstances: 1, want: false}, } for _, test := range tests { - if got := windowsUpdateCloseConfirmationRequired(test.goos, test.confirmed); got != test.want { - t.Fatalf("windowsUpdateCloseConfirmationRequired(%q, %v) = %v, want %v", test.goos, test.confirmed, got, test.want) + if got := windowsUpdateCloseConfirmationRequired(test.goos, test.confirmed, test.otherInstances); got != test.want { + t.Fatalf( + "windowsUpdateCloseConfirmationRequired(%q, %v, %d) = %v, want %v", + test.goos, + test.confirmed, + test.otherInstances, + got, + test.want, + ) } } } diff --git a/internal/app/windows_update_process.go b/internal/app/windows_update_process.go index 58b0316d..1894b340 100644 --- a/internal/app/windows_update_process.go +++ b/internal/app/windows_update_process.go @@ -1,8 +1,11 @@ package app import ( + "context" "fmt" "strings" + + wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime" ) func otherWindowsUpdateProcessIDs(processes []windowsUpdateProcess) []uint32 { @@ -13,8 +16,26 @@ func otherWindowsUpdateProcessIDs(processes []windowsUpdateProcess) []uint32 { return result } -func windowsUpdateCloseConfirmationRequired(goos string, confirmed bool) bool { - return strings.EqualFold(strings.TrimSpace(goos), "windows") && !confirmed +func windowsUpdateCloseConfirmationRequired(goos string, confirmed bool, otherInstanceCount int) bool { + return strings.EqualFold(strings.TrimSpace(goos), "windows") && !confirmed && otherInstanceCount > 0 +} + +func showWindowsUpdateCloseConfirmation(ctx context.Context, title, message string) (bool, error) { + if ctx == nil { + return false, fmt.Errorf("application window is not ready") + } + response, err := wailsRuntime.MessageDialog(ctx, wailsRuntime.MessageDialogOptions{ + Type: wailsRuntime.QuestionDialog, + Title: title, + Message: message, + Buttons: []string{"Yes", "No"}, + DefaultButton: "No", + CancelButton: "No", + }) + if err != nil { + return false, err + } + return strings.EqualFold(strings.TrimSpace(response), "yes"), nil } func closeOtherWindowsUpdateInstancesForInstall(targetPaths []string, currentPID int) ([]uint32, error) { diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json index 0e1d9e06..eed1c726 100644 --- a/shared/i18n/de-DE.json +++ b/shared/i18n/de-DE.json @@ -8409,8 +8409,8 @@ "app.about.action.download_portable_update": "Portable-Update herunterladen", "app.about.action.install_and_restart": "Installieren und neu starten", "app.about.action.launch_installer": "Installer starten", - "app.about.update_install_confirm.close_instances_title": "Alle Instanzen dieser GoNavi-Installation schließen und das Update installieren?", - "app.about.update_install_confirm.close_instances_content": "Zunächst werden nicht gespeicherte SQL-Änderungen in diesem Fenster behandelt. Anschließend schließt Windows alle weiteren Prozesse dieser Installation und startet das Installationsprogramm; Portable-Kopien in anderen Ordnern bleiben geöffnet. Nicht gespeicherte Inhalte in geschlossenen Instanzen können verloren gehen.", + "app.about.update_install_confirm.close_instances_title": "{{count}} weitere GoNavi-Instanz(en) erkannt. Schließen und das Update installieren?", + "app.about.update_install_confirm.close_instances_content": "Dieses Fenster hat die Prüfung vor dem Beenden abgeschlossen. Windows schließt die anderen GoNavi-Prozesse aus demselben Installationsverzeichnis und startet das Installationsprogramm; Portable-Kopien in anderen Ordnern bleiben geöffnet. Nicht gespeicherte Inhalte in geschlossenen Instanzen können verloren gehen.", "app.about.update_install_confirm.close_instances_ok": "Alle schließen und installieren", "app.about.download_progress.ready_to_restart": "Download abgeschlossen (100%). Klicken Sie auf „Zum Aktualisieren neu starten“.", "app.about.download_progress.ready_to_install": "MSI-Installer heruntergeladen (100%) und installationsbereit.", diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json index 74e35515..597fec36 100644 --- a/shared/i18n/en-US.json +++ b/shared/i18n/en-US.json @@ -8409,8 +8409,8 @@ "app.about.action.download_portable_update": "Download Portable update", "app.about.action.install_and_restart": "Install and restart", "app.about.action.launch_installer": "Launch installer", - "app.about.update_install_confirm.close_instances_title": "Close all instances from this GoNavi installation and install the update?", - "app.about.update_install_confirm.close_instances_content": "GoNavi will first handle unsaved SQL in this window. Windows will then close every other process from this installation and start the installer; Portable copies in other folders are not affected. Unsaved work in closed instances may be lost.", + "app.about.update_install_confirm.close_instances_title": "Detected {{count}} other GoNavi instance(s). Close them and install the update?", + "app.about.update_install_confirm.close_instances_content": "This window has completed its pre-exit checks. Windows will close the other GoNavi processes from the same installation directory and start the installer; Portable copies in other folders are not affected. Unsaved work in closed instances may be lost.", "app.about.update_install_confirm.close_instances_ok": "Close all and install", "app.about.download_progress.ready_to_restart": "Download complete (100%). Click “Restart to update” to finish installing.", "app.about.download_progress.ready_to_install": "MSI installer downloaded (100%) and ready to install.", diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json index c70066a3..cd5c5bcf 100644 --- a/shared/i18n/ja-JP.json +++ b/shared/i18n/ja-JP.json @@ -8409,8 +8409,8 @@ "app.about.action.download_portable_update": "Portable 更新をダウンロード", "app.about.action.install_and_restart": "インストールして再起動", "app.about.action.launch_installer": "インストーラーを起動", - "app.about.update_install_confirm.close_instances_title": "現在の GoNavi インストールの全インスタンスを終了して更新しますか?", - "app.about.update_install_confirm.close_instances_content": "続行すると、このウィンドウの未保存 SQL を先に処理した後、Windows が現在のインストールに属する他の GoNavi プロセスを終了してインストールを開始します。別のフォルダーにある Portable 版は影響を受けません。終了するインスタンスの未保存内容は失われる可能性があります。", + "app.about.update_install_confirm.close_instances_title": "他に {{count}} 個の GoNavi インスタンスが見つかりました。終了して更新しますか?", + "app.about.update_install_confirm.close_instances_content": "現在のウィンドウは終了前チェックを完了しています。続行すると、Windows が同じインストールフォルダーの他の GoNavi プロセスを終了してインストールを開始します。別のフォルダーにある Portable 版は影響を受けません。終了するインスタンスの未保存内容は失われる可能性があります。", "app.about.update_install_confirm.close_instances_ok": "すべて終了してインストール", "app.about.download_progress.ready_to_restart": "ダウンロード完了(100%)。「再起動して更新」をクリックしてください。", "app.about.download_progress.ready_to_install": "MSI インストーラーのダウンロードが完了しました(100%)。", diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json index eb952921..c631f6bb 100644 --- a/shared/i18n/ru-RU.json +++ b/shared/i18n/ru-RU.json @@ -8409,8 +8409,8 @@ "app.about.action.download_portable_update": "Скачать Portable-обновление", "app.about.action.install_and_restart": "Установить и перезапустить", "app.about.action.launch_installer": "Запустить установщик", - "app.about.update_install_confirm.close_instances_title": "Закрыть все экземпляры текущей установки GoNavi и установить обновление?", - "app.about.update_install_confirm.close_instances_content": "Сначала GoNavi обработает несохранённый SQL в этом окне. Затем Windows закроет остальные процессы текущей установки и запустит установщик; Portable-копии в других папках не будут затронуты. Несохранённые данные в закрываемых экземплярах могут быть потеряны.", + "app.about.update_install_confirm.close_instances_title": "Обнаружено других экземпляров GoNavi: {{count}}. Закрыть их и установить обновление?", + "app.about.update_install_confirm.close_instances_content": "Проверка текущего окна перед выходом завершена. Windows закроет другие процессы GoNavi из того же каталога установки и запустит установщик; Portable-копии в других папках не будут затронуты. Несохранённые данные в закрываемых экземплярах могут быть потеряны.", "app.about.update_install_confirm.close_instances_ok": "Закрыть все и установить", "app.about.download_progress.ready_to_restart": "Загрузка завершена (100%). Нажмите «Перезапустить для обновления».", "app.about.download_progress.ready_to_install": "MSI-установщик загружен (100%) и готов к установке.", diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json index 321408e0..50802966 100644 --- a/shared/i18n/zh-CN.json +++ b/shared/i18n/zh-CN.json @@ -8409,8 +8409,8 @@ "app.about.action.download_portable_update": "下载 Portable 更新", "app.about.action.install_and_restart": "安装并重启", "app.about.action.launch_installer": "启动安装程序", - "app.about.update_install_confirm.close_instances_title": "关闭当前安装的所有 GoNavi 实例并安装更新?", - "app.about.update_install_confirm.close_instances_content": "继续后,当前窗口会先处理未保存的 SQL,然后 Windows 将关闭当前安装中的其他 GoNavi 进程并开始安装;其他目录中的 Portable 副本不会受影响。被关闭实例中未保存的内容可能会丢失。", + "app.about.update_install_confirm.close_instances_title": "检测到 {{count}} 个其他 GoNavi 实例,关闭并安装更新?", + "app.about.update_install_confirm.close_instances_content": "当前窗口已完成退出前检查。继续后,Windows 将关闭同一安装目录中的其他 GoNavi 进程并开始安装;其他目录中的 Portable 副本不会受影响。被关闭实例中未保存的内容可能会丢失。", "app.about.update_install_confirm.close_instances_ok": "关闭全部并安装", "app.about.download_progress.ready_to_restart": "下载完成(100%)。点击「重启应用更新」即可完成安装。", "app.about.download_progress.ready_to_install": "MSI 安装包下载完成(100%),可以开始安装。", diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json index 38e0d117..37c0c75d 100644 --- a/shared/i18n/zh-TW.json +++ b/shared/i18n/zh-TW.json @@ -8409,8 +8409,8 @@ "app.about.action.download_portable_update": "下載 Portable 更新", "app.about.action.install_and_restart": "安裝並重新啟動", "app.about.action.launch_installer": "啟動安裝程式", - "app.about.update_install_confirm.close_instances_title": "關閉目前安裝的所有 GoNavi 執行個體並安裝更新?", - "app.about.update_install_confirm.close_instances_content": "繼續後,目前視窗會先處理未儲存的 SQL,然後 Windows 將關閉目前安裝中的其他 GoNavi 處理程序並開始安裝;其他資料夾中的 Portable 副本不受影響。被關閉執行個體中未儲存的內容可能會遺失。", + "app.about.update_install_confirm.close_instances_title": "偵測到 {{count}} 個其他 GoNavi 執行個體,關閉並安裝更新?", + "app.about.update_install_confirm.close_instances_content": "目前視窗已完成結束前檢查。繼續後,Windows 將關閉同一安裝目錄中的其他 GoNavi 處理程序並開始安裝;其他資料夾中的 Portable 副本不受影響。被關閉執行個體中未儲存的內容可能會遺失。", "app.about.update_install_confirm.close_instances_ok": "全部關閉並安裝", "app.about.download_progress.ready_to_restart": "下載完成(100%)。點選「重新啟動應用更新」即可完成安裝。", "app.about.download_progress.ready_to_install": "MSI 安裝套件下載完成(100%),可以開始安裝。", diff --git a/tools/windows-release-artifacts.test.py b/tools/windows-release-artifacts.test.py index 77f74600..9934e450 100644 --- a/tools/windows-release-artifacts.test.py +++ b/tools/windows-release-artifacts.test.py @@ -55,6 +55,7 @@ class WindowsReleaseArtifactsTest(unittest.TestCase): self.assertIn("TestPrepareWindowsUpdateHandoff", source) self.assertIn("TestWindowsUpdateRequiresExplicitCloseConfirmation", source) self.assertIn("TestInstallUpdateAndRestartRequiresCloseConfirmationOnWindows", source) + self.assertIn("TestInstallUpdateAndRestartSkipsCloseConfirmationForSingleWindowsInstance", source) self.assertIn("TestFindOtherWindowsUpdateInstances", source) self.assertIn("TestCloseWindowsUpdateInstances", source) self.assertIn("TestInstallUpdateAndRestartClosesOtherTargetInstances", source)