Skip to content

Commit a8c51de

Browse files
authored
Merge pull request #202 from microsoft/connor4312/issue176
feat: automatically use vscode version matching engine
2 parents a08ae56 + 121fe1f commit a8c51de

File tree

5 files changed

+256
-34
lines changed

5 files changed

+256
-34
lines changed

lib/download.test.ts

Lines changed: 95 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,32 @@
11
import { spawnSync } from 'child_process';
22
import { existsSync, promises as fs } from 'fs';
33
import { tmpdir } from 'os';
4-
import { join } from 'path';
5-
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
6-
import { downloadAndUnzipVSCode } from './download';
4+
import { dirname, join } from 'path';
5+
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest';
6+
import {
7+
downloadAndUnzipVSCode,
8+
fetchInsiderVersions,
9+
fetchStableVersions,
10+
fetchTargetInferredVersion,
11+
} from './download';
712
import { SilentReporter } from './progress';
813
import { resolveCliPathFromVSCodeExecutablePath, systemDefaultPlatform } from './util';
914

10-
const platforms = ['darwin', 'darwin-arm64', 'win32-archive', 'win32-x64-archive', 'linux-x64', 'linux-arm64', 'linux-armhf'];
15+
const platforms = [
16+
'darwin',
17+
'darwin-arm64',
18+
'win32-archive',
19+
'win32-x64-archive',
20+
'linux-x64',
21+
'linux-arm64',
22+
'linux-armhf',
23+
];
1124

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

1528
beforeAll(async () => {
16-
await fs.mkdir(testTempDir, { recursive: true })
29+
await fs.mkdir(testTempDir, { recursive: true });
1730
});
1831

1932
for (const platform of platforms) {
@@ -39,7 +52,7 @@ describe('sane downloads', () => {
3952
expect(version.status).to.equal(0);
4053
expect(version.stdout.toString().trim()).to.not.be.empty;
4154
}
42-
})
55+
});
4356
}
4457

4558
afterAll(async () => {
@@ -50,3 +63,79 @@ describe('sane downloads', () => {
5063
}
5164
});
5265
});
66+
67+
describe('fetchTargetInferredVersion', () => {
68+
let stable: string[];
69+
let insiders: string[];
70+
let extensionsDevelopmentPath = join(tmpdir(), 'vscode-test-tmp-workspace');
71+
72+
beforeAll(async () => {
73+
[stable, insiders] = await Promise.all([fetchStableVersions(5000), fetchInsiderVersions(5000)]);
74+
});
75+
76+
afterEach(async () => {
77+
await fs.rm(extensionsDevelopmentPath, { recursive: true, force: true });
78+
});
79+
80+
const writeJSON = async (path: string, contents: object) => {
81+
const target = join(extensionsDevelopmentPath, path);
82+
await fs.mkdir(dirname(target), { recursive: true });
83+
await fs.writeFile(target, JSON.stringify(contents));
84+
};
85+
86+
const doFetch = (paths = ['./']) =>
87+
fetchTargetInferredVersion({
88+
cachePath: join(extensionsDevelopmentPath, '.cache'),
89+
platform: 'win32-archive',
90+
timeout: 5000,
91+
extensionsDevelopmentPath: paths.map(p => join(extensionsDevelopmentPath, p)),
92+
});
93+
94+
test('matches stable if no workspace', async () => {
95+
const version = await doFetch();
96+
expect(version).to.equal(stable[0]);
97+
});
98+
99+
test('matches stable by default', async () => {
100+
await writeJSON('package.json', {});
101+
const version = await doFetch();
102+
expect(version).to.equal(stable[0]);
103+
});
104+
105+
test('matches if stable is defined', async () => {
106+
await writeJSON('package.json', { engines: { vscode: '^1.50.0' } });
107+
const version = await doFetch();
108+
expect(version).to.equal(stable[0]);
109+
});
110+
111+
test('matches best', async () => {
112+
await writeJSON('package.json', { engines: { vscode: '<=1.60.5' } });
113+
const version = await doFetch();
114+
expect(version).to.equal('1.60.2');
115+
});
116+
117+
test('matches multiple workspaces', async () => {
118+
await writeJSON('a/package.json', { engines: { vscode: '<=1.60.5' } });
119+
await writeJSON('b/package.json', { engines: { vscode: '<=1.55.5' } });
120+
const version = await doFetch(['a', 'b']);
121+
expect(version).to.equal('1.55.2');
122+
});
123+
124+
test('matches insiders to better stable if there is one', async () => {
125+
await writeJSON('package.json', { engines: { vscode: '^1.60.0-insider' } });
126+
const version = await doFetch();
127+
expect(version).to.equal(stable[0]);
128+
});
129+
130+
test('matches current insiders', async () => {
131+
await writeJSON('package.json', { engines: { vscode: `^${insiders[0]}` } });
132+
const version = await doFetch();
133+
expect(version).to.equal(insiders[0]);
134+
});
135+
136+
test('matches insiders to exact', async () => {
137+
await writeJSON('package.json', { engines: { vscode: '1.60.0-insider' } });
138+
const version = await doFetch();
139+
expect(version).to.equal('1.60.0-insider');
140+
});
141+
});

