From f0b0133cbaccbd8885540e529fc8ffe097b8daf7 Mon Sep 17 00:00:00 2001 From: PtJade Ceramic <185668489+PtJade-Ceramic@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:48:25 +0800 Subject: [PATCH 1/5] test: define WinGet parsing and update prompt behavior Test-first: covers parsing of 'winget show' output and of winget-pkgs manifest listings, plus the buttons and notes the update prompt should show for each WinGet state, including that WinGet is not offered off Windows. --- test/features/UpdatePowerShell.test.ts | 133 ++++++++++++++++++++++++- 1 file changed, 132 insertions(+), 1 deletion(-) diff --git a/test/features/UpdatePowerShell.test.ts b/test/features/UpdatePowerShell.test.ts index df146b3081..bf6fc89e87 100644 --- a/test/features/UpdatePowerShell.test.ts +++ b/test/features/UpdatePowerShell.test.ts @@ -3,7 +3,13 @@ import assert from "assert"; import * as vscode from "vscode"; -import { UpdatePowerShell } from "../../src/features/UpdatePowerShell"; +import { + buildUpdatePrompt, + getLatestWinGetVersion, + parseWinGetShowOutput, + toTriple, + UpdatePowerShell, +} from "../../src/features/UpdatePowerShell"; import type { IPowerShellVersionDetails } from "../../src/session"; import { changeSetting } from "../../src/settings"; import { testLogger } from "../utils"; @@ -146,4 +152,129 @@ describe("UpdatePowerShell feature", function () { assert(tag?.startsWith("v7.6") || tag?.startsWith("v7.4")); }); }); + + describe("WinGet version detection", function () { + it("Strips the revision component of a WinGet version", function () { + assert.strictEqual(toTriple("7.5.3.0"), "7.5.3"); + assert.strictEqual(toTriple("7.5.3"), "7.5.3"); + }); + + it("Parses the version from 'winget show' output", function () { + assert.strictEqual( + parseWinGetShowOutput( + [ + "Found Microsoft PowerShell [Microsoft.PowerShell]", + "Version: 7.5.3.0", + "Publisher: Microsoft Corporation", + "Moniker: powershell", + ].join("\n"), + ), + "7.5.3", + ); + }); + + it("Returns undefined when 'winget show' has no version", function () { + assert.strictEqual( + parseWinGetShowOutput( + "No package found matching input criteria.", + ), + undefined, + ); + }); + + it("Gets the newest version from a winget-pkgs listing", function () { + assert.strictEqual( + getLatestWinGetVersion([ + { name: "7.4.5.0", type: "dir" }, + { name: "7.5.3.0", type: "dir" }, + { name: "7.6.0", type: "dir" }, + { name: "README.md", type: "file" }, + ]), + "7.6.0", + ); + }); + + it("Returns undefined for a listing without versions", function () { + assert.strictEqual( + getLatestWinGetVersion([ + { name: ".gitattributes", type: "file" }, + ]), + undefined, + ); + }); + }); + + describe("The update prompt", function () { + const titlesOf = (prompt: { options: { title: string }[] }): string[] => + prompt.options.map(({ title }) => title); + + it("Does not offer WinGet where it does not exist", function () { + const prompt = buildUpdatePrompt("7.5.2", "v7.5.3", false, { + installed: false, + }); + assert.deepStrictEqual(prompt.options, [ + { id: "github", title: "Open GitHub Release" }, + { id: "not-now", title: "Not Now" }, + { id: "dont-show", title: "Don't Show Again" }, + ]); + assert(!prompt.message.includes("WinGet")); + }); + + it("Names both versions and asks whether to upgrade", function () { + const prompt = buildUpdatePrompt("7.5.2", "v7.5.3", false, { + installed: false, + }); + assert(prompt.message.includes("PowerShell v7.5.2 is out-of-date")); + assert(prompt.message.includes("The latest version is v7.5.3")); + assert(prompt.message.includes("Would you like to upgrade?")); + }); + + it("Offers to install WinGet on Windows without it", function () { + const prompt = buildUpdatePrompt("7.5.2", "v7.5.3", true, { + installed: false, + }); + assert(titlesOf(prompt).includes("Install WinGet")); + assert(prompt.message.includes("is not installed")); + }); + + it("Mentions the version WinGet would offer when missing", function () { + const prompt = buildUpdatePrompt("7.5.2", "v7.5.3", true, { + installed: false, + version: "7.5.3", + }); + assert( + prompt.message.includes( + "WinGet is not installed. It offers v7.5.3", + ), + ); + }); + + it("Offers to upgrade with WinGet when it has the new version", function () { + const prompt = buildUpdatePrompt("7.5.2", "v7.5.3", true, { + installed: true, + version: "7.5.3", + }); + assert(titlesOf(prompt).includes("Upgrade with WinGet")); + assert(!titlesOf(prompt).includes("View WinGet Progress")); + assert(!prompt.message.includes("caught up")); + }); + + it("Offers WinGet progress and notes its older version", function () { + const prompt = buildUpdatePrompt("7.5.0", "v7.5.3", true, { + installed: true, + version: "7.5.1", + }); + assert(titlesOf(prompt).includes("View WinGet Progress")); + assert(prompt.message.includes("WinGet currently has v7.5.1")); + }); + + it("Says when WinGet has not caught up yet", function () { + assert( + buildUpdatePrompt("7.5.2", "v7.5.3", true, { + installed: true, + version: "7.4.0", + }).message.includes("hasn't caught up yet"), + ); + }); + }); }); From 0c38bd9f015ff377253ad5f3081d79b11ca4c550 Mon Sep 17 00:00:00 2001 From: PtJade Ceramic <185668489+PtJade-Ceramic@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:48:26 +0800 Subject: [PATCH 2/5] feat: add WinGet upgrade option to PowerShell update notification (#5477) Detects WinGet via 'winget show --id Microsoft.PowerShell', falling back to the winget-pkgs listing when the CLI is missing. The prompt can upgrade with WinGet, install WinGet, and follow WinGet's progress, noting the version WinGet currently has. It is built by a pure buildUpdatePrompt() function, and WinGet options are only offered on Windows. --- src/features/UpdatePowerShell.ts | 455 +++++++++++++++++++++++-------- 1 file changed, 338 insertions(+), 117 deletions(-) diff --git a/src/features/UpdatePowerShell.ts b/src/features/UpdatePowerShell.ts index 99532e1d13..b60b5773a0 100644 --- a/src/features/UpdatePowerShell.ts +++ b/src/features/UpdatePowerShell.ts @@ -8,34 +8,129 @@ import type { ILogger } from "../logging"; import type { IPowerShellVersionDetails } from "../session"; import { changeSetting } from "../settings"; -interface IUpdateMessageItem extends vscode.MessageItem { - id: number; +async function fetchJSON(url: string): Promise { + const response = await fetch(url); + if (!response.ok) return undefined; + return response.json(); +} + +/** Strip the 4th component from a WinGet version (e.g. "7.4.5.0" → "7.4.5"). */ +export function toTriple(v: string): string { + return v.split(".").slice(0, 3).join("."); +} + +/** Parse the PowerShell version out of `winget show` output. */ +export function parseWinGetShowOutput(output: string): string | undefined { + const match = /Version:\s*([\d.]+)/.exec(output); + return match ? toTriple(match[1]) : undefined; +} + +/** One entry from the winget-pkgs manifest directory listing. */ +export interface IWinGetManifestEntry { + name: string; + type: string; +} + +/** Get the newest PowerShell version from a manifest directory listing. */ +export function getLatestWinGetVersion( + entries: IWinGetManifestEntry[], +): string | undefined { + return entries + .filter(({ type }) => type === "dir") + .map(({ name }) => toTriple(name)) + .sort((a, b) => new SemVer(b).compare(a))[0]; +} + +/** What WinGet has for PowerShell on this machine, if anything. */ +export interface IWinGetStatus { + /** Whether the `winget` CLI was found. */ + installed: boolean; + /** The newest PowerShell version WinGet knows about, if known. */ + version?: string; +} + +/** A button of the update prompt. */ +export interface IUpdatePromptOption { + id: "winget" | "winget-progress" | "github" | "not-now" | "dont-show"; + title: string; +} + +/** The update prompt to show the user. */ +export interface IUpdatePrompt { + message: string; + options: IUpdatePromptOption[]; +} + +/** + * Build the update prompt's message and buttons. This is a pure function so it + * can be tested; the WinGet status must be detected beforehand. + */ +export function buildUpdatePrompt( + localVersion: string, + releaseTag: string, + isWindows: boolean, + winget: IWinGetStatus, +): IUpdatePrompt { + const releaseVersion = new SemVer(releaseTag); + const options: IUpdatePromptOption[] = []; + + // WinGet only exists on Windows, so don't offer it elsewhere. + if (isWindows) { + options.push({ + id: "winget", + title: winget.installed ? "Upgrade with WinGet" : "Install WinGet", + }); + if ( + winget.installed && + winget.version && + new SemVer(winget.version).compare(releaseVersion.version) < 0 + ) { + options.push({ + id: "winget-progress", + title: "View WinGet Progress", + }); + } + } + options.push( + { id: "github", title: "Open GitHub Release" }, + { id: "not-now", title: "Not Now" }, + { id: "dont-show", title: "Don't Show Again" }, + ); + + let message = + `PowerShell v${localVersion} is out-of-date.\n` + + `The latest version is v${releaseVersion.version}.`; + // Note when WinGet is relevant but not (yet) useful. + if (winget.installed && winget.version) { + const wingetVersion = new SemVer(winget.version); + if (wingetVersion.compare(localVersion) <= 0) { + message += `\n(WinGet hasn't caught up yet — currently v${winget.version}.)`; + } else if (wingetVersion.compare(releaseVersion.version) < 0) { + message += `\n(WinGet currently has v${winget.version}.)`; + } + } else if (isWindows) { + message += winget.version + ? `\n(WinGet is not installed. It offers v${winget.version}.)` + : `\n(WinGet, the Windows Package Manager, is not installed.)`; + } + message += `\nWould you like to upgrade?`; + + return { message, options }; +} + +/** Await a value/promise, and if non-nullish, pass it to `fn`. */ +async function whenSome( + value: T | undefined | null | Promise, + fn: (value: T) => void | Promise, +): Promise { + const resolved = await value; + if (resolved != null) await fn(resolved); } // This attempts to mirror PowerShell's `UpdatesNotification.cs` logic as much as // possibly, documented at: // https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_update_notifications export class UpdatePowerShell { - private static LTSBuildInfoURL = "https://aka.ms/pwsh-buildinfo-lts"; - private static StableBuildInfoURL = "https://aka.ms/pwsh-buildinfo-stable"; - private static PreviewBuildInfoURL = - "https://aka.ms/pwsh-buildinfo-preview"; - private static GitHubWebReleaseURL = - "https://github.com/PowerShell/PowerShell/releases/tag/"; - private static promptOptions: IUpdateMessageItem[] = [ - { - id: 0, - title: "Yes", - }, - { - id: 1, - title: "Not Now", - }, - { - id: 2, - title: "Don't Show Again", - }, - ]; private localVersion: SemVer; constructor( @@ -49,56 +144,47 @@ export class UpdatePowerShell { this.localVersion = new SemVer(versionDetails.commit); } + private skip(reason: string): false { + this.logger.writeDebug(reason); + return false; + } + private shouldCheckForUpdate(): boolean { // Respect user setting. const promptToUpdatePowerShell = vscode.workspace .getConfiguration("powershell") .get("promptToUpdatePowerShell", true); - if (!promptToUpdatePowerShell) { - this.logger.writeDebug( - "Setting 'promptToUpdatePowerShell' was false.", - ); - return false; - } + if (!promptToUpdatePowerShell) + return this.skip("Setting 'promptToUpdatePowerShell' was false."); // Respect environment configuration. - if (process.env.POWERSHELL_UPDATECHECK?.toLowerCase() === "off") { - this.logger.writeDebug( + if (process.env.POWERSHELL_UPDATECHECK?.toLowerCase() === "off") + return this.skip( "Environment variable 'POWERSHELL_UPDATECHECK' was 'Off'.", ); - return false; - } // Skip prompting when using Windows PowerShell for now. - if (this.localVersion.compare("6.0.0") === -1) { + if (this.localVersion.compare("6.0.0") === -1) // TODO: Maybe we should announce PowerShell Core? - this.logger.writeDebug( - "Not prompting to update Windows PowerShell.", - ); - return false; - } + return this.skip("Not prompting to update Windows PowerShell."); if (this.localVersion.prerelease.length > 1) { // Daily builds look like '7.3.0-daily20221206.1' which split to // ['daily20221206', '1'] and development builds look like // '7.3.0-preview.3-508-g07175...' which splits to ['preview', // '3-508-g0717...']. The ellipsis is hiding a 40 char hash. - const daily = this.localVersion.prerelease[0].toString(); - const commit = this.localVersion.prerelease[1].toString(); - // Skip if PowerShell is self-built, that is, this contains a commit hash. - if (commit.length >= 40) { - this.logger.writeDebug( - "Not prompting to update development build.", - ); - return false; - } + if (this.localVersion.prerelease[1].toString().length >= 40) + return this.skip("Not prompting to update development build."); // Skip if preview is a daily build. - if (daily.toLowerCase().startsWith("daily")) { - this.logger.writeDebug("Not prompting to update daily build."); - return false; - } + if ( + this.localVersion.prerelease[0] + .toString() + .toLowerCase() + .startsWith("daily") + ) + return this.skip("Not prompting to update daily build."); } // TODO: Check if network is available? @@ -107,17 +193,10 @@ export class UpdatePowerShell { } private async getRemoteVersion(url: string): Promise { - const response = await fetch(url); - if (!response.ok) { - return undefined; - } - // Looks like: - // { - // "ReleaseDate": "2022-10-20T22:01:38Z", - // "BlobName": "v7-2-7", - // "ReleaseTag": "v7.2.7" - // } - const data = await response.json(); + const data = await fetchJSON<{ + ReleaseTag: string; + }>(url); + if (!data) return undefined; this.logger.writeDebug( `Received from '${url}':\n${JSON.stringify(data, undefined, 2)}`, ); @@ -130,40 +209,21 @@ export class UpdatePowerShell { } this.logger.writeDebug("Checking for PowerShell update..."); - const tags: string[] = []; - if (process.env.POWERSHELL_UPDATECHECK?.toLowerCase() === "lts") { - // Only check for update to LTS. - this.logger.writeDebug("Checking for LTS update..."); - const tag = await this.getRemoteVersion( - UpdatePowerShell.LTSBuildInfoURL, - ); - if (tag != undefined) { - tags.push(tag); - } - } else { - // Check for update to stable. - this.logger.writeDebug("Checking for stable update..."); - const tag = await this.getRemoteVersion( - UpdatePowerShell.StableBuildInfoURL, - ); - if (tag != undefined) { - tags.push(tag); - } - - // Also check for a preview update. - if (this.localVersion.prerelease.length > 0) { - this.logger.writeDebug("Checking for preview update..."); - const tag = await this.getRemoteVersion( - UpdatePowerShell.PreviewBuildInfoURL, - ); - if (tag != undefined) { - tags.push(tag); - } - } - } - - for (const tag of tags) { - if (this.localVersion.compare(tag) === -1) { + const suffixes = + process.env.POWERSHELL_UPDATECHECK?.toLowerCase() === "lts" + ? ["lts"] + : this.localVersion.prerelease.length > 0 + ? ["stable", "preview"] + : ["stable"]; + this.logger.writeDebug( + `Checking for ${suffixes.join(" and ")} update...`, + ); + for (const tag of await Promise.all( + suffixes.map((s) => + this.getRemoteVersion(`https://aka.ms/pwsh-buildinfo-${s}`), + ), + )) { + if (tag != undefined && this.localVersion.compare(tag) === -1) { return tag; } } @@ -174,11 +234,9 @@ export class UpdatePowerShell { public async checkForUpdate(): Promise { try { - const tag = await this.maybeGetNewRelease(); - if (tag) { - await this.promptToUpdate(tag); - return; - } + await whenSome(this.maybeGetNewRelease(), (tag) => + this.promptToUpdate(tag), + ); } catch (err) { // Best effort. This probably failed to fetch the data from GitHub. this.logger.writeWarning( @@ -188,10 +246,11 @@ export class UpdatePowerShell { } private async openReleaseInBrowser(tag: string): Promise { - const url = vscode.Uri.parse( - UpdatePowerShell.GitHubWebReleaseURL + tag, + await vscode.env.openExternal( + vscode.Uri.parse( + `https://github.com/PowerShell/PowerShell/releases/tag/${tag}`, + ), ); - await vscode.env.openExternal(url); } private async promptToUpdate(tag: string): Promise { @@ -199,11 +258,58 @@ export class UpdatePowerShell { this.logger.write( `Prompting to update PowerShell v${this.localVersion.version} to v${releaseVersion.version}.`, ); + + const isWindows = process.platform === "win32"; + // Get the PowerShell version WinGet has, if WinGet exists here. + let winget: IWinGetStatus = { installed: false }; + if (isWindows) { + try { + const { execFile } = await import("node:child_process"); + const { promisify } = await import("node:util"); + const { stdout } = await promisify(execFile)("winget", [ + "show", + "--id", + "Microsoft.PowerShell", + "-s", + "winget", + "--accept-source-agreements", + ]); + const version = parseWinGetShowOutput(stdout); + if (version !== undefined) { + winget = { installed: true, version }; + } + } catch { + // WinGet may not be installed — fall back to the GitHub API to + // ask which version it would offer. + try { + const entries = await fetchJSON( + "https://api.github.com/repos/microsoft/winget-pkgs/contents/manifests/m/Microsoft/PowerShell?per_page=100", + ); + const version = entries + ? getLatestWinGetVersion(entries) + : undefined; + this.logger.writeDebug( + `WinGet repo latest: ${version ?? "not found"}`, + ); + if (version !== undefined) { + winget = { installed: false, version }; + } + } catch { + // Best effort. + } + } + } + + const { message, options } = buildUpdatePrompt( + this.localVersion.version, + tag, + isWindows, + winget, + ); + const result = await vscode.window.showInformationMessage( - `PowerShell v${this.localVersion.version} is out-of-date. - The latest version is v${releaseVersion.version}. - Would you like to open the GitHub release in your browser?`, - ...UpdatePowerShell.promptOptions, + message, + ...options, ); // If the user cancels the notification. @@ -212,20 +318,135 @@ export class UpdatePowerShell { return; } - this.logger.writeDebug( - `User said '${UpdatePowerShell.promptOptions[result.id].title}'.`, - ); + this.logger.writeDebug(`User said '${result.title}'.`); switch (result.id) { - // Yes - case 0: + case "winget": + if (winget.installed) { + this.logger.write("Upgrading PowerShell via WinGet..."); + vscode.window + .createTerminal("PowerShell Upgrade (WinGet)") + .sendText( + "winget update --id Microsoft.PowerShell -e -s winget", + ); + } else { + // From: https://aka.ms/winget-docs + this.logger.write( + "Installing WinGet and upgrading PowerShell...", + ); + vscode.window + .createTerminal("Install WinGet & Upgrade PowerShell") + .sendText( + "$result = Add-AppxPackage -RegisterByFamilyName -MainPackage Microsoft.DesktopAppInstaller_8wekyb3d8bbwe -ErrorAction SilentlyContinue; if ($?) { winget update --id Microsoft.PowerShell -e -s winget } else { Write-Warning 'Failed to install WinGet. See https://aka.ms/winget-docs' }", + ); + } + break; + case "winget-progress": { + // Open the winget-pkgs issue or PR for the version WinGet is + // missing; find the open issue/PR mentioning the highest + // PowerShell version. + let statusUrl = + "https://github.com/microsoft/winget-pkgs/pulls?q=Microsoft.PowerShell+is:open"; + try { + let bestPR: string | undefined; + let bestIssue: string | undefined; + let bestPRVer: string | undefined; + let bestIssueVer: string | undefined; + for (const { + ver, + html_url, + pull_request, + } of await Promise.all( + ( + ( + await fetchJSON<{ + items?: { + title: string; + html_url: string; + pull_request?: unknown; + }[]; + }>( + "https://api.github.com/search/issues?q=Microsoft.PowerShell+repo:microsoft/winget-pkgs+is:open&sort=created&order=desc&per_page=30", + ) + )?.items ?? [] + ).map( + async ( + item, + ): Promise => { + const tm = + /(?:New version|Update|\[Update Request\]).*?(\d+\.\d+\.\d+)/i.exec( + item.title, + ); + if (tm) return { ...item, ver: tm[1] }; + // Try to extract version from the issue body. + try { + const bm = ( + await fetchJSON<{ + body?: string; + }>( + item.html_url.replace( + "https://github.com/", + "https://api.github.com/repos/", + ), + ) + )?.body?.match( + /Package Version.*?(\d+\.\d+\.\d+)/i, + ); + if (bm) { + return { ...item, ver: bm[1] }; + } + } catch { + // Best effort. + } + return item; + }, + ), + )) { + if (!ver) continue; + if (pull_request) { + if ( + !bestPRVer || + new SemVer(ver).compare(bestPRVer) > 0 + ) { + bestPRVer = ver; + bestPR = html_url; + } + } else if ( + !bestIssueVer || + new SemVer(ver).compare(bestIssueVer) > 0 + ) { + bestIssueVer = ver; + bestIssue = html_url; + } + } + // Prefer issue when linked to the PR for the same version. + const prVer = bestPRVer ? new SemVer(bestPRVer) : undefined; + const issueVer = bestIssueVer + ? new SemVer(bestIssueVer) + : undefined; + if (prVer && issueVer && prVer.compare(issueVer) === 0) { + statusUrl = bestIssue!; + } else if ( + prVer && + (!issueVer || prVer.compare(issueVer) > 0) + ) { + statusUrl = bestPR!; + } else if (issueVer) { + statusUrl = bestIssue!; + } + } catch { + // Fall back to generic search. + } + await vscode.env.openExternal(vscode.Uri.parse(statusUrl)); + break; + } + case "github": await this.openReleaseInBrowser(tag); break; - // Not Now - case 1: + case "not-now": + // Do nothing. break; - // Don't Show Again - case 2: + case "dont-show": await changeSetting( "promptToUpdatePowerShell", false, From 34c33ec165c97e77c9107f4692b45963c620007b Mon Sep 17 00:00:00 2001 From: PtJade Ceramic <185668489+PtJade-Ceramic@users.noreply.github.com> Date: Sat, 12 Sep 2026 23:05:59 +0800 Subject: [PATCH 3/5] ci: add nuget.org to the PowerShellEditorServices sources Temporary workaround: PSES's nuget.config only lists an Azure DevOps feed, which returns 401 (Unauthorized) for anonymous downloads, so CI fails while restoring the .NET reference packs before any tests run. Drop this once that is fixed upstream. --- .github/workflows/ci-test.yml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index f5ddaf15a4..c8195c9535 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -15,6 +15,9 @@ jobs: ci: name: node strategy: + # Report every platform's result instead of cancelling the others as soon + # as one fails. + fail-fast: false matrix: os: [windows-latest, macos-latest, ubuntu-latest] runs-on: ${{ matrix.os }} @@ -30,6 +33,37 @@ jobs: repository: PowerShell/PowerShellEditorServices path: PowerShellEditorServices + # TODO: Drop this once PowerShellEditorServices can restore again. Its + # nuget.config only lists an Azure DevOps feed whose package downloads + # return 401 (Unauthorized) to anonymous clients such as GitHub runners, + # so restoring the .NET reference packs fails before any tests run. Add + # nuget.org and map the reference packs to it (otherwise NuGet still + # downloads them from the feed that 401s). + - name: Add nuget.org for the PowerShellEditorServices reference packs + shell: pwsh + run: | + $config = @( + '' + '' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + '' + ) + Set-Content -Path PowerShellEditorServices/nuget.config -Value $config + - name: Checkout vscode-powershell uses: actions/checkout@v7 with: From b17a97720c01c29d459a4516988a320266006260 Mon Sep 17 00:00:00 2001 From: PtJade Ceramic <185668489+PtJade-Ceramic@users.noreply.github.com> Date: Sun, 13 Sep 2026 02:15:27 +0800 Subject: [PATCH 4/5] test: update `@vscode/test-electron` to 3.x for the renamed macOS binary VS Code 1.110 renamed the macOS main executable from 'Contents/MacOS/Electron' to the product name ('Code - Insiders' for Insiders), so v2's hardcoded path fails with 'spawn ... Electron ENOENT' before any test runs. v3 resolves it from the bundle's Info.plist. CI needs the `@vscode` scope served by npmjs meanwhile: the PowerShell npm mirror has not cached 3.1.0 yet and returns 401 for packages it has not cached, which npm silently skips for optional dependencies. Fixes #5592. --- .github/workflows/ci-test.yml | 10 ++++++++++ package-lock.json | 8 ++++---- package.json | 2 +- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index c8195c9535..8dbd1e2ba7 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -69,6 +69,16 @@ jobs: with: path: vscode-powershell + # TODO: Drop this once the PowerShell npm mirror has cached + # @vscode/test-electron 3.x. The mirror returns 401 for packages it has + # not cached (upstream access needs auth), and npm silently skips optional + # dependencies it cannot fetch, so `vscode-test` cannot resolve the newer + # package and the run dies before doing anything. + - name: Use npmjs instead of the PowerShell mirror + shell: pwsh + working-directory: vscode-powershell + run: (Get-Content .npmrc) -replace '^registry=.*', 'registry=https://registry.npmjs.org/' | Set-Content .npmrc + - name: Validate snippets JSON file shell: pwsh run: $null = ConvertFrom-Json -InputObject (Get-Content -Raw -Path './snippets/PowerShell.json') diff --git a/package-lock.json b/package-lock.json index f51c954422..a05c96e848 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,7 @@ "@ungap/structured-clone": "^1.3.1", "@vscode/debugprotocol": "^1.68.0", "@vscode/test-cli": "^0.0.12", - "@vscode/test-electron": "^2.5.2", + "@vscode/test-electron": "^3.1.0", "esbuild-register": "^3.6.0", "eslint": "^10.5.0", "eslint-config-prettier": "^10.1.8", @@ -1797,8 +1797,8 @@ } }, "node_modules/@vscode/test-electron": { - "version": "2.5.2", - "integrity": "sha1-99QHjoIwzpyUMi8qKcwWwXlUCF0=", + "version": "3.1.0", + "integrity": "sha1-Rrk9EY3NOzyJyq4pahP5p/XrY8U=", "license": "MIT", "optional": true, "dependencies": { @@ -1809,7 +1809,7 @@ "semver": "^7.6.2" }, "engines": { - "node": ">=16" + "node": ">=22" } }, "node_modules/@vscode/vsce": { diff --git a/package.json b/package.json index 75eaf159e8..9f6ba11536 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "@ungap/structured-clone": "^1.3.1", "@vscode/debugprotocol": "^1.68.0", "@vscode/test-cli": "^0.0.12", - "@vscode/test-electron": "^2.5.2", + "@vscode/test-electron": "^3.1.0", "esbuild-register": "^3.6.0", "eslint": "^10.5.0", "eslint-config-prettier": "^10.1.8", From 096ead75906f3f52fa1d12bc889d01980b6ecbd6 Mon Sep 17 00:00:00 2001 From: PtJade Ceramic <185668489+PtJade-Ceramic@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:24:22 +0800 Subject: [PATCH 5/5] fix: address the WinGet review feedback - Only offer "Upgrade with WinGet" when the installed WinGet reports a newer version; otherwise `winget update` is a guaranteed no-op while the message already says WinGet has not caught up. "View WinGet Progress" is still offered while the catalog lags. - Treat only ENOENT from `winget show` as WinGet being missing. Other failures (source lookups, network problems, and so on) keep WinGet installed with an unknown version instead of offering to install it. - Run the install action in an explicit `powershell.exe` terminal, since `Add-AppxPackage` is a PowerShell cmdlet and the user's default terminal profile may be cmd or Git Bash. - Show the terminal before sending the WinGet command so progress and errors are visible, matching how the extension reveals its own terminal. --- src/features/UpdatePowerShell.ts | 211 ++++++--- test/features/UpdatePowerShell.test.ts | 583 +++++++++++++++++-------- 2 files changed, 541 insertions(+), 253 deletions(-) diff --git a/src/features/UpdatePowerShell.ts b/src/features/UpdatePowerShell.ts index b60b5773a0..0d62cf746b 100644 --- a/src/features/UpdatePowerShell.ts +++ b/src/features/UpdatePowerShell.ts @@ -8,6 +8,21 @@ import type { ILogger } from "../logging"; import type { IPowerShellVersionDetails } from "../session"; import { changeSetting } from "../settings"; +/** The WinGet package identifier for PowerShell. */ +const WINGET_PACKAGE = "Microsoft.PowerShell"; + +/** The WinGet command that upgrades PowerShell. */ +const WINGET_UPGRADE = `winget update --id ${WINGET_PACKAGE} -e -s winget`; + +/** The GitHub repository that hosts the WinGet package manifests. */ +const WINGET_PKGS_REPO = "microsoft/winget-pkgs"; + +/** + * The first PowerShell Core release. Anything older is Windows PowerShell, + * which is neither checked nor prompted for updates. + */ +export const FIRST_CORE_RELEASE = "6.0.0"; + async function fetchJSON(url: string): Promise { const response = await fetch(url); if (!response.ok) return undefined; @@ -41,6 +56,15 @@ export function getLatestWinGetVersion( .sort((a, b) => new SemVer(b).compare(a))[0]; } +/** + * Whether the error means there is no `winget` executable to run. Every other + * failure (a source lookup, a network problem, and so on) leaves WinGet itself + * installed, it just did not answer. + */ +export function isWinGetMissing(error: unknown): boolean { + return (error as { code?: unknown } | undefined)?.code === "ENOENT"; +} + /** What WinGet has for PowerShell on this machine, if anything. */ export interface IWinGetStatus { /** Whether the `winget` CLI was found. */ @@ -72,18 +96,29 @@ export function buildUpdatePrompt( winget: IWinGetStatus, ): IUpdatePrompt { const releaseVersion = new SemVer(releaseTag); + // WinGet can report a version while missing (it is what it would install), + // so only use a version that comes from an installed WinGet. + const installedVersion = + winget.installed && winget.version + ? new SemVer(winget.version) + : undefined; const options: IUpdatePromptOption[] = []; // WinGet only exists on Windows, so don't offer it elsewhere. if (isWindows) { - options.push({ - id: "winget", - title: winget.installed ? "Upgrade with WinGet" : "Install WinGet", - }); + if (!winget.installed) { + options.push({ id: "winget", title: "Install WinGet" }); + } else if ( + installedVersion === undefined || + installedVersion.compare(localVersion) > 0 + ) { + // Only offer the upgrade when WinGet actually has something newer, + // otherwise `winget update` is a guaranteed no-op. + options.push({ id: "winget", title: "Upgrade with WinGet" }); + } if ( - winget.installed && - winget.version && - new SemVer(winget.version).compare(releaseVersion.version) < 0 + installedVersion && + installedVersion.compare(releaseVersion.version) < 0 ) { options.push({ id: "winget-progress", @@ -101,11 +136,10 @@ export function buildUpdatePrompt( `PowerShell v${localVersion} is out-of-date.\n` + `The latest version is v${releaseVersion.version}.`; // Note when WinGet is relevant but not (yet) useful. - if (winget.installed && winget.version) { - const wingetVersion = new SemVer(winget.version); - if (wingetVersion.compare(localVersion) <= 0) { + if (installedVersion) { + if (installedVersion.compare(localVersion) <= 0) { message += `\n(WinGet hasn't caught up yet — currently v${winget.version}.)`; - } else if (wingetVersion.compare(releaseVersion.version) < 0) { + } else if (installedVersion.compare(releaseVersion.version) < 0) { message += `\n(WinGet currently has v${winget.version}.)`; } } else if (isWindows) { @@ -118,6 +152,20 @@ export function buildUpdatePrompt( return { message, options }; } +/** Open a terminal, show it, and run the given command. */ +function runInTerminal( + name: string, + command: string, + shellPath?: string, +): void { + // Pin the shell only when asked, else use the user's default terminal. + const terminal = shellPath + ? vscode.window.createTerminal({ name, shellPath }) + : vscode.window.createTerminal(name); + terminal.show(); + terminal.sendText(command); +} + /** Await a value/promise, and if non-nullish, pass it to `fn`. */ async function whenSome( value: T | undefined | null | Promise, @@ -127,6 +175,25 @@ async function whenSome( if (resolved != null) await fn(resolved); } +/** The highest-versioned WinGet package match seen so far, with its link. */ +interface IBestMatch { + version: SemVer; + url: string; +} + +/** The given match, or `best` when it already refers to a higher version. */ +function keepHighestMatch( + best: IBestMatch | undefined, + version: string, + url: string, +): IBestMatch { + const candidate = new SemVer(version); + if (best === undefined || candidate.compare(best.version) > 0) { + return { version: candidate, url }; + } + return best; +} + // This attempts to mirror PowerShell's `UpdatesNotification.cs` logic as much as // possibly, documented at: // https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_update_notifications @@ -164,7 +231,7 @@ export class UpdatePowerShell { ); // Skip prompting when using Windows PowerShell for now. - if (this.localVersion.compare("6.0.0") === -1) + if (this.localVersion.compare(FIRST_CORE_RELEASE) === -1) // TODO: Maybe we should announce PowerShell Core? return this.skip("Not prompting to update Windows PowerShell."); @@ -269,7 +336,7 @@ export class UpdatePowerShell { const { stdout } = await promisify(execFile)("winget", [ "show", "--id", - "Microsoft.PowerShell", + WINGET_PACKAGE, "-s", "winget", "--accept-source-agreements", @@ -278,24 +345,33 @@ export class UpdatePowerShell { if (version !== undefined) { winget = { installed: true, version }; } - } catch { - // WinGet may not be installed — fall back to the GitHub API to - // ask which version it would offer. - try { - const entries = await fetchJSON( - "https://api.github.com/repos/microsoft/winget-pkgs/contents/manifests/m/Microsoft/PowerShell?per_page=100", - ); - const version = entries - ? getLatestWinGetVersion(entries) - : undefined; + } catch (error) { + if (isWinGetMissing(error)) { + // WinGet is not installed — ask the winget-pkgs listing + // which version it would offer. + try { + const entries = await fetchJSON( + `https://api.github.com/repos/${WINGET_PKGS_REPO}/contents/manifests/m/Microsoft/PowerShell?per_page=100`, + ); + const version = entries + ? getLatestWinGetVersion(entries) + : undefined; + this.logger.writeDebug( + `WinGet repo latest: ${version ?? "not found"}`, + ); + if (version !== undefined) { + winget = { installed: false, version }; + } + } catch { + // Best effort. + } + } else { + // WinGet is installed, it just did not answer (a source or + // network problem), so keep it and leave the version unknown. this.logger.writeDebug( - `WinGet repo latest: ${version ?? "not found"}`, + `Could not get the WinGet version: ${error instanceof Error ? error.message : "unknown"}`, ); - if (version !== undefined) { - winget = { installed: false, version }; - } - } catch { - // Best effort. + winget = { installed: true }; } } } @@ -324,21 +400,24 @@ export class UpdatePowerShell { case "winget": if (winget.installed) { this.logger.write("Upgrading PowerShell via WinGet..."); - vscode.window - .createTerminal("PowerShell Upgrade (WinGet)") - .sendText( - "winget update --id Microsoft.PowerShell -e -s winget", - ); + runInTerminal( + "PowerShell Upgrade (WinGet)", + WINGET_UPGRADE, + ); } else { // From: https://aka.ms/winget-docs this.logger.write( "Installing WinGet and upgrading PowerShell...", ); - vscode.window - .createTerminal("Install WinGet & Upgrade PowerShell") - .sendText( - "$result = Add-AppxPackage -RegisterByFamilyName -MainPackage Microsoft.DesktopAppInstaller_8wekyb3d8bbwe -ErrorAction SilentlyContinue; if ($?) { winget update --id Microsoft.PowerShell -e -s winget } else { Write-Warning 'Failed to install WinGet. See https://aka.ms/winget-docs' }", - ); + // The command is PowerShell (Add-AppxPackage is a cmdlet), + // so don't run it in whatever profile happens to be the + // user's default terminal. + runInTerminal( + "Install WinGet & Upgrade PowerShell", + "$result = Add-AppxPackage -RegisterByFamilyName -MainPackage Microsoft.DesktopAppInstaller_8wekyb3d8bbwe -ErrorAction SilentlyContinue; " + + `if ($?) { ${WINGET_UPGRADE} } else { Write-Warning 'Failed to install WinGet. See https://aka.ms/winget-docs' }`, + "powershell.exe", + ); } break; case "winget-progress": { @@ -346,12 +425,11 @@ export class UpdatePowerShell { // missing; find the open issue/PR mentioning the highest // PowerShell version. let statusUrl = - "https://github.com/microsoft/winget-pkgs/pulls?q=Microsoft.PowerShell+is:open"; + `https://github.com/${WINGET_PKGS_REPO}` + + `/pulls?q=${WINGET_PACKAGE}+is:open`; try { - let bestPR: string | undefined; - let bestIssue: string | undefined; - let bestPRVer: string | undefined; - let bestIssueVer: string | undefined; + let bestPR: IBestMatch | undefined; + let bestIssue: IBestMatch | undefined; for (const { ver, html_url, @@ -366,7 +444,7 @@ export class UpdatePowerShell { pull_request?: unknown; }[]; }>( - "https://api.github.com/search/issues?q=Microsoft.PowerShell+repo:microsoft/winget-pkgs+is:open&sort=created&order=desc&per_page=30", + `https://api.github.com/search/issues?q=${WINGET_PACKAGE}+repo:${WINGET_PKGS_REPO}+is:open&sort=created&order=desc&per_page=30`, ) )?.items ?? [] ).map( @@ -404,35 +482,30 @@ export class UpdatePowerShell { )) { if (!ver) continue; if (pull_request) { - if ( - !bestPRVer || - new SemVer(ver).compare(bestPRVer) > 0 - ) { - bestPRVer = ver; - bestPR = html_url; - } - } else if ( - !bestIssueVer || - new SemVer(ver).compare(bestIssueVer) > 0 - ) { - bestIssueVer = ver; - bestIssue = html_url; + bestPR = keepHighestMatch(bestPR, ver, html_url); + } else { + bestIssue = keepHighestMatch( + bestIssue, + ver, + html_url, + ); } } // Prefer issue when linked to the PR for the same version. - const prVer = bestPRVer ? new SemVer(bestPRVer) : undefined; - const issueVer = bestIssueVer - ? new SemVer(bestIssueVer) - : undefined; - if (prVer && issueVer && prVer.compare(issueVer) === 0) { - statusUrl = bestIssue!; + if ( + bestPR && + bestIssue && + bestPR.version.compare(bestIssue.version) === 0 + ) { + statusUrl = bestIssue.url; } else if ( - prVer && - (!issueVer || prVer.compare(issueVer) > 0) + bestPR && + (!bestIssue || + bestPR.version.compare(bestIssue.version) > 0) ) { - statusUrl = bestPR!; - } else if (issueVer) { - statusUrl = bestIssue!; + statusUrl = bestPR.url; + } else if (bestIssue) { + statusUrl = bestIssue.url; } } catch { // Fall back to generic search. diff --git a/test/features/UpdatePowerShell.test.ts b/test/features/UpdatePowerShell.test.ts index bf6fc89e87..0aab0bd089 100644 --- a/test/features/UpdatePowerShell.test.ts +++ b/test/features/UpdatePowerShell.test.ts @@ -2,10 +2,18 @@ // Licensed under the MIT License. import assert from "assert"; +import { SemVer, type ReleaseType } from "semver"; import * as vscode from "vscode"; +import type { + IUpdatePrompt, + IUpdatePromptOption, + IWinGetStatus, +} from "../../src/features/UpdatePowerShell"; import { buildUpdatePrompt, + FIRST_CORE_RELEASE, getLatestWinGetVersion, + isWinGetMissing, parseWinGetShowOutput, toTriple, UpdatePowerShell, @@ -14,6 +22,119 @@ import type { IPowerShellVersionDetails } from "../../src/session"; import { changeSetting } from "../../src/settings"; import { testLogger } from "../utils"; +/** + * The first PowerShell Core release, which is also the oldest release checked + * for updates. Every version in this file derives from it, so the tests + * describe how releases relate to one another (newer, older, stable, + * prerelease) instead of pinning releases, which go stale. + */ +const firstCore = new SemVer(FIRST_CORE_RELEASE); + +/** `version` incremented by `release`, e.g. its next patch or minor release. */ +function bumped(version: SemVer, release: ReleaseType): SemVer { + // `new SemVer(version)` would return `version` itself here, and `inc` + // mutates, so clone from the string to leave the argument alone. + return new SemVer(version.version).inc(release); +} + +/** The major release before `version`, e.g. Windows PowerShell before Core. */ +function previousMajor(version: SemVer): SemVer { + return new SemVer(`${version.major - 1}.0.0`); +} + +/** The first preview of `release`, e.g. "6.0.0-preview.1". */ +function preview(release: SemVer): string { + return `${release.version}-preview.1`; +} + +/** How much of its commit hash a self-built PowerShell embeds in its version. */ +const COMMIT_HASH_LENGTH = 40; + +/** A self-built development release, which embeds its commit hash. */ +function developmentBuild(release: SemVer): string { + return `${preview(release)}-1-g${"0".repeat(COMMIT_HASH_LENGTH)}`; +} + +/** The day a daily release is stamped with, in PowerShell's `yyyyMMdd` form. */ +const DAILY_BUILD_DAY = "20221206"; + +/** A daily release, which is stamped with the day it was built. */ +function dailyBuild(release: SemVer): string { + return `${release.version}-daily${DAILY_BUILD_DAY}.1`; +} + +/** PowerShell version details, defaulting to a stable Core x64 build. */ +function versionDetails( + version: string, + overrides: Partial = {}, +): IPowerShellVersionDetails { + return { + version, + edition: "Core", + commit: version, + architecture: "X64", + ...overrides, + }; +} + +/** An `UpdatePowerShell` instance, for testing its private methods. */ +function updatePowerShell( + version: IPowerShellVersionDetails, +): UpdatePowerShell { + return new UpdatePowerShell(testLogger, version); +} + +/** Whether PowerShell would check for a new release to update to. */ +function shouldCheckForUpdate(version: IPowerShellVersionDetails): boolean { + // @ts-expect-error method is private. + return updatePowerShell(version).shouldCheckForUpdate(); +} + +/** Assert PowerShell would check for a new release to update to. */ +function assertChecksForUpdate(version: IPowerShellVersionDetails): void { + assert( + shouldCheckForUpdate(version), + `Expected PowerShell v${version.version} to check for an update.`, + ); +} + +/** Assert PowerShell would not check for a new release to update to. */ +function assertSkipsUpdateCheck(version: IPowerShellVersionDetails): void { + assert( + !shouldCheckForUpdate(version), + `Expected PowerShell v${version.version} not to check for an update.`, + ); +} + +/** The release tag PowerShell would offer to update to, if any. */ +function maybeGetNewRelease( + version: IPowerShellVersionDetails, +): Promise { + // @ts-expect-error method is private. + return updatePowerShell(version).maybeGetNewRelease(); +} + +/** Set `promptToUpdatePowerShell`, run the test, then restore the setting. */ +async function withPromptToUpdatePowerShell( + value: boolean, + test: () => void, +): Promise { + const setPromptToUpdate = (v: boolean | undefined): Promise => + changeSetting( + "promptToUpdatePowerShell", + v, + vscode.ConfigurationTarget.Workspace, + undefined, + ); + + await setPromptToUpdate(value); + try { + test(); + } finally { + await setPromptToUpdate(undefined); + } +} + describe("UpdatePowerShell feature", function () { let currentUpdateSetting: string | undefined; @@ -30,251 +151,345 @@ describe("UpdatePowerShell feature", function () { }); describe("When it should check for an update", function () { - it("Won't check if 'promptToUpdatePowerShell' is false", async function () { - await changeSetting( - "promptToUpdatePowerShell", - false, - vscode.ConfigurationTarget.Workspace, - undefined, - ); - try { - const version: IPowerShellVersionDetails = { - version: "7.3.0", - edition: "Core", - commit: "7.3.0", - architecture: "X64", - }; - const updater = new UpdatePowerShell(testLogger, version); - // @ts-expect-error method is private. - assert(!updater.shouldCheckForUpdate()); - } finally { - await changeSetting( - "promptToUpdatePowerShell", - undefined, - vscode.ConfigurationTarget.Workspace, - undefined, - ); - } - }); - - it("Won't check for Windows PowerShell", function () { - const version: IPowerShellVersionDetails = { - version: "5.1.22621", - edition: "Desktop", - commit: "5.1.22621", - architecture: "X64", - }; - const updater = new UpdatePowerShell(testLogger, version); - // @ts-expect-error method is private. - assert(!updater.shouldCheckForUpdate()); - }); + /** A stable Core release, which is checked by default. */ + const stable = versionDetails(firstCore.version); - it("Won't check for a development build of PowerShell", function () { - const version: IPowerShellVersionDetails = { - version: "7.3.0-preview.3", - edition: "Core", - commit: "7.3.0-preview.3-508-g07175ae0ff8eb7306fe0b0fc7d19bdef4fbf2d67", - architecture: "Arm64", - }; - const updater = new UpdatePowerShell(testLogger, version); - // @ts-expect-error method is private. - assert(!updater.shouldCheckForUpdate()); + it("Won't check if 'promptToUpdatePowerShell' is false", async function () { + await withPromptToUpdatePowerShell(false, () => { + assertSkipsUpdateCheck(stable); + }); }); - it("Won't check for a daily build of PowerShell", function () { - const version: IPowerShellVersionDetails = { - version: "7.3.0-daily20221206.1", - edition: "Core", - commit: "7.3.0-daily20221206.1", - architecture: "Arm64", - }; - const updater = new UpdatePowerShell(testLogger, version); - // @ts-expect-error method is private. - assert(!updater.shouldCheckForUpdate()); - }); + for (const { name, version } of [ + { + name: "Windows PowerShell", + version: versionDetails(previousMajor(firstCore).version, { + edition: "Desktop", + }), + }, + { + name: "a development build of PowerShell", + version: versionDetails(preview(firstCore), { + commit: developmentBuild(firstCore), + architecture: "Arm64", + }), + }, + { + name: "a daily build of PowerShell", + version: versionDetails(dailyBuild(firstCore), { + architecture: "Arm64", + }), + }, + ]) { + it(`Won't check for ${name}`, function () { + assertSkipsUpdateCheck(version); + }); + } it("Won't check if POWERSHELL_UPDATECHECK is 'Off'", function () { process.env.POWERSHELL_UPDATECHECK = "Off"; - const version: IPowerShellVersionDetails = { - version: "7.3.0", - edition: "Core", - commit: "7.3.0", - architecture: "X64", - }; - const updater = new UpdatePowerShell(testLogger, version); - // @ts-expect-error method is private. - assert(!updater.shouldCheckForUpdate()); + assertSkipsUpdateCheck(stable); }); it("Should otherwise check to update PowerShell", function () { - const version: IPowerShellVersionDetails = { - version: "7.3.0", - edition: "Core", - commit: "7.3.0", - architecture: "X64", - }; - const updater = new UpdatePowerShell(testLogger, version); - // @ts-expect-error method is private. - assert(updater.shouldCheckForUpdate()); + assertChecksForUpdate(stable); }); }); describe("Which version it gets", function () { + /** + * Ask the currently selected channel for the update it offers from + * `localVersion`, asserting it is a stable release newer than that + * version and returning it. No release number is pinned, so this holds + * for every release the channel reports from now on. + */ + const getOfferedUpdate = async ( + localVersion: string, + ): Promise => { + const tag = await maybeGetNewRelease(versionDetails(localVersion)); + assert( + tag !== undefined, + `Expected an update from v${localVersion}.`, + ); + const release = new SemVer(tag); + assert.strictEqual( + release.prerelease.length, + 0, + `Expected '${tag}' to be a stable release.`, + ); + assert.strictEqual( + release.compare(localVersion), + 1, + `Expected '${tag}' to be newer than v${localVersion}.`, + ); + return release; + }; + it("Would update to LTS", async function () { process.env.POWERSHELL_UPDATECHECK = "LTS"; - const version: IPowerShellVersionDetails = { - version: "7.2.0", - edition: "Core", - commit: "7.2.0", - architecture: "X64", - }; - const updater = new UpdatePowerShell(testLogger, version); - // @ts-expect-error method is private. - const tag: string = (await updater.maybeGetNewRelease()) ?? ""; - // NOTE: This will need to be updated each time an LTS is released. - // Also sometimes the prior LTS is more recently updated than the latest LTS. - assert(tag.startsWith("v7.4")); + // Only has to be older than whatever the channel offers. + const lts = await getOfferedUpdate(firstCore.version); + + // The LTS channel answers with LTS, so its own release leaves + // nothing newer to offer, unlike the (at least as new) stable one. + assert.strictEqual( + await maybeGetNewRelease(versionDetails(lts.version)), + undefined, + ); + + process.env.POWERSHELL_UPDATECHECK = "Default"; + const stable = await getOfferedUpdate(firstCore.version); + assert( + lts.compare(stable) <= 0, + `Expected LTS v${lts.version} not to be newer than stable v${stable.version}.`, + ); }); it("Would update to stable", async function () { - const version: IPowerShellVersionDetails = { - version: "7.3.0", - edition: "Core", - commit: "7.3.0", - architecture: "X64", - }; - const updater = new UpdatePowerShell(testLogger, version); - // @ts-expect-error method is private. - const tag: string | undefined = await updater.maybeGetNewRelease(); - // NOTE: This will need to be updated each new major stable. - // TODO: Upstream bug causes LTS releases to update the stable info. - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - assert(tag?.startsWith("v7.6") || tag?.startsWith("v7.4")); + // Only has to be older than whatever the channel offers. + await getOfferedUpdate(firstCore.version); }); }); describe("WinGet version detection", function () { + /** The release whose WinGet package versions these tests parse. */ + const packaged = bumped(firstCore, "minor"); + + /** The WinGet package version for `release`, which adds a revision. */ + const packageVersion = (release: SemVer): string => + `${release.version}.0`; + + /** + * Assert that `detect` derives exactly `expected` from `input`, where + * `undefined` means no version could be derived at all. + */ + const assertDetects = ( + detect: (input: T) => string | undefined, + input: T, + expected: string | undefined, + ): void => { + assert.strictEqual(detect(input), expected); + }; + it("Strips the revision component of a WinGet version", function () { - assert.strictEqual(toTriple("7.5.3.0"), "7.5.3"); - assert.strictEqual(toTriple("7.5.3"), "7.5.3"); + // The revision is optional, so it strips either form. + for (const version of [ + packageVersion(packaged), + packaged.version, + ]) { + assertDetects(toTriple, version, packaged.version); + } }); it("Parses the version from 'winget show' output", function () { - assert.strictEqual( - parseWinGetShowOutput( - [ - "Found Microsoft PowerShell [Microsoft.PowerShell]", - "Version: 7.5.3.0", - "Publisher: Microsoft Corporation", - "Moniker: powershell", - ].join("\n"), - ), - "7.5.3", + assertDetects( + parseWinGetShowOutput, + [ + "Found Microsoft PowerShell [Microsoft.PowerShell]", + `Version: ${packageVersion(packaged)}`, + "Publisher: Microsoft Corporation", + "Moniker: powershell", + ].join("\n"), + packaged.version, ); }); it("Returns undefined when 'winget show' has no version", function () { - assert.strictEqual( - parseWinGetShowOutput( - "No package found matching input criteria.", - ), + assertDetects( + parseWinGetShowOutput, + "No package found matching input criteria.", undefined, ); }); it("Gets the newest version from a winget-pkgs listing", function () { - assert.strictEqual( - getLatestWinGetVersion([ - { name: "7.4.5.0", type: "dir" }, - { name: "7.5.3.0", type: "dir" }, - { name: "7.6.0", type: "dir" }, + assertDetects( + getLatestWinGetVersion, + [ + { + name: packageVersion(previousMajor(packaged)), + type: "dir", + }, + { name: packageVersion(firstCore), type: "dir" }, + { name: packaged.version, type: "dir" }, { name: "README.md", type: "file" }, - ]), - "7.6.0", + ], + packaged.version, ); }); it("Returns undefined for a listing without versions", function () { - assert.strictEqual( - getLatestWinGetVersion([ - { name: ".gitattributes", type: "file" }, - ]), + assertDetects( + getLatestWinGetVersion, + [{ name: ".gitattributes", type: "file" }], undefined, ); }); }); describe("The update prompt", function () { - const titlesOf = (prompt: { options: { title: string }[] }): string[] => - prompt.options.map(({ title }) => title); + /** The button the prompt offers with the given ID and title. */ + const option = ( + id: IUpdatePromptOption["id"], + title: string, + ): IUpdatePromptOption => ({ id, title }); - it("Does not offer WinGet where it does not exist", function () { - const prompt = buildUpdatePrompt("7.5.2", "v7.5.3", false, { - installed: false, - }); + /** The WinGet button the prompt offers, if any, for each state. */ + const installWinGet = option("winget", "Install WinGet"); + const upgradeWithWinGet = option("winget", "Upgrade with WinGet"); + const viewWinGetProgress = option( + "winget-progress", + "View WinGet Progress", + ); + + /** + * The prompt offers exactly the given WinGet buttons, if any, followed + * by the buttons every prompt offers. + */ + const assertOptions = ( + prompt: IUpdatePrompt, + ...winget: IUpdatePromptOption[] + ): void => { assert.deepStrictEqual(prompt.options, [ - { id: "github", title: "Open GitHub Release" }, - { id: "not-now", title: "Not Now" }, - { id: "dont-show", title: "Don't Show Again" }, + ...winget, + option("github", "Open GitHub Release"), + option("not-now", "Not Now"), + option("dont-show", "Don't Show Again"), ]); - assert(!prompt.message.includes("WinGet")); - }); + }; - it("Names both versions and asks whether to upgrade", function () { - const prompt = buildUpdatePrompt("7.5.2", "v7.5.3", false, { - installed: false, - }); - assert(prompt.message.includes("PowerShell v7.5.2 is out-of-date")); - assert(prompt.message.includes("The latest version is v7.5.3")); - assert(prompt.message.includes("Would you like to upgrade?")); - }); + /** The prompt's message mentions every one of the given strings. */ + const assertMentions = ( + prompt: IUpdatePrompt, + ...texts: string[] + ): void => { + for (const text of texts) { + assert( + prompt.message.includes(text), + `Expected the prompt to mention "${text}":\n${prompt.message}`, + ); + } + }; - it("Offers to install WinGet on Windows without it", function () { - const prompt = buildUpdatePrompt("7.5.2", "v7.5.3", true, { - installed: false, - }); - assert(titlesOf(prompt).includes("Install WinGet")); - assert(prompt.message.includes("is not installed")); - }); + /** The prompt's message mentions none of the given strings. */ + const assertOmits = ( + prompt: IUpdatePrompt, + ...texts: string[] + ): void => { + assert.deepStrictEqual( + texts.filter((text) => prompt.message.includes(text)), + [], + ); + }; - it("Mentions the version WinGet would offer when missing", function () { - const prompt = buildUpdatePrompt("7.5.2", "v7.5.3", true, { - installed: false, - version: "7.5.3", - }); - assert( - prompt.message.includes( - "WinGet is not installed. It offers v7.5.3", - ), + /** WinGet is missing on Windows, or not considered off Windows. */ + const noWinGet: IWinGetStatus = { installed: false }; + + /** The release the user has, and the one the prompt offers from it. */ + const localRelease = firstCore; + const offeredRelease = bumped(localRelease, "minor"); + + /** A WinGet release newer than the local one, but not the offer. */ + const wingetAheadOfLocal = bumped(localRelease, "patch"); + + /** A WinGet release older than the local one. */ + const wingetBehindLocal = previousMajor(localRelease); + + /** Build the prompt for the local release above and the offer from it. */ + const updatePrompt = ( + isWindows: boolean, + winget: IWinGetStatus, + ): IUpdatePrompt => + buildUpdatePrompt( + localRelease.version, + `v${offeredRelease.version}`, + isWindows, + winget, ); + + it("Does not offer WinGet where it does not exist", function () { + const prompt = updatePrompt(false, noWinGet); + assertOptions(prompt); + assertOmits(prompt, "WinGet"); }); - it("Offers to upgrade with WinGet when it has the new version", function () { - const prompt = buildUpdatePrompt("7.5.2", "v7.5.3", true, { - installed: true, - version: "7.5.3", - }); - assert(titlesOf(prompt).includes("Upgrade with WinGet")); - assert(!titlesOf(prompt).includes("View WinGet Progress")); - assert(!prompt.message.includes("caught up")); + it("Names both versions and asks whether to upgrade", function () { + assertMentions( + updatePrompt(false, noWinGet), + `PowerShell v${localRelease.version} is out-of-date`, + `The latest version is v${offeredRelease.version}`, + "Would you like to upgrade?", + ); }); - it("Offers WinGet progress and notes its older version", function () { - const prompt = buildUpdatePrompt("7.5.0", "v7.5.3", true, { - installed: true, - version: "7.5.1", + for (const state of [ + { + name: "Offers to install WinGet on Windows without it", + winget: noWinGet, + buttons: [installWinGet], + mentions: "is not installed", + }, + { + name: "Mentions the version WinGet would offer when missing", + winget: { installed: false, version: offeredRelease.version }, + buttons: [installWinGet], + mentions: `WinGet is not installed. It offers v${offeredRelease.version}`, + }, + { + name: "Offers to upgrade with WinGet when it has the new version", + winget: { installed: true, version: offeredRelease.version }, + buttons: [upgradeWithWinGet], + omits: "caught up", + }, + { + name: "Offers WinGet progress and notes its older version", + // WinGet has a newer version than the local one, but not the latest. + winget: { + installed: true, + version: wingetAheadOfLocal.version, + }, + buttons: [upgradeWithWinGet, viewWinGetProgress], + mentions: `WinGet currently has v${wingetAheadOfLocal.version}`, + }, + { + name: "Says when WinGet has not caught up yet, and offers no WinGet upgrade", + winget: { installed: true, version: wingetBehindLocal.version }, + buttons: [viewWinGetProgress], + mentions: "hasn't caught up yet", + }, + { + name: "Offers to upgrade when WinGet's version is unknown", + winget: { installed: true }, + buttons: [upgradeWithWinGet], + }, + ]) { + it(state.name, function () { + const prompt = updatePrompt(true, state.winget); + assertOptions(prompt, ...state.buttons); + if (state.mentions) assertMentions(prompt, state.mentions); + if (state.omits) assertOmits(prompt, state.omits); }); - assert(titlesOf(prompt).includes("View WinGet Progress")); - assert(prompt.message.includes("WinGet currently has v7.5.1")); - }); + } + }); + + describe("WinGet detection errors", function () { + const errorWithCode = (message: string, code: unknown): Error => + Object.assign(new Error(message), { code }); - it("Says when WinGet has not caught up yet", function () { + it("Recognizes a missing WinGet executable", function () { assert( - buildUpdatePrompt("7.5.2", "v7.5.3", true, { - installed: true, - version: "7.4.0", - }).message.includes("hasn't caught up yet"), + isWinGetMissing(errorWithCode("spawn winget ENOENT", "ENOENT")), ); }); + + it("Does not treat other failures as a missing WinGet", function () { + for (const error of [ + errorWithCode("failed to open source", 1), + new Error("unknown"), + undefined, + ]) { + assert(!isWinGetMissing(error)); + } + }); }); });