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));
+ }
});
});
});