lib/download.ts

Lines changed: 116 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,19 @@ import { promisify } from 'util';
1212
import * as del from './del';
1313
import { ConsoleReporter, ProgressReporter, ProgressReportStage } from './progress';
1414
import * as request from './request';
15+
import * as semver from 'semver';
1516
import {
1617
downloadDirToExecutablePath,
18+
getInsidersVersionMetadata,
1719
getLatestInsidersMetadata,
1820
getVSCodeDownloadUrl,
1921
insidersDownloadDirMetadata,
2022
insidersDownloadDirToExecutablePath,
2123
isDefined,
24+
isInsiderVersionIdentifier,
2225
isStableVersionIdentifier,
2326
isSubdirectory,
27+
onceWithoutRejections,
2428
streamToBuffer,
2529
systemDefaultPlatform,
2630
} from './util';
@@ -29,6 +33,7 @@ const extensionRoot = process.cwd();
2933
const pipelineAsync = promisify(pipeline);
3034

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

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

3843
const DOWNLOAD_ATTEMPTS = 3;
3944

45+
interface IFetchStableOptions {
46+
timeout: number;
47+
cachePath: string;
48+
platform: string;
49+
}
50+
51+
interface IFetchInferredOptions extends IFetchStableOptions {
52+
extensionsDevelopmentPath?: string | string[];
53+
}
54+
55+
export const fetchStableVersions = onceWithoutRejections((timeout: number) =>
56+
request.getJSON<string[]>(vscodeStableReleasesAPI, timeout)
57+
);
58+
export const fetchInsiderVersions = onceWithoutRejections((timeout: number) =>
59+
request.getJSON<string[]>(vscodeInsiderReleasesAPI, timeout)
60+
);
61+
4062
/**
4163
* Returns the stable version to run tests against. Attempts to get the latest
4264
* version from the update sverice, but falls back to local installs if
4365
* not available (e.g. if the machine is offline).
4466
*/
45-
async function fetchTargetStableVersion(timeout: number, cachePath: string, platform: string): Promise<string> {
46-
let versions: string[] = [];
67+
async function fetchTargetStableVersion({ timeout, cachePath, platform }: IFetchStableOptions): Promise<string> {
4768
try {
48-
versions = await request.getJSON<string[]>(vscodeStableReleasesAPI, timeout);
69+
const versions = await fetchStableVersions(timeout);
70+
return versions[0];
4971
} catch (e) {
50-
const entries = await fs.promises.readdir(cachePath).catch(() => [] as string[]);
51-
const [fallbackTo] = entries
52-
.map((e) => downloadDirNameFormat.exec(e))
53-
.filter(isDefined)
54-
.filter((e) => e.groups!.platform === platform)
55-
.map((e) => e.groups!.version)
56-
.sort((a, b) => Number(b) - Number(a));
57-
58-
if (fallbackTo) {
59-
console.warn(`Error retrieving VS Code versions, using already-installed version ${fallbackTo}`, e);
60-
return fallbackTo;
72+
return fallbackToLocalEntries(cachePath, platform, e as Error);
73+
}
74+
}
75+
76+
export async function fetchTargetInferredVersion(options: IFetchInferredOptions) {
77+
if (!options.extensionsDevelopmentPath) {
78+
return fetchTargetStableVersion(options);
79+
}
80+
81+
// load all engines versions from all development paths. Then, get the latest
82+
// stable version (or, latest Insiders version) that satisfies all
83+
// `engines.vscode` constraints.
84+
const extPaths = Array.isArray(options.extensionsDevelopmentPath)
85+
? options.extensionsDevelopmentPath
86+
: [options.extensionsDevelopmentPath];
87+
const maybeExtVersions = await Promise.all(extPaths.map(getEngineVersionFromExtension));
88+
const extVersions = maybeExtVersions.filter(isDefined);
89+
const matches = (v: string) => !extVersions.some((range) => !semver.satisfies(v, range, { includePrerelease: true }));
90+
91+
try {
92+
const stable = await fetchStableVersions(options.timeout);
93+
const found1 = stable.find(matches);
94+
if (found1) {
95+
return found1;
6196
}
6297

63-
throw e;
98+
const insiders = await fetchInsiderVersions(options.timeout);
99+
const found2 = insiders.find(matches);
100+
if (found2) {
101+
return found2;
102+
}
103+
104+
console.warn(`No version of VS Code satisfies all extension engine constraints (${extVersions.join(', ')}). Falling back to stable.`);
105+
106+
return stable[0]; // 🤷
107+
} catch (e) {
108+
return fallbackToLocalEntries(options.cachePath, options.platform, e as Error);
109+
}
110+
}
111+
112+
async function getEngineVersionFromExtension(extensionPath: string): Promise<string | undefined> {
113+
try {
114+
const packageContents = await fs.promises.readFile(path.join(extensionPath, 'package.json'), 'utf8');
115+
const packageJson = JSON.parse(packageContents);
116+
return packageJson?.engines?.vscode;
117+
} catch {
118+
return undefined;
119+
}
120+
}
121+
122+
async function fallbackToLocalEntries(cachePath: string, platform: string, fromError: Error) {
123+
const entries = await fs.promises.readdir(cachePath).catch(() => [] as string[]);
124+
const [fallbackTo] = entries
125+
.map((e) => downloadDirNameFormat.exec(e))
126+
.filter(isDefined)
127+
.filter((e) => e.groups!.platform === platform)
128+
.map((e) => e.groups!.version)
129+
.sort((a, b) => Number(b) - Number(a));
130+
131+
if (fallbackTo) {
132+
console.warn(`Error retrieving VS Code versions, using already-installed version ${fallbackTo}`, fromError);
133+
return fallbackTo;
64134
}
65135

66-
return versions[0];
136+
throw fromError;
67137
}
68138

69139
async function isValidVersion(version: string, platform: string, timeout: number) {
70-
if (version === 'insiders') {
140+
if (version === 'insiders' || version === 'stable') {
71141
return true;
72142
}
73143

74-
const stableVersionNumbers: string[] = await request.getJSON(vscodeStableReleasesAPI, timeout);
75-
if (stableVersionNumbers.includes(version)) {
76-
return true;
144+
if (isStableVersionIdentifier(version)) {
145+
const stableVersionNumbers = await fetchStableVersions(timeout);
146+
if (stableVersionNumbers.includes(version)) {
147+
return true;
148+
}
149+
}
150+
151+
if (isInsiderVersionIdentifier(version)) {
152+
const insiderVersionNumbers = await fetchInsiderVersions(timeout);
153+
if (insiderVersionNumbers.includes(version)) {
154+
return true;
155+
}
77156
}
78157

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

117197
const timeout = options.timeout!;
118198
const downloadUrl = getVSCodeDownloadUrl(options.version, options.platform);
199+
119200
options.reporter?.report({ stage: ProgressReportStage.ResolvingCDNLocation, url: downloadUrl });
120201
const res = await request.getStream(downloadUrl, timeout);
121202
if (res.statusCode !== 302) {
@@ -248,7 +329,9 @@ export async function download(options: Partial<DownloadOptions> = {}): Promise<
248329
timeout = 15_000,
249330
} = options;
250331

251-
if (version && version !== 'stable') {
332+
if (version === 'stable') {
333+
version = await fetchTargetStableVersion({ timeout, cachePath, platform });
334+
} else if (version) {
252335
/**
253336
* Only validate version against server when no local download that matches version exists
254337
*/
@@ -258,20 +341,27 @@ export async function download(options: Partial<DownloadOptions> = {}): Promise<
258341
}
259342
}
260343
} else {
261-
version = await fetchTargetStableVersion(timeout, cachePath, platform);
344+
version = await fetchTargetInferredVersion({
345+
timeout,
346+
cachePath,
347+
platform,
348+
extensionsDevelopmentPath: options.extensionDevelopmentPath,
349+
});
262350
}
263351

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

266354
const downloadedPath = path.resolve(cachePath, makeDownloadDirName(platform, version));
267355
if (fs.existsSync(downloadedPath)) {
268-
if (version === 'insiders') {
356+
if (isInsiderVersionIdentifier(version)) {
269357
reporter.report({ stage: ProgressReportStage.FetchingInsidersMetadata });
270358
const { version: currentHash, date: currentDate } = insidersDownloadDirMetadata(downloadedPath, platform);
271359

272-
const { version: latestHash, timestamp: latestTimestamp } = await getLatestInsidersMetadata(
273-
systemDefaultPlatform
274-
);
360+
const { version: latestHash, timestamp: latestTimestamp } =
361+
version === 'insiders'
362+
? await getLatestInsidersMetadata(systemDefaultPlatform)
363+
: await getInsidersVersionMetadata(systemDefaultPlatform, version);
364+
275365
if (currentHash === latestHash) {
276366
reporter.report({ stage: ProgressReportStage.FoundMatchingInstall, downloadedPath });
277367
return Promise.resolve(insidersDownloadDirToExecutablePath(downloadedPath, platform));

0 commit comments

Comments
 (0)