Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: automatically use vscode version matching engine #202

Merged
merged 1 commit into from
Feb 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 95 additions & 6 deletions lib/download.test.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,32 @@
import { spawnSync } from 'child_process';
import { existsSync, promises as fs } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
import { downloadAndUnzipVSCode } from './download';
import { dirname, join } from 'path';
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest';
import {
downloadAndUnzipVSCode,
fetchInsiderVersions,
fetchStableVersions,
fetchTargetInferredVersion,
} from './download';
import { SilentReporter } from './progress';
import { resolveCliPathFromVSCodeExecutablePath, systemDefaultPlatform } from './util';

const platforms = ['darwin', 'darwin-arm64', 'win32-archive', 'win32-x64-archive', 'linux-x64', 'linux-arm64', 'linux-armhf'];
const platforms = [
'darwin',
'darwin-arm64',
'win32-archive',
'win32-x64-archive',
'linux-x64',
'linux-arm64',
'linux-armhf',
];

describe('sane downloads', () => {
const testTempDir = join(tmpdir(), 'vscode-test-download');

beforeAll(async () => {
await fs.mkdir(testTempDir, { recursive: true })
await fs.mkdir(testTempDir, { recursive: true });
});

for (const platform of platforms) {
Expand All @@ -39,7 +52,7 @@ describe('sane downloads', () => {
expect(version.status).to.equal(0);
expect(version.stdout.toString().trim()).to.not.be.empty;
}
})
});
}

afterAll(async () => {
Expand All @@ -50,3 +63,79 @@ describe('sane downloads', () => {
}
});
});

describe('fetchTargetInferredVersion', () => {
let stable: string[];
let insiders: string[];
let extensionsDevelopmentPath = join(tmpdir(), 'vscode-test-tmp-workspace');

beforeAll(async () => {
[stable, insiders] = await Promise.all([fetchStableVersions(5000), fetchInsiderVersions(5000)]);
});

afterEach(async () => {
await fs.rm(extensionsDevelopmentPath, { recursive: true, force: true });
});

const writeJSON = async (path: string, contents: object) => {
const target = join(extensionsDevelopmentPath, path);
await fs.mkdir(dirname(target), { recursive: true });
await fs.writeFile(target, JSON.stringify(contents));
};

const doFetch = (paths = ['./']) =>
fetchTargetInferredVersion({
cachePath: join(extensionsDevelopmentPath, '.cache'),
platform: 'win32-archive',
timeout: 5000,
extensionsDevelopmentPath: paths.map(p => join(extensionsDevelopmentPath, p)),
});

test('matches stable if no workspace', async () => {
const version = await doFetch();
expect(version).to.equal(stable[0]);
});

test('matches stable by default', async () => {
await writeJSON('package.json', {});
const version = await doFetch();
expect(version).to.equal(stable[0]);
});

test('matches if stable is defined', async () => {
await writeJSON('package.json', { engines: { vscode: '^1.50.0' } });
const version = await doFetch();
expect(version).to.equal(stable[0]);
});

test('matches best', async () => {
await writeJSON('package.json', { engines: { vscode: '<=1.60.5' } });
const version = await doFetch();
expect(version).to.equal('1.60.2');
});

test('matches multiple workspaces', async () => {
await writeJSON('a/package.json', { engines: { vscode: '<=1.60.5' } });
await writeJSON('b/package.json', { engines: { vscode: '<=1.55.5' } });
const version = await doFetch(['a', 'b']);
expect(version).to.equal('1.55.2');
});

test('matches insiders to better stable if there is one', async () => {
await writeJSON('package.json', { engines: { vscode: '^1.60.0-insider' } });
const version = await doFetch();
expect(version).to.equal(stable[0]);
});

test('matches current insiders', async () => {
await writeJSON('package.json', { engines: { vscode: `^${insiders[0]}` } });
const version = await doFetch();
expect(version).to.equal(insiders[0]);
});

test('matches insiders to exact', async () => {
await writeJSON('package.json', { engines: { vscode: '1.60.0-insider' } });
const version = await doFetch();
expect(version).to.equal('1.60.0-insider');
});
});
142 changes: 116 additions & 26 deletions lib/download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,19 @@ import { promisify } from 'util';
import * as del from './del';
import { ConsoleReporter, ProgressReporter, ProgressReportStage } from './progress';
import * as request from './request';
import * as semver from 'semver';
import {
downloadDirToExecutablePath,
getInsidersVersionMetadata,
getLatestInsidersMetadata,
getVSCodeDownloadUrl,
insidersDownloadDirMetadata,
insidersDownloadDirToExecutablePath,
isDefined,
isInsiderVersionIdentifier,
isStableVersionIdentifier,
isSubdirectory,
onceWithoutRejections,
streamToBuffer,
systemDefaultPlatform,
} from './util';
Expand All @@ -29,6 +33,7 @@ const extensionRoot = process.cwd();
const pipelineAsync = promisify(pipeline);

