From 38f202e5b8b8018ab6dabaf6fa017bc965571539 Mon Sep 17 00:00:00 2001 From: Stella Huang Date: Thu, 10 Sep 2026 16:28:21 -0700 Subject: [PATCH] Warn when companion extensions are too old for inline-script envs A PEP 723 inline-script environment is built correctly by this extension alone, but it only reaches the language server once ms-python.python resolves interpreters per file (exactResource, PR #26129) and Pylance handles the file-scoped python/didChangeFilePythonPath notification (PR #9302). Without both, setup reports success and the user is left without the full inline script experience and no explanation. Warn once, after a successful setup rather than before one, since that is the moment the gap becomes visible and the creation progress notification is already gone. Both the CodeLens command and the bulk command prompt at the handler level, so a bulk run shows one notification rather than one per script. The prompt offers "Update Extension" and "Don't Show Again"; the latter persists in global state. Dismissing the notification without choosing an action suppresses it for the session only. Version handling notes: - Thresholds are per channel because the two lines interleave numerically: pre-release 2026.5.x/2026.7.x sort above stable 2026.4.0, so a single threshold cannot express both. Channel is inferred from the patch component, since VS Code does not populate __metadata.preRelease for installed extensions. Both inference rules were checked against every published version above the relevant floors. - PEP 440 ordering is used, as these extensions are not semver. - Dev builds are skipped rather than compared. "2026.7.0-dev" is valid PEP 440 and normalizes to 2026.7.0.dev0, which sorts below every real build of the same minor, so comparing it would flag anyone running a local build. - A missing extension is not treated as outdated; Pylance is optional. - Pylance is skipped unless python.languageServer selects it, so Jedi and None users are not asked to update an unused extension. Each outdated combination has its own complete l10n string rather than a joined extension list, since conjunctions and word order are locale-specific. The thresholds record the newest version on each channel that lacks the required change, and must be refreshed if either extension ships another release before the change lands. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/common/constants.ts | 1 + src/common/extVersion.ts | 31 +- src/common/localize.ts | 20 +- .../inlineScript/extensionVersionCheck.ts | 123 +++++++ src/features/inlineScript/setupEnvironment.ts | 9 + .../extensionVersionCheck.unit.test.ts | 306 ++++++++++++++++++ 6 files changed, 485 insertions(+), 5 deletions(-) create mode 100644 src/features/inlineScript/extensionVersionCheck.ts create mode 100644 src/test/features/inlineScript/extensionVersionCheck.unit.test.ts diff --git a/src/common/constants.ts b/src/common/constants.ts index 087834ae9..f4008f3d9 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 6a671b7d1..d318827cc 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 43266c33a..a6a6a3ce4 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 000000000..9daa8e84d --- /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 6a8a0cb01..f604fdde2 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 000000000..b0b50fb05 --- /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); + }); + }); +});