From da03e4ef04b9338a69f2fbb638bb173ff06471f3 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 29 May 2026 18:37:38 +0200 Subject: [PATCH] extensions: add delayed auto-update mode to extensions.autoUpdate (#318974) * extensions: add delayed auto-update mode to extensions.autoUpdate Folds a 6-hour delayed auto-update mode into the existing `extensions.autoUpdate` setting, which is now a string enum ('on' | 'delayed' | 'off') and policy-controllable via the ExtensionsAutoUpdate policy. When set to 'delayed', an extension whose newer version was published less than 6 hours ago is treated as outdated-but-delayed: it is excluded from auto-update and the activity badge, and surfaces an info status explaining when it will be auto updated. Manual updates still work. Includes startup migration from legacy values (true/'all'/ 'onlyEnabledExtensions' -> 'on'; false/'none'/'onlySelectedExtensions' -> 'off') plus defensive read-time normalization, and unit tests for the new logic. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * update policy file * extensions: address code review feedback for delayed auto-update - Type IExtensionsConfiguration.autoUpdate as AutoUpdateConfigurationValue - Keep ExtensionStatusAction.update() returning void; add recomputeStatus() for callers that need to await the computed status - Preserve the higher-priority warning CSS class when the delayed info status is also applied to an outdated extension - Hover awaits recomputeStatus() instead of update() - Update pluginAutoUpdate doc comment to the on/delayed/off enum values Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * update policies data --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- build/lib/policies/policyData.jsonc | 34 ++++++++ .../contrib/chat/browser/pluginAutoUpdate.ts | 18 ++-- .../plugins/pluginMarketplaceService.ts | 7 +- .../browser/plugins/pluginAutoUpdate.test.ts | 46 +++++----- .../plugins/pluginMarketplaceService.test.ts | 16 ++++ .../browser/extensions.contribution.ts | 72 ++++++++++++---- .../extensions/browser/extensionsActions.ts | 26 +++++- .../extensions/browser/extensionsViewlet.ts | 2 +- .../extensions/browser/extensionsWidgets.ts | 11 ++- .../browser/extensionsWorkbenchService.ts | 86 +++++++++++++++---- .../contrib/extensions/common/extensions.ts | 6 +- .../extensionsWorkbenchService.test.ts | 86 ++++++++++++++++++- 12 files changed, 330 insertions(+), 80 deletions(-) diff --git a/build/lib/policies/policyData.jsonc b/build/lib/policies/policyData.jsonc index dff4edad27f..c6b3fb35ced 100644 --- a/build/lib/policies/policyData.jsonc +++ b/build/lib/policies/policyData.jsonc @@ -312,6 +312,40 @@ "default": true, "included": true }, + { + "key": "extensions.autoUpdate", + "name": "ExtensionsAutoUpdate", + "category": "Extensions", + "minimumVersion": "1.122", + "localization": { + "description": { + "key": "extensions.autoUpdate", + "value": "Controls the automatic update behavior of extensions. The updates are fetched from a Microsoft online service." + }, + "enumDescriptions": [ + { + "key": "extensions.autoUpdate.on", + "value": "Enabled extensions are updated automatically." + }, + { + "key": "extensions.autoUpdate.delayed", + "value": "Enabled extensions are updated automatically, but only 6 hours after a new version has been published." + }, + { + "key": "extensions.autoUpdate.off", + "value": "Extensions are not updated automatically." + } + ] + }, + "type": "string", + "default": "on", + "enum": [ + "on", + "delayed", + "off" + ], + "included": true + }, { "key": "chat.tools.terminal.enableAutoApprove", "name": "ChatToolsTerminalEnableAutoApprove", diff --git a/src/vs/workbench/contrib/chat/browser/pluginAutoUpdate.ts b/src/vs/workbench/contrib/chat/browser/pluginAutoUpdate.ts index b602e809bc8..b8af0a61eb4 100644 --- a/src/vs/workbench/contrib/chat/browser/pluginAutoUpdate.ts +++ b/src/vs/workbench/contrib/chat/browser/pluginAutoUpdate.ts @@ -6,10 +6,9 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; -import { AutoUpdateConfigurationKey, AutoUpdateConfigurationValue } from '../../extensions/common/extensions.js'; +import { IExtensionsWorkbenchService } from '../../extensions/common/extensions.js'; import { IPluginInstallService } from '../common/plugins/pluginInstallService.js'; import { IPluginMarketplaceService } from '../common/plugins/pluginMarketplaceService.js'; @@ -23,11 +22,10 @@ import { IPluginMarketplaceService } from '../common/plugins/pluginMarketplaceSe * Without this contribution, that signal was never consumed and plugins * were never auto-updated (see microsoft/vscode#308563). * - * When the signal becomes `true` and `extensions.autoUpdate === true`, we - * silently update all installed plugins. Other auto-update modes - * (`'onlyEnabledExtensions'`, `'onlySelectedExtensions'`) gate updates on a - * per-extension opt-in that has no plugin equivalent, so they are treated - * the same as `false` for plugins. + * When the signal becomes `true` and `extensions.autoUpdate` is set to update + * extensions (`'on'` or `'delayed'`), we silently update all installed plugins. + * The `'off'` mode gates updates on a per-extension opt-in that has no plugin + * equivalent, so it is treated the same as disabled for plugins. * * The flag is cleared after every attempt — including failures — so the * next periodic check's `false → true` transition can always re-trigger the @@ -43,7 +41,7 @@ export class PluginAutoUpdate extends Disposable implements IWorkbenchContributi constructor( @IPluginMarketplaceService private readonly _pluginMarketplaceService: IPluginMarketplaceService, @IPluginInstallService private readonly _pluginInstallService: IPluginInstallService, - @IConfigurationService private readonly _configurationService: IConfigurationService, + @IExtensionsWorkbenchService private readonly _extensionsWorkbenchService: IExtensionsWorkbenchService, @ILogService private readonly _logService: ILogService, ) { super(); @@ -61,8 +59,8 @@ export class PluginAutoUpdate extends Disposable implements IWorkbenchContributi return; } - const autoUpdate = this._configurationService.getValue(AutoUpdateConfigurationKey); - if (autoUpdate !== true) { + const autoUpdate = this._extensionsWorkbenchService.getAutoUpdateValue(); + if (autoUpdate === 'off') { return; } diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index 22cb3bc6505..af9437fc73d 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -22,7 +22,7 @@ import { ObservableMemento, observableMemento } from '../../../../../platform/ob import { asJson, IRequestService } from '../../../../../platform/request/common/request.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import type { Dto } from '../../../../services/extensions/common/proxyIdentifier.js'; -import { AutoUpdateConfigurationKey, AutoUpdateConfigurationValue } from '../../../extensions/common/extensions.js'; +import { AutoUpdateConfigurationKey, IExtensionsWorkbenchService } from '../../../extensions/common/extensions.js'; import { ChatConfiguration } from '../constants.js'; import { IAgentPluginRepositoryService } from './agentPluginRepositoryService.js'; import { FileBackedInstalledPluginsStore, IStoredInstalledPlugin } from './fileBackedInstalledPluginsStore.js'; @@ -304,6 +304,7 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke @IStorageService private readonly _storageService: IStorageService, @IWorkspacePluginSettingsService private readonly _workspacePluginSettingsService: IWorkspacePluginSettingsService, @IWorkspaceTrustManagementService private readonly _workspaceTrustService: IWorkspaceTrustManagementService, + @IExtensionsWorkbenchService private readonly _extensionsWorkbenchService: IExtensionsWorkbenchService, ) { super(); @@ -723,8 +724,8 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke // --- Periodic update check ------------------------------------------------ - private _isAutoUpdateEnabled(): AutoUpdateConfigurationValue { - return this._configurationService.getValue(AutoUpdateConfigurationKey); + private _isAutoUpdateEnabled(): boolean { + return this._extensionsWorkbenchService.getAutoUpdateValue() !== 'off'; } /** diff --git a/src/vs/workbench/contrib/chat/test/browser/plugins/pluginAutoUpdate.test.ts b/src/vs/workbench/contrib/chat/test/browser/plugins/pluginAutoUpdate.test.ts index b6dfffe66ac..d8fc502dc38 100644 --- a/src/vs/workbench/contrib/chat/test/browser/plugins/pluginAutoUpdate.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/pluginAutoUpdate.test.ts @@ -7,11 +7,9 @@ import assert from 'assert'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { observableValue } from '../../../../../../base/common/observable.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; -import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; -import { AutoUpdateConfigurationKey } from '../../../../extensions/common/extensions.js'; +import { AutoUpdateConfigurationValue, IExtensionsWorkbenchService } from '../../../../extensions/common/extensions.js'; import { PluginAutoUpdate } from '../../../browser/pluginAutoUpdate.js'; import { IPluginInstallService, IUpdateAllPluginsOptions, IUpdateAllPluginsResult } from '../../../common/plugins/pluginInstallService.js'; import { IPluginMarketplaceService } from '../../../common/plugins/pluginMarketplaceService.js'; @@ -26,7 +24,7 @@ suite('PluginAutoUpdate', () => { clearUpdatesAvailableCalls: number; } - function createContribution(autoUpdate: unknown, stateOverrides?: Partial): { contribution: PluginAutoUpdate; state: MockState } { + function createContribution(autoUpdate: AutoUpdateConfigurationValue, stateOverrides?: Partial): { contribution: PluginAutoUpdate; state: MockState } { const instantiationService = store.add(new TestInstantiationService()); const state: MockState = { @@ -52,9 +50,9 @@ suite('PluginAutoUpdate', () => { }, } as Partial as IPluginInstallService); - const configService = new TestConfigurationService(); - configService.setUserConfiguration(AutoUpdateConfigurationKey, autoUpdate); - instantiationService.stub(IConfigurationService, configService); + instantiationService.stub(IExtensionsWorkbenchService, { + getAutoUpdateValue: () => autoUpdate, + } as Partial as IExtensionsWorkbenchService); instantiationService.stub(ILogService, new NullLogService()); @@ -67,14 +65,14 @@ suite('PluginAutoUpdate', () => { return new Promise(resolve => queueMicrotask(resolve)); } - test('does not trigger update on construction when flag is false', async () => { - const { state } = createContribution(true); + test('does not trigger update on construction', async () => { + const { state } = createContribution('on'); await flushMicrotasks(); assert.deepStrictEqual(state.updateAllCalls, []); }); test('triggers silent updateAllPlugins when hasUpdatesAvailable becomes true', async () => { - const { state } = createContribution(true); + const { state } = createContribution('on'); state.hasUpdatesAvailable.set(true, undefined); await flushMicrotasks(); @@ -82,8 +80,8 @@ suite('PluginAutoUpdate', () => { assert.deepStrictEqual(state.updateAllCalls, [{ silent: true }]); }); - test('does not trigger update when extensions.autoUpdate is false', async () => { - const { state } = createContribution(false); + test('does not trigger update when extensions.autoUpdate is off', async () => { + const { state } = createContribution('off'); state.hasUpdatesAvailable.set(true, undefined); await flushMicrotasks(); @@ -91,17 +89,13 @@ suite('PluginAutoUpdate', () => { assert.deepStrictEqual(state.updateAllCalls, []); }); - test('does not trigger update for non-true auto-update values like onlyEnabledExtensions', async () => { - // Plugins have no per-item opt-in equivalent to extensions, so only - // `true` (update everything) opts plugins into auto-update. - for (const value of ['onlyEnabledExtensions', 'onlySelectedExtensions']) { - const { state } = createContribution(value); + test('triggers update for the delayed auto-update mode', async () => { + const { state } = createContribution('delayed'); - state.hasUpdatesAvailable.set(true, undefined); - await flushMicrotasks(); + state.hasUpdatesAvailable.set(true, undefined); + await flushMicrotasks(); - assert.deepStrictEqual(state.updateAllCalls, [], `expected no update for autoUpdate=${value}`); - } + assert.deepStrictEqual(state.updateAllCalls, [{ silent: true }]); }); test('does not run a second update concurrently with one in flight', async () => { @@ -109,7 +103,7 @@ suite('PluginAutoUpdate', () => { const pendingUpdate = new Promise(resolve => { resolveUpdate = () => resolve({ updatedNames: [], failedNames: [] }); }); - const { state } = createContribution(true, { + const { state } = createContribution('on', { updateAllImpl: () => pendingUpdate, }); @@ -129,7 +123,7 @@ suite('PluginAutoUpdate', () => { }); test('continues running on subsequent cycles after the previous update finished', async () => { - const { state } = createContribution(true); + const { state } = createContribution('on'); state.hasUpdatesAvailable.set(true, undefined); await flushMicrotasks(); @@ -146,7 +140,7 @@ suite('PluginAutoUpdate', () => { }); test('swallows errors from updateAllPlugins', async () => { - const { state } = createContribution(true, { + const { state } = createContribution('on', { updateAllImpl: async () => { throw new Error('boom'); }, }); @@ -169,7 +163,7 @@ suite('PluginAutoUpdate', () => { // path in `PluginInstallService.updateAllPlugins`). Without our own // clear in `finally`, the observable would stay stuck at `true` and // the next periodic check's `set(true)` would not notify subscribers. - const { state } = createContribution(true, { + const { state } = createContribution('on', { updateAllImpl: async () => ({ updatedNames: [], failedNames: ['plugin-a'] }), }); @@ -190,7 +184,7 @@ suite('PluginAutoUpdate', () => { }); test('clears the flag even when updateAllPlugins throws', async () => { - const { state } = createContribution(true, { + const { state } = createContribution('on', { updateAllImpl: async () => { throw new Error('boom'); }, }); diff --git a/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts b/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts index 6daf96b4306..916b513ddc3 100644 --- a/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts @@ -20,6 +20,7 @@ import { IRequestService } from '../../../../../../platform/request/common/reque import { IStorageService, InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { IWorkspaceTrustManagementService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; import { IEnvironmentService } from '../../../../../../platform/environment/common/environment.js'; +import { IExtensionsWorkbenchService } from '../../../../extensions/common/extensions.js'; import { ChatConfiguration } from '../../../common/constants.js'; import { IAgentPluginRepositoryService } from '../../../common/plugins/agentPluginRepositoryService.js'; import { IMarketplacePlugin, IMarketplaceReference, IPluginSourceDescriptor, MarketplaceReferenceKind, MarketplaceType, PluginMarketplaceService, PluginSourceKind, getPluginSourceLabel, parseMarketplaceReference, parseMarketplaceReferences, parsePluginSource } from '../../../common/plugins/pluginMarketplaceService.js'; @@ -257,6 +258,9 @@ suite('PluginMarketplaceService - GitHub marketplace refs', () => { isWorkspaceTrusted: () => true, onDidChangeTrust: Event.None, } as Partial as IWorkspaceTrustManagementService); + instantiationService.stub(IExtensionsWorkbenchService, { + getAutoUpdateValue: () => 'on', + } as Partial as IExtensionsWorkbenchService); const service = store.add(instantiationService.createInstance(PluginMarketplaceService)); await service.fetchMarketplacePlugins(CancellationToken.None); @@ -293,6 +297,9 @@ suite('PluginMarketplaceService - getMarketplacePluginMetadata', () => { isWorkspaceTrusted: () => true, onDidChangeTrust: Event.None, } as Partial as IWorkspaceTrustManagementService); + instantiationService.stub(IExtensionsWorkbenchService, { + getAutoUpdateValue: () => 'on', + } as Partial as IExtensionsWorkbenchService); return store.add(instantiationService.createInstance(PluginMarketplaceService)); } @@ -369,6 +376,9 @@ suite('PluginMarketplaceService - installed plugins lifecycle', () => { isWorkspaceTrusted: () => true, onDidChangeTrust: Event.None, } as Partial as IWorkspaceTrustManagementService); + instantiationService.stub(IExtensionsWorkbenchService, { + getAutoUpdateValue: () => 'on', + } as Partial as IExtensionsWorkbenchService); return store.add(instantiationService.createInstance(PluginMarketplaceService)); } @@ -586,6 +596,9 @@ suite('PluginMarketplaceService - hydration after restart', () => { isWorkspaceTrusted: () => true, onDidChangeTrust: Event.None, } as Partial as IWorkspaceTrustManagementService); + instantiationService.stub(IExtensionsWorkbenchService, { + getAutoUpdateValue: () => 'on', + } as Partial as IExtensionsWorkbenchService); const service = store.add(instantiationService.createInstance(PluginMarketplaceService)); @@ -636,6 +649,9 @@ suite('PluginMarketplaceService - hydration after restart', () => { isWorkspaceTrusted: () => true, onDidChangeTrust: Event.None, } as Partial as IWorkspaceTrustManagementService); + instantiationService.stub(IExtensionsWorkbenchService, { + getAutoUpdateValue: () => 'on', + } as Partial as IExtensionsWorkbenchService); return store.add(instantiationService.createInstance(PluginMarketplaceService)); } diff --git a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts index 464b3948e27..5dee85d99bc 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts @@ -23,7 +23,8 @@ import { Action2, IAction2Options, IMenuItem, MenuId, MenuRegistry, registerActi import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; import { CommandsRegistry, ICommandService } from '../../../../platform/commands/common/commands.js'; import { Extensions as ConfigurationExtensions, ConfigurationScope, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; -import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { IDialogService, IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { ExtensionGalleryManifestStatus, ExtensionGalleryResourceType, ExtensionGalleryServiceUrlConfigKey, getExtensionGalleryManifestResourceUri, IExtensionGalleryManifest, IExtensionGalleryManifestService } from '../../../../platform/extensionManagement/common/extensionGalleryManifest.js'; import { EXTENSION_INSTALL_SOURCE_CONTEXT, ExtensionInstallSource, ExtensionRequestsTimeoutConfigKey, ExtensionsLocalizedLabel, FilterType, IExtensionGalleryService, IExtensionManagementService, PreferencesLocalizedLabel, SortBy, VerifyExtensionSignatureConfigKey } from '../../../../platform/extensionManagement/common/extensionManagement.js'; @@ -137,21 +138,38 @@ Registry.as(ConfigurationExtensions.Configuration) type: 'object', properties: { 'extensions.autoUpdate': { - enum: [true, 'onlyEnabledExtensions', false,], + type: 'string', + enum: ['on', 'delayed', 'off'], enumItemLabels: [ - localize('all', "All Extensions"), - localize('enabled', "Only Enabled Extensions"), - localize('none', "None"), + localize('on', "On"), + localize('delayed', "Delayed"), + localize('off', "Off"), ], enumDescriptions: [ - localize('extensions.autoUpdate.true', 'Download and install updates automatically for all extensions.'), - localize('extensions.autoUpdate.enabled', 'Download and install updates automatically only for enabled extensions.'), - localize('extensions.autoUpdate.false', 'Extensions are not automatically updated.'), + localize('extensions.autoUpdate.on', 'Enabled extensions are updated automatically.'), + localize('extensions.autoUpdate.delayed', 'Enabled extensions are updated automatically, but only 6 hours after a new version has been published.'), + localize('extensions.autoUpdate.off', 'Extensions are not updated automatically.'), ], description: localize('extensions.autoUpdate', "Controls the automatic update behavior of extensions. The updates are fetched from a Microsoft online service."), - default: true, + default: 'on', scope: ConfigurationScope.APPLICATION, - tags: ['usesOnlineServices'] + tags: ['usesOnlineServices'], + policy: { + name: 'ExtensionsAutoUpdate', + category: PolicyCategory.Extensions, + minimumVersion: '1.122', + localization: { + description: { + key: 'extensions.autoUpdate', + value: localize('extensions.autoUpdate', "Controls the automatic update behavior of extensions. The updates are fetched from a Microsoft online service."), + }, + enumDescriptions: [ + { key: 'extensions.autoUpdate.on', value: localize('extensions.autoUpdate.on', 'Enabled extensions are updated automatically.') }, + { key: 'extensions.autoUpdate.delayed', value: localize('extensions.autoUpdate.delayed', 'Enabled extensions are updated automatically, but only 6 hours after a new version has been published.') }, + { key: 'extensions.autoUpdate.off', value: localize('extensions.autoUpdate.off', 'Extensions are not updated automatically.') }, + ] + } + }, }, 'extensions.autoCheckUpdates': { type: 'boolean', @@ -547,6 +565,7 @@ const CONTEXT_GALLERY_FILTER_CAPABILITIES = new RawContextKey('galleryFi const CONTEXT_GALLERY_ALL_PUBLIC_REPOSITORY_SIGNED = new RawContextKey('galleryAllPublicRepositorySigned', false); const CONTEXT_GALLERY_ALL_PRIVATE_REPOSITORY_SIGNED = new RawContextKey('galleryAllPrivateRepositorySigned', false); const CONTEXT_GALLERY_HAS_EXTENSION_LINK = new RawContextKey('galleryHasExtensionLink', false); +const CONTEXT_EXTENSIONS_AUTO_UPDATE_POLICY = new RawContextKey('extensionsAutoUpdatePolicy', false); async function runAction(action: IAction): Promise { try { @@ -565,6 +584,8 @@ type IExtensionActionOptions = IAction2Options & { class ExtensionsContributions extends Disposable implements IWorkbenchContribution { + private readonly autoUpdatePolicyContext: IContextKey; + constructor( @IExtensionManagementService private readonly extensionManagementService: IExtensionManagementService, @IExtensionManagementServerService private readonly extensionManagementServerService: IExtensionManagementServerService, @@ -578,6 +599,7 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi @ICommandService private readonly commandService: ICommandService, @IProductService private readonly productService: IProductService, @IPluginInstallService private readonly pluginInstallService: IPluginInstallService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { super(); const hasLocalServerContext = CONTEXT_HAS_LOCAL_SERVER.bindTo(contextKeyService); @@ -597,6 +619,14 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi this.updateExtensionGalleryStatusContexts(); this._register(extensionGalleryManifestService.onDidChangeExtensionGalleryManifestStatus(() => this.updateExtensionGalleryStatusContexts())); + + this.autoUpdatePolicyContext = CONTEXT_EXTENSIONS_AUTO_UPDATE_POLICY.bindTo(contextKeyService); + this.updateAutoUpdatePolicyContext(); + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(AutoUpdateConfigurationKey)) { + this.updateAutoUpdatePolicyContext(); + } + })); extensionGalleryManifestService.getExtensionGalleryManifest() .then(extensionGalleryManifest => { this.updateGalleryCapabilitiesContexts(extensionGalleryManifest); @@ -607,6 +637,10 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi this.registerQuickAccessProvider(); } + private updateAutoUpdatePolicyContext(): void { + this.autoUpdatePolicyContext.set(this.configurationService.inspect(AutoUpdateConfigurationKey).policyValue !== undefined); + } + private async updateExtensionGalleryStatusContexts(): Promise { CONTEXT_HAS_GALLERY.bindTo(this.contextKeyService).set(this.extensionGalleryManifestService.extensionGalleryManifestStatus === ExtensionGalleryManifestStatus.Available); CONTEXT_EXTENSIONS_GALLERY_STATUS.bindTo(this.contextKeyService).set(this.extensionGalleryManifestService.extensionGalleryManifestStatus); @@ -733,7 +767,8 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi } }); - const enableAutoUpdateWhenCondition = ContextKeyExpr.equals(`config.${AutoUpdateConfigurationKey}`, false); + const notPolicyControlledCondition = CONTEXT_EXTENSIONS_AUTO_UPDATE_POLICY.toNegated(); + const enableAutoUpdateWhenCondition = ContextKeyExpr.and(ContextKeyExpr.equals(`config.${AutoUpdateConfigurationKey}`, 'off'), notPolicyControlledCondition); this.registerExtensionAction({ id: 'workbench.extensions.action.enableAutoUpdate', title: localize2('enableAutoUpdate', 'Enable Auto Update for All Extensions'), @@ -750,7 +785,7 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi run: (accessor: ServicesAccessor) => accessor.get(IExtensionsWorkbenchService).updateAutoUpdateForAllExtensions(true) }); - const disableAutoUpdateWhenCondition = ContextKeyExpr.notEquals(`config.${AutoUpdateConfigurationKey}`, false); + const disableAutoUpdateWhenCondition = ContextKeyExpr.and(ContextKeyExpr.notEquals(`config.${AutoUpdateConfigurationKey}`, 'off'), notPolicyControlledCondition); this.registerExtensionAction({ id: 'workbench.extensions.action.disableAutoUpdate', title: localize2('disableAutoUpdate', 'Disable Auto Update for All Extensions'), @@ -778,7 +813,7 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi when: ContextKeyExpr.and(CONTEXT_HAS_GALLERY, ContextKeyExpr.or(CONTEXT_HAS_LOCAL_SERVER, CONTEXT_HAS_REMOTE_SERVER, CONTEXT_HAS_WEB_SERVER)) }, { id: MenuId.ViewContainerTitle, - when: ContextKeyExpr.and(ContextKeyExpr.equals('viewContainer', VIEWLET_ID), ContextKeyExpr.or(ContextKeyExpr.has(`config.${AutoUpdateConfigurationKey}`).negate(), ContextKeyExpr.equals(`config.${AutoUpdateConfigurationKey}`, 'onlyEnabledExtensions'))), + when: ContextKeyExpr.equals('viewContainer', VIEWLET_ID), group: '1_updates', order: 2 }, { @@ -1445,7 +1480,7 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi id: ToggleAutoUpdateForExtensionAction.ID, title: ToggleAutoUpdateForExtensionAction.LABEL, category: ExtensionsLocalizedLabel, - precondition: ContextKeyExpr.and(ContextKeyExpr.or(ContextKeyExpr.notEquals(`config.${AutoUpdateConfigurationKey}`, 'onlyEnabledExtensions'), ContextKeyExpr.equals('isExtensionEnabled', true)), ContextKeyExpr.not('extensionDisallowInstall'), ContextKeyExpr.has('isExtensionAllowed')), + precondition: ContextKeyExpr.and(ContextKeyExpr.or(ContextKeyExpr.equals(`config.${AutoUpdateConfigurationKey}`, 'off'), ContextKeyExpr.equals('isExtensionEnabled', true)), ContextKeyExpr.not('extensionDisallowInstall'), ContextKeyExpr.has('isExtensionAllowed')), menu: { id: MenuId.ExtensionContext, group: UPDATE_ACTIONS_GROUP, @@ -1472,7 +1507,7 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi id: ToggleAutoUpdatesForPublisherAction.ID, title: { value: ToggleAutoUpdatesForPublisherAction.LABEL, original: 'Auto Update (Publisher)' }, category: ExtensionsLocalizedLabel, - precondition: ContextKeyExpr.equals(`config.${AutoUpdateConfigurationKey}`, false), + precondition: ContextKeyExpr.equals(`config.${AutoUpdateConfigurationKey}`, 'off'), menu: { id: MenuId.ExtensionContext, group: UPDATE_ACTIONS_GROUP, @@ -2101,8 +2136,11 @@ Registry.as(ConfigurationMigrationExtensions.Co .registerConfigurationMigrations([{ key: AutoUpdateConfigurationKey, migrateFn: (value, accessor) => { - if (value === 'onlySelectedExtensions') { - return { value: false }; + if (value === true || value === 'all' || value === 'onlyEnabledExtensions') { + return { value: 'on' }; + } + if (value === false || value === 'none' || value === 'onlySelectedExtensions') { + return { value: 'off' }; } return []; } diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts b/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts index bdf319de98e..8c917ba4fac 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts @@ -1087,7 +1087,7 @@ export class ToggleAutoUpdateForExtensionAction extends ExtensionAction { if (extension && this.allowedExtensionsService.isAllowed(extension) !== true) { return; } - if (this.extensionsWorkbenchService.getAutoUpdateValue() === 'onlyEnabledExtensions' && !this.extensionEnablementService.isEnabledEnablementState(this.extension.enablementState)) { + if (this.extensionsWorkbenchService.getAutoUpdateValue() !== 'off' && !this.extensionEnablementService.isEnabledEnablementState(this.extension.enablementState)) { return; } this.enabled = true; @@ -2771,17 +2771,32 @@ export class ExtensionStatusAction extends ExtensionAction { @IWorkbenchExtensionEnablementService private readonly workbenchExtensionEnablementService: IWorkbenchExtensionEnablementService, @IExtensionFeaturesManagementService private readonly extensionFeaturesManagementService: IExtensionFeaturesManagementService, @IExtensionGalleryManifestService private readonly extensionGalleryManifestService: IExtensionGalleryManifestService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { super('extensions.status', '', `${ExtensionStatusAction.CLASS} hide`, false); this._register(this.labelService.onDidChangeFormatters(() => this.update(), this)); this._register(this.extensionService.onDidChangeExtensions(() => this.update())); this._register(this.extensionFeaturesManagementService.onDidChangeAccessData(() => this.update())); this._register(allowedExtensionsService.onDidChangeAllowedExtensionsConfigValue(() => this.update())); + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(AutoUpdateConfigurationKey)) { + this.update(); + } + })); this.update(); } update(): void { - this.updateThrottler.queue(() => this.computeAndUpdateStatus()); + this.recomputeStatus(); + } + + /** + * Recomputes the status and returns a promise that resolves when the + * computation is done. Use this when callers need to await time-sensitive + * status content (e.g. the delayed auto-update message) before reading it. + */ + recomputeStatus(): Promise { + return this.updateThrottler.queue(() => this.computeAndUpdateStatus()); } private async computeAndUpdateStatus(): Promise { @@ -2829,8 +2844,10 @@ export class ExtensionStatusAction extends ExtensionAction { } if (this.extension.outdated) { + let hasConsentWarning = false; const message = await this.extensionsWorkbenchService.shouldRequireConsentToUpdate(this.extension); if (message) { + hasConsentWarning = true; const markdown = new MarkdownString(); markdown.appendMarkdown(`${message} `); markdown.appendMarkdown( @@ -2843,6 +2860,11 @@ export class ExtensionStatusAction extends ExtensionAction { )); this.updateStatus({ icon: warningIcon, message: markdown }, true); } + if (this.extensionsWorkbenchService.isAutoUpdateDelayed(this.extension)) { + const updateAt = fromNow(Date.now() + this.extensionsWorkbenchService.getAutoUpdateDelayRemaining(this.extension), false, true); + // Do not override the higher-priority warning class with the info class. + this.updateStatus({ icon: infoIcon, message: new MarkdownString(localize('autoUpdateDelayed', "This extension is not updated yet because extension auto updates are configured to be delayed by 6 hours. It will be auto updated {0}.", updateAt)) }, !hasConsentWarning); + } } if (this.extension.gallery && this.extension.state === ExtensionState.Uninstalled) { diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsViewlet.ts b/src/vs/workbench/contrib/extensions/browser/extensionsViewlet.ts index 8bfac4292b4..e4b96e0b50e 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsViewlet.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsViewlet.ts @@ -1011,7 +1011,7 @@ export class StatusUpdater extends Disposable implements IWorkbenchContribution if (!badge) { const actionRequired = this.configurationService.getValue(AutoRestartConfigurationKey) === true ? [] : this.extensionsWorkbenchService.installed.filter(e => e.runtimeState !== undefined); - const outdated = this.extensionsWorkbenchService.outdated.reduce((r, e) => r + (this.extensionEnablementService.isEnabled(e.local!) && !actionRequired.includes(e) ? 1 : 0), 0); + const outdated = this.extensionsWorkbenchService.outdated.reduce((r, e) => r + (this.extensionEnablementService.isEnabled(e.local!) && !actionRequired.includes(e) && !this.extensionsWorkbenchService.isAutoUpdateDelayed(e) ? 1 : 0), 0); const newBadgeNumber = outdated + actionRequired.length; if (newBadgeNumber > 0) { let msg = ''; diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsWidgets.ts b/src/vs/workbench/contrib/extensions/browser/extensionsWidgets.ts index 91a30d512e5..57c7a5c0f4b 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsWidgets.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsWidgets.ts @@ -825,7 +825,16 @@ export class ExtensionHoverWidget extends ExtensionWidget { }, this.options.target, { - markdown: () => Promise.resolve(this.getHoverMarkdown()), + markdown: async () => { + // Recompute the status so any time-sensitive content (e.g. the + // delayed auto-update message) reflects the current time on each hover. + try { + await this.extensionStatusAction.recomputeStatus(); + } catch (error) { + // Ignore: fall back to the last computed status. + } + return this.getHoverMarkdown(); + }, markdownNotSupportedFallback: undefined }, { diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts index 3058d14e98c..671ed27e36e 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts @@ -7,7 +7,7 @@ import * as nls from '../../../../nls.js'; import * as semver from '../../../../base/common/semver/semver.js'; import { Event, Emitter } from '../../../../base/common/event.js'; import { index } from '../../../../base/common/arrays.js'; -import { CancelablePromise, Promises, ThrottledDelayer, createCancelablePromise } from '../../../../base/common/async.js'; +import { CancelablePromise, Promises, ThrottledDelayer, createCancelablePromise, disposableTimeout } from '../../../../base/common/async.js'; import { CancellationError, getErrorMessage, isCancellationError } from '../../../../base/common/errors.js'; import { Disposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { IPager, singlePagePager } from '../../../../base/common/paging.js'; @@ -52,7 +52,7 @@ import { FileAccess } from '../../../../base/common/network.js'; import { IIgnoredExtensionsManagementService } from '../../../../platform/userDataSync/common/ignoredExtensions.js'; import { IUserDataAutoSyncService, IUserDataSyncEnablementService, SyncResource } from '../../../../platform/userDataSync/common/userDataSync.js'; import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { isBoolean, isDefined, isString, isUndefined } from '../../../../base/common/types.js'; +import { isDefined, isString, isUndefined } from '../../../../base/common/types.js'; import { IExtensionManifestPropertiesService } from '../../../services/extensions/common/extensionManifestPropertiesService.js'; import { IExtensionService, IExtensionsStatus as IExtensionRuntimeStatus, toExtension, toExtensionDescription } from '../../../services/extensions/common/extensions.js'; import { isWeb, language } from '../../../../base/common/platform.js'; @@ -80,6 +80,8 @@ interface IExtensionStateProvider { (extension: Extension): T; } +const DELAYED_AUTO_UPDATE_PERIOD = 6 * 60 * 60 * 1000; // 6 hours in milliseconds + interface InstalledExtensionsEvent { readonly extensionIds: TelemetryTrustedValue; readonly count: number; @@ -998,6 +1000,7 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension private installing: IExtension[] = []; private tasksInProgress: CancelablePromise[] = []; + private readonly delayedAutoUpdateCheckTimer = this._register(new MutableDisposable()); readonly whenInitialized: Promise; @@ -1119,9 +1122,15 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension // Register listeners for auto updates this._register(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(AutoUpdateConfigurationKey)) { + if (this.getAutoUpdateValue() !== 'delayed') { + // No longer delaying — cancel any pending delayed re-check + this.delayedAutoUpdateCheckTimer.value = undefined; + } if (this.isAutoUpdateEnabled()) { this.eventuallyAutoUpdateExtensions(); } + // The auto update value affects whether an extension is shown as delayed + this._onChange.fire(undefined); } if (e.affectsConfiguration(AutoCheckUpdatesConfigurationKey)) { if (this.isAutoCheckUpdatesEnabled()) { @@ -1130,7 +1139,7 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension } })); this._register(this.extensionEnablementService.onEnablementChanged(platformExtensions => { - if (this.isAutoCheckUpdatesEnabled() && this.getAutoUpdateValue() === 'onlyEnabledExtensions' && platformExtensions.some(e => this.extensionEnablementService.isEnabled(e))) { + if (this.isAutoCheckUpdatesEnabled() && this.getAutoUpdateValue() !== 'off' && platformExtensions.some(e => this.extensionEnablementService.isEnabled(e))) { this.checkForUpdates('Extension enablement changed'); } })); @@ -1188,16 +1197,45 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension if (this.meteredConnectionService.isConnectionMetered) { return false; } - return this.getAutoUpdateValue() !== false; + return this.getAutoUpdateValue() !== 'off'; } getAutoUpdateValue(): AutoUpdateConfigurationValue { - const autoUpdate = this.configurationService.getValue(AutoUpdateConfigurationKey); - // eslint-disable-next-line local/code-no-any-casts - if (autoUpdate === 'onlySelectedExtensions') { + const autoUpdate = this.configurationService.getValue(AutoUpdateConfigurationKey); + // Normalize legacy values + if (autoUpdate === true || autoUpdate === 'on' || autoUpdate === 'all' || autoUpdate === 'onlyEnabledExtensions') { + return 'on'; + } + if (autoUpdate === false || autoUpdate === 'off' || autoUpdate === 'none' || autoUpdate === 'onlySelectedExtensions') { + return 'off'; + } + if (autoUpdate === 'delayed') { + return 'delayed'; + } + return 'on'; + } + + isAutoUpdateDelayed(extension: IExtension): boolean { + if (!extension.outdated) { return false; } - return isBoolean(autoUpdate) || autoUpdate === 'onlyEnabledExtensions' ? autoUpdate : true; + if (this.getAutoUpdateValue() !== 'delayed') { + return false; + } + return this.getAutoUpdateDelayRemaining(extension) > 0; + } + + getAutoUpdateDelayRemaining(extension: IExtension): number { + const lastUpdated = extension.gallery?.lastUpdated; + if (!Number.isFinite(lastUpdated) || !lastUpdated) { + return 0; + } + const elapsed = Date.now() - lastUpdated; + if (elapsed < 0) { + // Future timestamp (clock skew) — treat as not delayed + return 0; + } + return Math.max(0, DELAYED_AUTO_UPDATE_PERIOD - elapsed); } async updateAutoUpdateForAllExtensions(isAutoUpdateEnabled: boolean): Promise { @@ -1220,7 +1258,7 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension // Reset extensions enabled for auto update first to prevent them from being updated this.setEnabledAutoUpdateExtensions([]); - await this.configurationService.updateValue(AutoUpdateConfigurationKey, isAutoUpdateEnabled); + await this.configurationService.updateValue(AutoUpdateConfigurationKey, isAutoUpdateEnabled ? 'on' : 'off'); this.setDisabledAutoUpdateExtensions([]); await this.updateExtensionsPinnedState(!isAutoUpdateEnabled); @@ -2139,7 +2177,11 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension } private getUpdatesCheckInterval(): number { - if (this.productService.quality === 'insider' && this.getProductUpdateVersion()) { + if ( + this.productService.quality === 'insider' + && this.getProductUpdateVersion() + && this.getAutoUpdateValue() !== 'delayed' + ) { return 1000 * 60 * 60 * 1; // 1 hour } return ExtensionsWorkbenchService.UpdatesCheckInterval; @@ -2183,11 +2225,21 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension const toUpdate: IExtension[] = []; const disabledAutoUpdate = []; const consentRequired = []; + let soonestDelayRemaining = Number.MAX_SAFE_INTEGER; + const isDelayed = this.getAutoUpdateValue() === 'delayed'; for (const extension of this.outdated) { if (!this.shouldAutoUpdateExtension(extension)) { disabledAutoUpdate.push(extension.identifier.id); continue; } + if (isDelayed && !extension.local?.forceAutoUpdate) { + const delayRemaining = this.getAutoUpdateDelayRemaining(extension); + if (delayRemaining > 0) { + this.logService.trace('Auto update delayed for extension', extension.identifier.id); + soonestDelayRemaining = Math.min(soonestDelayRemaining, delayRemaining); + continue; + } + } if (await this.shouldRequireConsentToUpdate(extension)) { consentRequired.push(extension.identifier.id); continue; @@ -2195,6 +2247,12 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension toUpdate.push(extension); } + if (soonestDelayRemaining < Number.MAX_SAFE_INTEGER) { + this.delayedAutoUpdateCheckTimer.value = disposableTimeout(() => this.eventuallyCheckForUpdates(true), soonestDelayRemaining); + } else { + this.delayedAutoUpdateCheckTimer.value = undefined; + } + if (disabledAutoUpdate.length) { this.logService.trace('Auto update disabled for extensions', disabledAutoUpdate.join(', ')); } @@ -2246,7 +2304,7 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension const autoUpdateValue = this.getAutoUpdateValue(); - if (autoUpdateValue === false) { + if (autoUpdateValue === 'off') { const extensionsToAutoUpdate = this.getEnabledAutoUpdateExtensions(); const extensionId = extension.identifier.id.toLowerCase(); if (extensionsToAutoUpdate.includes(extensionId)) { @@ -2267,11 +2325,7 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension return false; } - if (autoUpdateValue === true) { - return true; - } - - if (autoUpdateValue === 'onlyEnabledExtensions') { + if (autoUpdateValue === 'on' || autoUpdateValue === 'delayed') { return extension.enablementState !== EnablementState.DisabledGlobally && extension.enablementState !== EnablementState.DisabledWorkspace; } diff --git a/src/vs/workbench/contrib/extensions/common/extensions.ts b/src/vs/workbench/contrib/extensions/common/extensions.ts index 8f38df97660..5d137eff868 100644 --- a/src/vs/workbench/contrib/extensions/common/extensions.ts +++ b/src/vs/workbench/contrib/extensions/common/extensions.ts @@ -161,6 +161,8 @@ export interface IExtensionsWorkbenchService { open(extension: IExtension | string, options?: IExtensionEditorOptions): Promise; openSearch(searchValue: string, focus?: boolean): Promise; getAutoUpdateValue(): AutoUpdateConfigurationValue; + isAutoUpdateDelayed(extension: IExtension): boolean; + getAutoUpdateDelayRemaining(extension: IExtension): number; checkForUpdates(): Promise; getExtensionRuntimeStatus(extension: IExtension): IExtensionRuntimeStatus | undefined; updateAll(): Promise; @@ -189,10 +191,10 @@ export const AutoCheckUpdatesConfigurationKey = 'extensions.autoCheckUpdates'; export const CloseExtensionDetailsOnViewChangeKey = 'extensions.closeExtensionDetailsOnViewChange'; export const AutoRestartConfigurationKey = 'extensions.autoRestart'; -export type AutoUpdateConfigurationValue = boolean | 'onlyEnabledExtensions' | 'onlySelectedExtensions'; +export type AutoUpdateConfigurationValue = 'on' | 'delayed' | 'off'; export interface IExtensionsConfiguration { - autoUpdate: boolean; + autoUpdate: AutoUpdateConfigurationValue; autoCheckUpdates: boolean; ignoreRecommendations: boolean; closeExtensionDetailsOnViewChange: boolean; diff --git a/src/vs/workbench/contrib/extensions/test/electron-browser/extensionsWorkbenchService.test.ts b/src/vs/workbench/contrib/extensions/test/electron-browser/extensionsWorkbenchService.test.ts index 6c008b531ca..c2a78617313 100644 --- a/src/vs/workbench/contrib/extensions/test/electron-browser/extensionsWorkbenchService.test.ts +++ b/src/vs/workbench/contrib/extensions/test/electron-browser/extensionsWorkbenchService.test.ts @@ -424,6 +424,76 @@ suite('ExtensionsWorkbenchServiceTest', () => { assert.ok(!testObject.local[0].outdated); }); + test('test isAutoUpdateDelayed returns false when auto update is not set to delayed', async () => { + stubConfiguration('on', true); + testObject = await anOutdatedExtensionWorkbenchService(Date.now()); + + assert.strictEqual(testObject.local[0].outdated, true); + assert.strictEqual(testObject.isAutoUpdateDelayed(testObject.local[0]), false); + }); + + test('test isAutoUpdateDelayed returns true for recently published version when auto update is set to delayed', async () => { + stubConfiguration('delayed', true); + testObject = await anOutdatedExtensionWorkbenchService(Date.now() - (1000 * 60 * 60) /* 1 hour ago */); + + assert.strictEqual(testObject.local[0].outdated, true); + assert.strictEqual(testObject.isAutoUpdateDelayed(testObject.local[0]), true); + }); + + test('test isAutoUpdateDelayed returns false for version published more than 6 hours ago', async () => { + stubConfiguration('delayed', true); + testObject = await anOutdatedExtensionWorkbenchService(Date.now() - (1000 * 60 * 60 * 7) /* 7 hours ago */); + + assert.strictEqual(testObject.local[0].outdated, true); + assert.strictEqual(testObject.isAutoUpdateDelayed(testObject.local[0]), false); + }); + + test('test isAutoUpdateDelayed returns false for version with a future published timestamp', async () => { + stubConfiguration('delayed', true); + testObject = await anOutdatedExtensionWorkbenchService(Date.now() + (1000 * 60 * 60) /* 1 hour in the future */); + + assert.strictEqual(testObject.local[0].outdated, true); + assert.strictEqual(testObject.isAutoUpdateDelayed(testObject.local[0]), false); + }); + + test('test getAutoUpdateValue normalizes configured and legacy values', async () => { + const result: Record = {}; + for (const value of [true, false, 'on', 'off', 'all', 'onlyEnabledExtensions', 'onlySelectedExtensions', 'delayed', 'none', 'unknown']) { + stubConfiguration(value); + testObject = await aWorkbenchService(); + result[String(value)] = testObject.getAutoUpdateValue(); + } + + assert.deepStrictEqual(result, { + 'true': 'on', + 'false': 'off', + 'on': 'on', + 'off': 'off', + 'all': 'on', + 'onlyEnabledExtensions': 'on', + 'onlySelectedExtensions': 'off', + 'delayed': 'delayed', + 'none': 'off', + 'unknown': 'on', + }); + }); + + test('test getAutoUpdateDelayRemaining returns remaining time within the delay window', async () => { + stubConfiguration('delayed', true); + testObject = await anOutdatedExtensionWorkbenchService(Date.now() - (1000 * 60 * 60 * 2) /* 2 hours ago */); + + const remaining = testObject.getAutoUpdateDelayRemaining(testObject.local[0]); + // Published 2 hours ago, so ~4 hours of the 6 hour window remain. + assert.ok(remaining > (1000 * 60 * 60 * 3) && remaining <= (1000 * 60 * 60 * 4), `unexpected remaining time ${remaining}`); + }); + + test('test getAutoUpdateDelayRemaining returns 0 when the published timestamp is missing', async () => { + stubConfiguration('delayed', true); + testObject = await anOutdatedExtensionWorkbenchService(undefined); + + assert.strictEqual(testObject.getAutoUpdateDelayRemaining(testObject.local[0]), 0); + }); + test('test canInstall returns false for extensions with out gallery', async () => { const local = aLocalExtension('a', { version: '1.0.1' }, { type: ExtensionType.System }); instantiationService.stubPromise(IExtensionManagementService, 'getInstalled', [local]); @@ -1516,7 +1586,7 @@ suite('ExtensionsWorkbenchServiceTest', () => { }); test('Test disable autoupdate for extension when auto update is enabled for enabled extensions', async () => { - stubConfiguration('onlyEnabledExtensions'); + stubConfiguration('on'); const extension1 = aLocalExtension('a'); const extension2 = aLocalExtension('b'); @@ -1695,9 +1765,21 @@ suite('ExtensionsWorkbenchServiceTest', () => { return workbenchService; } + async function anOutdatedExtensionWorkbenchService(lastUpdated: number | undefined): Promise { + const local = aLocalExtension('a', { version: '1.0.1' }); + const gallery = aGalleryExtension(local.manifest.name, { identifier: local.identifier, version: '1.0.2', lastUpdated }); + instantiationService.stubPromise(IExtensionManagementService, 'getInstalled', [local]); + instantiationService.stubPromise(IExtensionGalleryService, 'query', aPage(gallery)); + instantiationService.stubPromise(IExtensionGalleryService, 'getCompatibleExtension', gallery); + instantiationService.stubPromise(IExtensionGalleryService, 'getExtensions', [gallery]); + const workbenchService = await aWorkbenchService(); + await workbenchService.checkForUpdates(); + return workbenchService; + } + function stubConfiguration(autoUpdateValue?: any, autoCheckUpdatesValue?: any): void { const values: any = { - [AutoUpdateConfigurationKey]: autoUpdateValue ?? true, + [AutoUpdateConfigurationKey]: autoUpdateValue ?? 'on', [AutoCheckUpdatesConfigurationKey]: autoCheckUpdatesValue ?? true }; const emitter = disposableStore.add(new Emitter());