const vscodeStableReleasesAPI = `https://update.code.visualstudio.com/api/releases/stable`;
const vscodeInsiderReleasesAPI = `https://update.code.visualstudio.com/api/releases/insider`;
const vscodeInsiderCommitsAPI = (platform: string) =>
`https://update.code.visualstudio.com/api/commits/insider/${platform}`;

Expand All @@ -37,43 +42,117 @@ const makeDownloadDirName = (platform: string, version: string) => `vscode-${pla

const DOWNLOAD_ATTEMPTS = 3;

interface IFetchStableOptions {
timeout: number;
cachePath: string;
platform: string;
}

interface IFetchInferredOptions extends IFetchStableOptions {
extensionsDevelopmentPath?: string | string[];
}

export const fetchStableVersions = onceWithoutRejections((timeout: number) =>
request.getJSON<string[]>(vscodeStableReleasesAPI, timeout)
);
export const fetchInsiderVersions = onceWithoutRejections((timeout: number) =>
request.getJSON<string[]>(vscodeInsiderReleasesAPI, timeout)
);

/**
* Returns the stable version to run tests against. Attempts to get the latest
* version from the update sverice, but falls back to local installs if
* not available (e.g. if the machine is offline).
*/
async function fetchTargetStableVersion(timeout: number, cachePath: string, platform: string): Promise<string> {
let versions: string[] = [];
async function fetchTargetStableVersion({ timeout, cachePath, platform }: IFetchStableOptions): Promise<string> {
try {
versions = await request.getJSON<string[]>(vscodeStableReleasesAPI, timeout);
const versions = await fetchStableVersions(timeout);
return versions[0];
} catch (e) {
const entries = await fs.promises.readdir(cachePath).catch(() => [] as string[]);
const [fallbackTo] = entries
.map((e) => downloadDirNameFormat.exec(e))
.filter(isDefined)
.filter((e) => e.groups!.platform === platform)
.map((e) => e.groups!.version)
.sort((a, b) => Number(b) - Number(a));

if (fallbackTo) {
console.warn(`Error retrieving VS Code versions, using already-installed version ${fallbackTo}`, e);
return fallbackTo;
return fallbackToLocalEntries(cachePath, platform, e as Error);
}
}

export async function fetchTargetInferredVersion(options: IFetchInferredOptions) {
if (!options.extensionsDevelopmentPath) {
return fetchTargetStableVersion(options);
}

// load all engines versions from all development paths. Then, get the latest
// stable version (or, latest Insiders version) that satisfies all
// `engines.vscode` constraints.
const extPaths = Array.isArray(options.extensionsDevelopmentPath)
? options.extensionsDevelopmentPath
: [options.extensionsDevelopmentPath];
const maybeExtVersions = await Promise.all(extPaths.map(getEngineVersionFromExtension));
const extVersions = maybeExtVersions.filter(isDefined);
const matches = (v: string) => !extVersions.some((range) => !semver.satisfies(v, range, { includePrerelease: true }));

try {
const stable = await fetchStableVersions(options.timeout);
const found1 = stable.find(matches);
if (found1) {
return found1;
}

throw e;
const insiders = await fetchInsiderVersions(options.timeout);
const found2 = insiders.find(matches);
if (found2) {
return found2;
}

console.warn(`No version of VS Code satisfies all extension engine constraints (${extVersions.join(', ')}). Falling back to stable.`);

return stable[0]; // 🤷
} catch (e) {
return fallbackToLocalEntries(options.cachePath, options.platform, e as Error);
}
}

async function getEngineVersionFromExtension(extensionPath: string): Promise<string | undefined> {
try {
const packageContents = await fs.promises.readFile(path.join(extensionPath, 'package.json'), 'utf8');
const packageJson = JSON.parse(packageContents);
return packageJson?.engines?.vscode;
} catch {
return undefined;
}
}

async function fallbackToLocalEntries(cachePath: string, platform: string, fromError: Error) {
const entries = await fs.promises.readdir(cachePath).catch(() => [] as string[]);
const [fallbackTo] = entries
.map((e) => downloadDirNameFormat.exec(e))
.filter(isDefined)
.filter((e) => e.groups!.platform === platform)
.map((e) => e.groups!.version)
.sort((a, b) => Number(b) - Number(a));

if (fallbackTo) {
console.warn(`Error retrieving VS Code versions, using already-installed version ${fallbackTo}`, fromError);
return fallbackTo;
}

return versions[0];
throw fromError;
}

async function isValidVersion(version: string, platform: string, timeout: number) {
if (version === 'insiders') {
if (version === 'insiders' || version === 'stable') {
return true;
}

const stableVersionNumbers: string[] = await request.getJSON(vscodeStableReleasesAPI, timeout);
if (stableVersionNumbers.includes(version)) {
return true;
if (isStableVersionIdentifier(version)) {
const stableVersionNumbers = await fetchStableVersions(timeout);
if (stableVersionNumbers.includes(version)) {
return true;
}
}

if (isInsiderVersionIdentifier(version)) {
const insiderVersionNumbers = await fetchInsiderVersions(timeout);
if (insiderVersionNumbers.includes(version)) {
return true;
}
}

const insiderCommits: string[] = await request.getJSON(vscodeInsiderCommitsAPI(platform), timeout);
Expand All @@ -97,6 +176,7 @@ export interface DownloadOptions {
readonly cachePath: string;
readonly version: DownloadVersion;
readonly platform: DownloadPlatform;
readonly extensionDevelopmentPath?: string | string[];
readonly reporter?: ProgressReporter;
readonly extractSync?: boolean;
readonly timeout?: number;
Expand All @@ -116,6 +196,7 @@ async function downloadVSCodeArchive(options: DownloadOptions) {

const timeout = options.timeout!;
const downloadUrl = getVSCodeDownloadUrl(options.version, options.platform);

options.reporter?.report({ stage: ProgressReportStage.ResolvingCDNLocation, url: downloadUrl });
const res = await request.getStream(downloadUrl, timeout);
if (res.statusCode !== 302) {
Expand Down Expand Up @@ -248,7 +329,9 @@ export async function download(options: Partial<DownloadOptions> = {}): Promise<
timeout = 15_000,
} = options;

if (version && version !== 'stable') {
if (version === 'stable') {
version = await fetchTargetStableVersion({ timeout, cachePath, platform });
} else if (version) {
/**
* Only validate version against server when no local download that matches version exists
*/
Expand All @@ -258,20 +341,27 @@ export async function download(options: Partial<DownloadOptions> = {}): Promise<
}
}
} else {
version = await fetchTargetStableVersion(timeout, cachePath, platform);
version = await fetchTargetInferredVersion({
timeout,
cachePath,
platform,
extensionsDevelopmentPath: options.extensionDevelopmentPath,
});
}

reporter.report({ stage: ProgressReportStage.ResolvedVersion, version });

const downloadedPath = path.resolve(cachePath, makeDownloadDirName(platform, version));
if (fs.existsSync(downloadedPath)) {
if (version === 'insiders') {
if (isInsiderVersionIdentifier(version)) {
reporter.report({ stage: ProgressReportStage.FetchingInsidersMetadata });
const { version: currentHash, date: currentDate } = insidersDownloadDirMetadata(downloadedPath, platform);

const { version: latestHash, timestamp: latestTimestamp } = await getLatestInsidersMetadata(
systemDefaultPlatform
);
const { version: latestHash, timestamp: latestTimestamp } =
version === 'insiders'
? await getLatestInsidersMetadata(systemDefaultPlatform)
: await getInsidersVersionMetadata(systemDefaultPlatform, version);

if (currentHash === latestHash) {
reporter.report({ stage: ProgressReportStage.FoundMatchingInstall, downloadedPath });
return Promise.resolve(insidersDownloadDirToExecutablePath(downloadedPath, platform));
Expand Down
Loading