diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index f5ddaf15a4..8dbd1e2ba7 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,11 +33,52 @@ 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: 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", diff --git a/src/features/UpdatePowerShell.ts b/src/features/UpdatePowerShell.ts index 99532e1d13..0d62cf746b 100644 --- a/src/features/UpdatePowerShell.ts +++ b/src/features/UpdatePowerShell.ts @@ -8,34 +8,196 @@ import type { ILogger } from "../logging"; import type { IPowerShellVersionDetails } from "../session"; import { changeSetting } from "../settings"; -interface IUpdateMessageItem extends vscode.MessageItem { - id: number; +/** 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; + 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]; +} + +/** + * 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. */ + 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); + // 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) { + 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 ( + installedVersion && + installedVersion.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 (installedVersion) { + if (installedVersion.compare(localVersion) <= 0) { + message += `\n(WinGet hasn't caught up yet — currently v${winget.version}.)`; + } else if (installedVersion.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 }; +} + +/** 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, + fn: (value: T) => void | Promise, +): Promise { + const resolved = await value; + 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 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 +211,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(FIRST_CORE_RELEASE) === -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 +260,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 +276,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 +301,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 +313,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 +325,67 @@ 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", + WINGET_PACKAGE, + "-s", + "winget", + "--accept-source-agreements", + ]); + const version = parseWinGetShowOutput(stdout); + if (version !== undefined) { + winget = { installed: true, version }; + } + } 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( + `Could not get the WinGet version: ${error instanceof Error ? error.message : "unknown"}`, + ); + winget = { installed: true }; + } + } + } + + 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 +394,132 @@ 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..."); + runInTerminal( + "PowerShell Upgrade (WinGet)", + WINGET_UPGRADE, + ); + } else { + // From: https://aka.ms/winget-docs + this.logger.write( + "Installing WinGet and upgrading PowerShell...", + ); + // 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": { + // 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/${WINGET_PKGS_REPO}` + + `/pulls?q=${WINGET_PACKAGE}+is:open`; + try { + let bestPR: IBestMatch | undefined; + let bestIssue: IBestMatch | 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=${WINGET_PACKAGE}+repo:${WINGET_PKGS_REPO}+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) { + bestPR = keepHighestMatch(bestPR, ver, html_url); + } else { + bestIssue = keepHighestMatch( + bestIssue, + ver, + html_url, + ); + } + } + // Prefer issue when linked to the PR for the same version. + if ( + bestPR && + bestIssue && + bestPR.version.compare(bestIssue.version) === 0 + ) { + statusUrl = bestIssue.url; + } else if ( + bestPR && + (!bestIssue || + bestPR.version.compare(bestIssue.version) > 0) + ) { + statusUrl = bestPR.url; + } else if (bestIssue) { + statusUrl = bestIssue.url; + } + } 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, diff --git a/test/features/UpdatePowerShell.test.ts b/test/features/UpdatePowerShell.test.ts index df146b3081..0aab0bd089 100644 --- a/test/features/UpdatePowerShell.test.ts +++ b/test/features/UpdatePowerShell.test.ts @@ -2,12 +2,139 @@ // Licensed under the MIT License. import assert from "assert"; +import { SemVer, type ReleaseType } from "semver"; import * as vscode from "vscode"; -import { UpdatePowerShell } from "../../src/features/UpdatePowerShell"; +import type { + IUpdatePrompt, + IUpdatePromptOption, + IWinGetStatus, +} from "../../src/features/UpdatePowerShell"; +import { + buildUpdatePrompt, + FIRST_CORE_RELEASE, + getLatestWinGetVersion, + isWinGetMissing, + parseWinGetShowOutput, + toTriple, + UpdatePowerShell, +} from "../../src/features/UpdatePowerShell"; 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; @@ -24,126 +151,345 @@ describe("UpdatePowerShell feature", function () { }); describe("When it should check for an update", function () { + /** A stable Core release, which is checked by default. */ + const stable = versionDetails(firstCore.version); + it("Won't check if 'promptToUpdatePowerShell' is false", async function () { - await changeSetting( - "promptToUpdatePowerShell", - false, - vscode.ConfigurationTarget.Workspace, + await withPromptToUpdatePowerShell(false, () => { + assertSkipsUpdateCheck(stable); + }); + }); + + 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"; + assertSkipsUpdateCheck(stable); + }); + + it("Should otherwise check to update PowerShell", function () { + 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"; + // 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, ); - 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, - ); + + 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 () { + // 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 () { + // The revision is optional, so it strips either form. + for (const version of [ + packageVersion(packaged), + packaged.version, + ]) { + assertDetects(toTriple, version, packaged.version); } }); - 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()); + it("Parses the version from 'winget show' output", function () { + assertDetects( + parseWinGetShowOutput, + [ + "Found Microsoft PowerShell [Microsoft.PowerShell]", + `Version: ${packageVersion(packaged)}`, + "Publisher: Microsoft Corporation", + "Moniker: powershell", + ].join("\n"), + packaged.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("Returns undefined when 'winget show' has no version", function () { + assertDetects( + parseWinGetShowOutput, + "No package found matching input criteria.", + undefined, + ); }); - 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()); + it("Gets the newest version from a winget-pkgs listing", function () { + assertDetects( + getLatestWinGetVersion, + [ + { + name: packageVersion(previousMajor(packaged)), + type: "dir", + }, + { name: packageVersion(firstCore), type: "dir" }, + { name: packaged.version, type: "dir" }, + { name: "README.md", type: "file" }, + ], + packaged.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()); + it("Returns undefined for a listing without versions", function () { + assertDetects( + getLatestWinGetVersion, + [{ name: ".gitattributes", type: "file" }], + undefined, + ); }); + }); - 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()); + describe("The update prompt", function () { + /** The button the prompt offers with the given ID and title. */ + const option = ( + id: IUpdatePromptOption["id"], + title: string, + ): IUpdatePromptOption => ({ id, title }); + + /** 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, [ + ...winget, + option("github", "Open GitHub Release"), + option("not-now", "Not Now"), + option("dont-show", "Don't Show Again"), + ]); + }; + + /** 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}`, + ); + } + }; + + /** 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)), + [], + ); + }; + + /** 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("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?", + ); + }); + + 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); + }); + } }); - describe("Which version it gets", function () { - 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")); + describe("WinGet detection errors", function () { + const errorWithCode = (message: string, code: unknown): Error => + Object.assign(new Error(message), { code }); + + it("Recognizes a missing WinGet executable", function () { + assert( + isWinGetMissing(errorWithCode("spawn winget ENOENT", "ENOENT")), + ); }); - 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")); + 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)); + } }); }); });