diff --git a/src/common/constants.ts b/src/common/constants.ts index 087834ae..f4008f3d 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -2,6 +2,7 @@ import * as path from 'path'; export const ENVS_EXTENSION_ID = 'ms-python.vscode-python-envs'; export const PYTHON_EXTENSION_ID = 'ms-python.python'; +export const PYLANCE_EXTENSION_ID = 'ms-python.vscode-pylance'; export const CONDA_MANAGER_ID = `${PYTHON_EXTENSION_ID}:conda`; export const INLINE_SCRIPT_MANAGER_ID = `${PYTHON_EXTENSION_ID}:inline-script`; export const INLINE_SCRIPT_ENVS_KEY = `${ENVS_EXTENSION_ID}:inline-script:SCRIPT_ENVIRONMENTS`; diff --git a/src/common/extVersion.ts b/src/common/extVersion.ts index 6a671b7d..d318827c 100644 --- a/src/common/extVersion.ts +++ b/src/common/extVersion.ts @@ -1,7 +1,34 @@ -import { compare as pep440Compare, valid as pep440Valid } from '@renovatebot/pep440'; +import { compare as pep440Compare, explain as pep440Explain, valid as pep440Valid } from '@renovatebot/pep440'; import { PYTHON_EXTENSION_ID } from './constants'; import { getExtension } from './extension.apis'; -import { traceError } from './logging'; +import { traceError, traceWarn } from './logging'; + +export type ComparableExtensionVersion = + | { readonly kind: 'version'; readonly version: string } + | { readonly kind: 'not-installed' } + | { readonly kind: 'unknown' }; + +export function getComparableExtensionVersion(extensionId: string): ComparableExtensionVersion { + const extension = getExtension(extensionId); + if (!extension) { + return { kind: 'not-installed' }; + } + const rawVersion = extension.packageJSON?.version; + if (typeof rawVersion !== 'string') { + traceWarn(`Extension ${extensionId} reported no version string; skipping version comparison.`); + return { kind: 'unknown' }; + } + const parsed = pep440Explain(rawVersion); + if (!parsed) { + traceWarn(`Extension ${extensionId} version "${rawVersion}" is not PEP 440 parseable; skipping comparison.`); + return { kind: 'unknown' }; + } + if (parsed.is_devrelease) { + traceWarn(`Extension ${extensionId} version "${rawVersion}" is a dev build; skipping version comparison.`); + return { kind: 'unknown' }; + } + return { kind: 'version', version: rawVersion }; +} export function ensureCorrectVersion() { const extension = getExtension(PYTHON_EXTENSION_ID); diff --git a/src/common/localize.ts b/src/common/localize.ts index 43266c33..a6a6a3ce 100644 --- a/src/common/localize.ts +++ b/src/common/localize.ts @@ -23,6 +23,20 @@ export namespace WorkbenchStrings { export const installExtension = l10n.t('Install Extension'); } +export namespace InlineScriptStrings { + export const updateExtension = l10n.t('Update Extension'); + + export const updatePythonExtension = l10n.t( + 'The environment for this script was created. Update the Python extension for the full inline script experience.', + ); + export const updatePylanceExtension = l10n.t( + 'The environment for this script was created. Update Pylance for the full inline script experience.', + ); + export const updatePythonAndPylanceExtensions = l10n.t( + 'The environment for this script was created. Update the Python and Pylance extensions for the full inline script experience.', + ); +} + export namespace Interpreter { export const statusBarSelect = l10n.t('Select Interpreter'); export const browsePath = l10n.t('Browse...'); @@ -249,7 +263,7 @@ export namespace UvInstallStrings { export function inlineScriptInstallPythonPrompt(requiresPython?: string, version?: string): string { if (requiresPython && version) { return l10n.t( - 'No installed Python satisfies this script\'s requirement ({0}). Would you like to install Python {1} using uv?', + "No installed Python satisfies this script's requirement ({0}). Would you like to install Python {1} using uv?", requiresPython, version, ); @@ -267,7 +281,7 @@ export namespace UvInstallStrings { export function inlineScriptInstallPythonAndUvPrompt(requiresPython?: string, version?: string): string { if (requiresPython && version) { return l10n.t( - 'No installed Python satisfies this script\'s requirement ({0}). Would you like to install uv and use it to install Python {1}? This will download and run an installer from https://astral.sh.', + "No installed Python satisfies this script's requirement ({0}). Would you like to install uv and use it to install Python {1}? This will download and run an installer from https://astral.sh.", requiresPython, version, ); @@ -284,7 +298,7 @@ export namespace UvInstallStrings { } export function inlineScriptInstallUvForVersionLookupPrompt(requiresPython: string): string { return l10n.t( - 'No installed Python satisfies this script\'s requirement ({0}). Install uv to find a compatible Python version? This will download and run an installer from https://astral.sh.', + "No installed Python satisfies this script's requirement ({0}). Install uv to find a compatible Python version? This will download and run an installer from https://astral.sh.", requiresPython, ); } diff --git a/src/features/inlineScript/extensionVersionCheck.ts b/src/features/inlineScript/extensionVersionCheck.ts new file mode 100644 index 00000000..9daa8e84 --- /dev/null +++ b/src/features/inlineScript/extensionVersionCheck.ts @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { compare as pep440Compare } from '@renovatebot/pep440'; +import { PYLANCE_EXTENSION_ID, PYTHON_EXTENSION_ID } from '../../common/constants'; +import { getComparableExtensionVersion } from '../../common/extVersion'; +import { Common, InlineScriptStrings } from '../../common/localize'; +import { traceInfo, traceVerbose } from '../../common/logging'; +import { getGlobalPersistentState } from '../../common/persistentState'; +import { showWarningMessage } from '../../common/window.apis'; +import { openExtension } from '../../common/workbenchCommands'; +import { getConfiguration } from '../../common/workspace.apis'; + +export const INLINE_SCRIPT_UPDATE_EXTENSIONS_DONT_SHOW_KEY = 'python-envs:inline-script:UPDATE_EXTENSIONS_DONT_SHOW'; + +interface CompanionExtension { + readonly id: string; + /** Newest version on each channel that still LACKS the required change; anything newer is fine. */ + readonly lastUnsupportedStable: string; + readonly lastUnsupportedPreRelease: string; + /** Patch component at or above which a version is a pre-release build. */ + readonly preReleasePatchFloor: number; +} + +/** + * Python needs per-file interpreter resolution (`exactResource`, PR #26129, merged 2026-08-31); + * Pylance needs the `python/didChangeFilePythonPath` notification (PR #9302, merged 2026-09-01). + */ +const PYTHON_COMPANION: CompanionExtension = { + id: PYTHON_EXTENSION_ID, + lastUnsupportedStable: '2026.4.0', + lastUnsupportedPreRelease: '2026.7.2026082601', + preReleasePatchFloor: 1_000_000, +}; + +const PYLANCE_COMPANION: CompanionExtension = { + id: PYLANCE_EXTENSION_ID, + lastUnsupportedStable: '2026.3.1', + lastUnsupportedPreRelease: '2026.3.101', + preReleasePatchFloor: 100, +}; + +let promptShownThisSession = false; + +export function resetInlineScriptExtensionPromptForTests(): void { + promptShownThisSession = false; +} + +function isPylanceInUse(): boolean { + const languageServer = getConfiguration('python').get('languageServer', 'Default'); + return languageServer === 'Default' || languageServer === 'Pylance'; +} + +function isPreReleaseBuild(version: string, preReleasePatchFloor: number): boolean { + const patch = Number((version.split('.')[2] ?? '').replace(/\D.*$/, '')); + return Number.isFinite(patch) && patch >= preReleasePatchFloor; +} + +function isOutdated(extension: CompanionExtension): boolean { + const resolved = getComparableExtensionVersion(extension.id); + if (resolved.kind !== 'version') { + traceVerbose(`inline-script companion check: ${extension.id} -> ${resolved.kind}`); + return false; + } + const lastUnsupported = isPreReleaseBuild(resolved.version, extension.preReleasePatchFloor) + ? extension.lastUnsupportedPreRelease + : extension.lastUnsupportedStable; + const outdated = pep440Compare(resolved.version, lastUnsupported) <= 0; + if (outdated) { + traceVerbose(`inline-script companion check: ${extension.id} ${resolved.version} <= ${lastUnsupported}`); + } + return outdated; +} + +export function getOutdatedInlineScriptExtensions(): CompanionExtension[] { + const candidates = isPylanceInUse() ? [PYTHON_COMPANION, PYLANCE_COMPANION] : [PYTHON_COMPANION]; + return candidates.filter(isOutdated); +} + +function getOutdatedMessage(outdated: readonly CompanionExtension[]): string { + const hasPython = outdated.some((extension) => extension.id === PYTHON_EXTENSION_ID); + const hasPylance = outdated.some((extension) => extension.id === PYLANCE_EXTENSION_ID); + if (hasPython && hasPylance) { + return InlineScriptStrings.updatePythonAndPylanceExtensions; + } + return hasPython ? InlineScriptStrings.updatePythonExtension : InlineScriptStrings.updatePylanceExtension; +} + +export async function promptUpdateExtensionsForInlineScripts(): Promise { + if (promptShownThisSession) { + return; + } + + const outdated = getOutdatedInlineScriptExtensions(); + if (outdated.length === 0) { + return; + } + + // Latched here, not on entry, so an up-to-date run does not consume the session's one prompt. + promptShownThisSession = true; + + const state = await getGlobalPersistentState(); + if (await state.get(INLINE_SCRIPT_UPDATE_EXTENSIONS_DONT_SHOW_KEY)) { + traceInfo('Skipping inline-script companion extension prompt: user selected "Don\'t Show Again".'); + return; + } + + const names = outdated.map((extension) => extension.id).join(', '); + traceInfo(`Inline-script companion extensions out of date: ${names}`); + + const result = await showWarningMessage( + getOutdatedMessage(outdated), + InlineScriptStrings.updateExtension, + Common.dontShowAgain, + ); + + if (result === InlineScriptStrings.updateExtension) { + await openExtension(outdated[0].id); + } else if (result === Common.dontShowAgain) { + await state.set(INLINE_SCRIPT_UPDATE_EXTENSIONS_DONT_SHOW_KEY, true); + traceInfo('User selected "Don\'t Show Again" for the inline-script companion extension prompt.'); + } +} diff --git a/src/features/inlineScript/setupEnvironment.ts b/src/features/inlineScript/setupEnvironment.ts index 6a8a0cb0..f604fdde 100644 --- a/src/features/inlineScript/setupEnvironment.ts +++ b/src/features/inlineScript/setupEnvironment.ts @@ -17,6 +17,7 @@ import { import { asRelativePath, findFiles, getOpenTextDocuments } from '../../common/workspace.apis'; import { EnvironmentManagers } from '../../internal.api'; import { registerInlineScriptCodeLens } from './codeLens'; +import { promptUpdateExtensionsForInlineScripts } from './extensionVersionCheck'; /** * Hidden command invoked by the inline-script CodeLens to set up the environment for one script. @@ -115,7 +116,9 @@ function setupInlineScriptEnvironmentHandler( const environment = await setUpInlineScriptEnvironment(uri, em, routing); if (!environment) { notifyInlineScriptSetupOutcome(uri, routing); + return; } + await promptUpdateExtensionsForInlineScripts(); } catch (error) { traceError(`Failed to set up the inline-script environment for ${uri.fsPath}:`, error); showErrorMessage( @@ -259,6 +262,12 @@ export async function setUpInlineScriptEnvironmentsInWorkspace( `Inline-script bulk setup: created or reused ${succeeded} of ${picks.length} environment(s)` + `${cancelled ? ' (canceled)' : ''}.`, ); + if (succeeded > 0) { + // Not awaited so the run summary below is not held behind this notification. + void promptUpdateExtensionsForInlineScripts().catch((error) => + traceError('Failed to check companion extension versions for inline scripts:', error), + ); + } if (cancelled) { showWarningMessage( l10n.t( diff --git a/src/test/features/inlineScript/extensionVersionCheck.unit.test.ts b/src/test/features/inlineScript/extensionVersionCheck.unit.test.ts new file mode 100644 index 00000000..b0b50fb0 --- /dev/null +++ b/src/test/features/inlineScript/extensionVersionCheck.unit.test.ts @@ -0,0 +1,306 @@ +import assert from 'assert'; +import * as sinon from 'sinon'; +import { PYLANCE_EXTENSION_ID, PYTHON_EXTENSION_ID } from '../../../common/constants'; +import * as extensionApis from '../../../common/extension.apis'; +import { getComparableExtensionVersion } from '../../../common/extVersion'; +import { Common, InlineScriptStrings } from '../../../common/localize'; +import * as persistentState from '../../../common/persistentState'; +import * as windowApis from '../../../common/window.apis'; +import * as workbenchCommands from '../../../common/workbenchCommands'; +import * as workspaceApis from '../../../common/workspace.apis'; +import { + getOutdatedInlineScriptExtensions, + INLINE_SCRIPT_UPDATE_EXTENSIONS_DONT_SHOW_KEY, + promptUpdateExtensionsForInlineScripts, + resetInlineScriptExtensionPromptForTests, +} from '../../../features/inlineScript/extensionVersionCheck'; + +suite('inlineScript extensionVersionCheck', () => { + let getExtensionStub: sinon.SinonStub; + let showWarningMessageStub: sinon.SinonStub; + let openExtensionStub: sinon.SinonStub; + let mockState: { get: sinon.SinonStub; set: sinon.SinonStub; clear: sinon.SinonStub }; + let languageServerSetting: string; + + /** Register an installed extension with `version`; pass `undefined` for "not installed". */ + function stubExtension(id: string, version: string | undefined): void { + if (version === undefined) { + getExtensionStub.withArgs(id).returns(undefined); + return; + } + getExtensionStub.withArgs(id).returns({ id, packageJSON: { version } }); + } + + function outdatedIds(): string[] { + return getOutdatedInlineScriptExtensions().map((e) => e.id); + } + + setup(() => { + resetInlineScriptExtensionPromptForTests(); + languageServerSetting = 'Default'; + getExtensionStub = sinon.stub(extensionApis, 'getExtension').returns(undefined); + showWarningMessageStub = sinon.stub(windowApis, 'showWarningMessage').resolves(undefined); + openExtensionStub = sinon.stub(workbenchCommands, 'openExtension').resolves(); + sinon.stub(workspaceApis, 'getConfiguration').returns({ get: () => languageServerSetting } as never); + mockState = { + get: sinon.stub().resolves(undefined), + set: sinon.stub().resolves(), + clear: sinon.stub().resolves(), + }; + sinon.stub(persistentState, 'getGlobalPersistentState').resolves(mockState); + }); + + teardown(() => { + sinon.restore(); + resetInlineScriptExtensionPromptForTests(); + }); + + suite('getComparableExtensionVersion', () => { + test('reports not-installed when the extension is absent', () => { + assert.strictEqual(getComparableExtensionVersion('some.ext').kind, 'not-installed'); + }); + + test('returns the raw version for a normal release', () => { + stubExtension('some.ext', '2026.4.0'); + assert.deepStrictEqual(getComparableExtensionVersion('some.ext'), { + kind: 'version', + version: '2026.4.0', + }); + }); + + test('reports unknown for a local dev build', () => { + stubExtension('some.ext', '9999.0.0-dev'); + assert.strictEqual(getComparableExtensionVersion('some.ext').kind, 'unknown'); + }); + + test('reports unknown for a dev build that would otherwise sort as outdated', () => { + stubExtension('some.ext', '2026.7.0-dev'); + assert.strictEqual(getComparableExtensionVersion('some.ext').kind, 'unknown'); + }); + + test('reports unknown for an unparseable version', () => { + stubExtension('some.ext', 'not-a-version'); + assert.strictEqual(getComparableExtensionVersion('some.ext').kind, 'unknown'); + }); + + test('reports unknown when packageJSON carries no version', () => { + getExtensionStub.withArgs('some.ext').returns({ id: 'some.ext', packageJSON: {} }); + assert.strictEqual(getComparableExtensionVersion('some.ext').kind, 'unknown'); + }); + }); + + suite('Python channel thresholds', () => { + setup(() => stubExtension(PYLANCE_EXTENSION_ID, undefined)); + + test('current stable 2026.4.0 is outdated', () => { + stubExtension(PYTHON_EXTENSION_ID, '2026.4.0'); + assert.deepStrictEqual(outdatedIds(), [PYTHON_EXTENSION_ID]); + }); + + test('older stable is outdated', () => { + stubExtension(PYTHON_EXTENSION_ID, '2026.2.0'); + assert.deepStrictEqual(outdatedIds(), [PYTHON_EXTENSION_ID]); + }); + + test('a next stable below the pre-release line is accepted', () => { + // 2026.6.0 sorts BELOW pre-release 2026.7.x, so a single threshold would wrongly reject it. + stubExtension(PYTHON_EXTENSION_ID, '2026.6.0'); + assert.deepStrictEqual(outdatedIds(), []); + }); + + test('a next stable above the pre-release line is accepted', () => { + stubExtension(PYTHON_EXTENSION_ID, '2026.8.0'); + assert.deepStrictEqual(outdatedIds(), []); + }); + + test('current pre-release 2026.7.2026082601 is outdated', () => { + stubExtension(PYTHON_EXTENSION_ID, '2026.7.2026082601'); + assert.deepStrictEqual(outdatedIds(), [PYTHON_EXTENSION_ID]); + }); + + test('an older pre-release above the stable floor is still outdated', () => { + // > 2026.4.0 numerically, but it is a pre-release and must use the pre-release floor. + stubExtension(PYTHON_EXTENSION_ID, '2026.5.2026070801'); + assert.deepStrictEqual(outdatedIds(), [PYTHON_EXTENSION_ID]); + }); + + test('the next pre-release build is accepted', () => { + stubExtension(PYTHON_EXTENSION_ID, '2026.7.2026090101'); + assert.deepStrictEqual(outdatedIds(), []); + }); + }); + + suite('Pylance channel thresholds', () => { + setup(() => stubExtension(PYTHON_EXTENSION_ID, '2026.8.0')); + + test('current stable 2026.3.1 is outdated', () => { + stubExtension(PYLANCE_EXTENSION_ID, '2026.3.1'); + assert.deepStrictEqual(outdatedIds(), [PYLANCE_EXTENSION_ID]); + }); + + test('a next stable is accepted', () => { + stubExtension(PYLANCE_EXTENSION_ID, '2026.4.1'); + assert.deepStrictEqual(outdatedIds(), []); + }); + + test('pre-release 2026.3.101 is outdated', () => { + stubExtension(PYLANCE_EXTENSION_ID, '2026.3.101'); + assert.deepStrictEqual(outdatedIds(), [PYLANCE_EXTENSION_ID]); + }); + + test('pre-release 2026.3.102 (verified to carry the change) is accepted', () => { + stubExtension(PYLANCE_EXTENSION_ID, '2026.3.102'); + assert.deepStrictEqual(outdatedIds(), []); + }); + + test('an older pre-release above the stable floor is still outdated', () => { + stubExtension(PYLANCE_EXTENSION_ID, '2026.3.100'); + assert.deepStrictEqual(outdatedIds(), [PYLANCE_EXTENSION_ID]); + }); + }); + + suite('getOutdatedInlineScriptExtensions', () => { + test('returns nothing when both companions are current', () => { + stubExtension(PYTHON_EXTENSION_ID, '2026.8.0'); + stubExtension(PYLANCE_EXTENSION_ID, '2026.3.102'); + assert.deepStrictEqual(outdatedIds(), []); + }); + + test('ignores extensions that are not installed', () => { + stubExtension(PYTHON_EXTENSION_ID, undefined); + stubExtension(PYLANCE_EXTENSION_ID, undefined); + assert.deepStrictEqual(outdatedIds(), []); + }); + + test('ignores local dev builds of either companion', () => { + stubExtension(PYTHON_EXTENSION_ID, '2026.7.0-dev'); + stubExtension(PYLANCE_EXTENSION_ID, '9999.0.0-dev'); + assert.deepStrictEqual(outdatedIds(), []); + }); + + test('flags both when both are behind', () => { + stubExtension(PYTHON_EXTENSION_ID, '2026.7.2026082601'); + stubExtension(PYLANCE_EXTENSION_ID, '2026.3.101'); + assert.deepStrictEqual(outdatedIds(), [PYTHON_EXTENSION_ID, PYLANCE_EXTENSION_ID]); + }); + + test('skips Pylance entirely when it is not the configured language server', () => { + languageServerSetting = 'None'; + stubExtension(PYTHON_EXTENSION_ID, '2026.7.2026082601'); + stubExtension(PYLANCE_EXTENSION_ID, '2026.3.101'); + assert.deepStrictEqual(outdatedIds(), [PYTHON_EXTENSION_ID]); + }); + + test('still checks Pylance when the language server is explicitly Pylance', () => { + languageServerSetting = 'Pylance'; + stubExtension(PYTHON_EXTENSION_ID, '2026.8.0'); + stubExtension(PYLANCE_EXTENSION_ID, '2026.3.101'); + assert.deepStrictEqual(outdatedIds(), [PYLANCE_EXTENSION_ID]); + }); + }); + + suite('promptUpdateExtensionsForInlineScripts', () => { + function stubOutdatedPython(): void { + stubExtension(PYTHON_EXTENSION_ID, '2026.7.2026082601'); + stubExtension(PYLANCE_EXTENSION_ID, '2026.3.102'); + } + + test('does not warn when everything is current', async () => { + stubExtension(PYTHON_EXTENSION_ID, '2026.8.0'); + stubExtension(PYLANCE_EXTENSION_ID, '2026.3.102'); + await promptUpdateExtensionsForInlineScripts(); + assert.strictEqual(showWarningMessageStub.callCount, 0); + }); + + test('warns with the Python-only message', async () => { + stubOutdatedPython(); + await promptUpdateExtensionsForInlineScripts(); + assert.strictEqual(showWarningMessageStub.callCount, 1); + const [message, ...actions] = showWarningMessageStub.firstCall.args; + assert.strictEqual(message, InlineScriptStrings.updatePythonExtension); + assert.deepStrictEqual(actions, [InlineScriptStrings.updateExtension, Common.dontShowAgain]); + }); + + test('uses the Pylance-only message when only Pylance is behind', async () => { + stubExtension(PYTHON_EXTENSION_ID, '2026.8.0'); + stubExtension(PYLANCE_EXTENSION_ID, '2026.3.101'); + await promptUpdateExtensionsForInlineScripts(); + assert.strictEqual(showWarningMessageStub.firstCall.args[0], InlineScriptStrings.updatePylanceExtension); + }); + + test('uses the combined message when both are behind', async () => { + stubExtension(PYTHON_EXTENSION_ID, '2026.7.2026082601'); + stubExtension(PYLANCE_EXTENSION_ID, '2026.3.101'); + await promptUpdateExtensionsForInlineScripts(); + assert.strictEqual( + showWarningMessageStub.firstCall.args[0], + InlineScriptStrings.updatePythonAndPylanceExtensions, + ); + }); + + test('does not mention Pylance when the language server is Jedi', async () => { + languageServerSetting = 'Jedi'; + stubExtension(PYTHON_EXTENSION_ID, '2026.7.2026082601'); + stubExtension(PYLANCE_EXTENSION_ID, '2026.3.101'); + await promptUpdateExtensionsForInlineScripts(); + assert.strictEqual(showWarningMessageStub.firstCall.args[0], InlineScriptStrings.updatePythonExtension); + }); + + test('stays silent when only Pylance is behind and the language server is None', async () => { + languageServerSetting = 'None'; + stubExtension(PYTHON_EXTENSION_ID, '2026.8.0'); + stubExtension(PYLANCE_EXTENSION_ID, '2026.3.101'); + await promptUpdateExtensionsForInlineScripts(); + assert.strictEqual(showWarningMessageStub.callCount, 0); + }); + + test('"Update Extension" opens the outdated extension page', async () => { + stubOutdatedPython(); + showWarningMessageStub.resolves(InlineScriptStrings.updateExtension); + await promptUpdateExtensionsForInlineScripts(); + assert.ok(openExtensionStub.calledOnceWith(PYTHON_EXTENSION_ID)); + assert.strictEqual(mockState.set.callCount, 0, 'opening the page must not suppress future prompts'); + }); + + test('"Don\'t Show Again" persists the suppression flag', async () => { + stubOutdatedPython(); + showWarningMessageStub.resolves(Common.dontShowAgain); + await promptUpdateExtensionsForInlineScripts(); + assert.ok(mockState.set.calledOnceWith(INLINE_SCRIPT_UPDATE_EXTENSIONS_DONT_SHOW_KEY, true)); + assert.strictEqual(openExtensionStub.callCount, 0); + }); + + test('stays silent when the suppression flag is already set', async () => { + stubOutdatedPython(); + mockState.get.withArgs(INLINE_SCRIPT_UPDATE_EXTENSIONS_DONT_SHOW_KEY).resolves(true); + await promptUpdateExtensionsForInlineScripts(); + assert.strictEqual(showWarningMessageStub.callCount, 0); + }); + + test('shows at most once per session', async () => { + stubOutdatedPython(); + await promptUpdateExtensionsForInlineScripts(); + await promptUpdateExtensionsForInlineScripts(); + await promptUpdateExtensionsForInlineScripts(); + assert.strictEqual(showWarningMessageStub.callCount, 1); + }); + + test('an up-to-date run does not consume the session prompt', async () => { + stubExtension(PYTHON_EXTENSION_ID, '2026.8.0'); + stubExtension(PYLANCE_EXTENSION_ID, '2026.3.102'); + await promptUpdateExtensionsForInlineScripts(); + assert.strictEqual(showWarningMessageStub.callCount, 0); + + getExtensionStub.withArgs(PYTHON_EXTENSION_ID).returns({ packageJSON: { version: '2026.7.2026082601' } }); + await promptUpdateExtensionsForInlineScripts(); + assert.strictEqual(showWarningMessageStub.callCount, 1); + }); + + test('dismissing without choosing an action does not persist suppression', async () => { + stubOutdatedPython(); + showWarningMessageStub.resolves(undefined); + await promptUpdateExtensionsForInlineScripts(); + assert.strictEqual(mockState.set.callCount, 0); + }); + }); +});