Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/test/sanity/src/context.ts
4772 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { spawnSync, SpawnSyncReturns } from 'child_process';
7
import { createHash } from 'crypto';
8
import fs from 'fs';
9
import fetch, { Response } from 'node-fetch';
10
import os from 'os';
11
import path from 'path';
12
import { Browser, chromium, webkit } from 'playwright';
13
14
/**
15
* Response from https://update.code.visualstudio.com/api/versions/commit:<commit>/<target>/<quality>
16
*/
17
interface ITargetMetadata {
18
url: string;
19
name: string;
20
version: string;
21
productVersion: string;
22
hash: string;
23
timestamp: number;
24
sha256hash: string;
25
supportsFastUpdate: boolean;
26
}
27
28
/**
29
* Provides context and utilities for VS Code sanity tests.
30
*/
31
export class TestContext {
32
private static readonly authenticodeInclude = /^.+\.(exe|dll|sys|cab|cat|msi|jar|ocx|ps1|psm1|psd1|ps1xml|pssc1)$/i;
33
private static readonly codesignExclude = /node_modules\/(@parcel\/watcher\/build\/Release\/watcher\.node|@vscode\/deviceid\/build\/Release\/windows\.node|@vscode\/ripgrep\/bin\/rg|@vscode\/spdlog\/build\/Release\/spdlog.node|kerberos\/build\/Release\/kerberos.node|native-watchdog\/build\/Release\/watchdog\.node|node-pty\/build\/Release\/(pty\.node|spawn-helper)|vsda\/build\/Release\/vsda\.node)$/;
34
35
private readonly tempDirs = new Set<string>();
36
private readonly logFile: string;
37
38
public constructor(
39
public readonly quality: 'stable' | 'insider' | 'exploration',
40
public readonly commit: string,
41
public readonly verbose: boolean,
42
) {
43
const osTempDir = fs.realpathSync(os.tmpdir());
44
const logDir = fs.mkdtempSync(path.join(osTempDir, 'vscode-sanity-log'));
45
this.logFile = path.join(logDir, 'sanity.log');
46
console.log(`Log file: ${this.logFile}`);
47
}
48
49
/**
50
* Returns the current platform in the format <platform>-<arch>.
51
*/
52
public get platform(): string {
53
return `${os.platform()}-${os.arch()}`;
54
}
55
56
/**
57
* Logs a message with a timestamp.
58
*/
59
public log(message: string) {
60
const line = `[${new Date().toISOString()}] ${message}\n`;
61
fs.appendFileSync(this.logFile, line);
62
if (this.verbose) {
63
console.log(line.trimEnd());
64
}
65
}
66
67
/**
68
* Logs an error message and throws an Error.
69
*/
70
public error(message: string): never {
71
const line = `[${new Date().toISOString()}] ERROR: ${message}\n`;
72
fs.appendFileSync(this.logFile, line);
73
console.error(line.trimEnd());
74
throw new Error(message);
75
}
76
77
/**
78
* Creates a new temporary directory and returns its path.
79
*/
80
public createTempDir(): string {
81
const osTempDir = fs.realpathSync(os.tmpdir());
82
const tempDir = fs.mkdtempSync(path.join(osTempDir, 'vscode-sanity'));
83
this.log(`Created temp directory: ${tempDir}`);
84
this.tempDirs.add(tempDir);
85
return tempDir;
86
}
87
88
/**
89
* Ensures that the directory for the specified file path exists.
90
*/
91
public ensureDirExists(filePath: string) {
92
const dir = path.dirname(filePath);
93
if (!fs.existsSync(dir)) {
94
fs.mkdirSync(dir, { recursive: true });
95
}
96
}
97
98
/**
99
* Cleans up all temporary directories created during the test run.
100
*/
101
public cleanup() {
102
for (const dir of this.tempDirs) {
103
this.log(`Deleting temp directory: ${dir}`);
104
try {
105
fs.rmSync(dir, { recursive: true, force: true });
106
this.log(`Deleted temp directory: ${dir}`);
107
} catch (error) {
108
this.log(`Failed to delete temp directory: ${dir}: ${error}`);
109
}
110
}
111
this.tempDirs.clear();
112
}
113
114
/**
115
* Fetches a URL and ensures there are no errors.
116
* @param url The URL to fetch.
117
* @returns The fetch Response object.
118
*/
119
public async fetchNoErrors(url: string): Promise<Response & { body: NodeJS.ReadableStream }> {
120
const maxRetries = 5;
121
let lastError: Error | undefined;
122
123
for (let attempt = 0; attempt < maxRetries; attempt++) {
124
if (attempt > 0) {
125
const delay = Math.pow(2, attempt - 1) * 1000;
126
this.log(`Retrying fetch (attempt ${attempt + 1}/${maxRetries}) after ${delay}ms`);
127
await new Promise(resolve => setTimeout(resolve, delay));
128
}
129
130
try {
131
const response = await fetch(url);
132
if (!response.ok) {
133
lastError = new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
134
continue;
135
}
136
137
if (response.body === null) {
138
lastError = new Error(`Response body is null for ${url}`);
139
continue;
140
}
141
142
return response as Response & { body: NodeJS.ReadableStream };
143
} catch (error) {
144
lastError = error instanceof Error ? error : new Error(String(error));
145
this.log(`Fetch attempt ${attempt + 1} failed: ${lastError.message}`);
146
}
147
}
148
149
this.error(`Failed to fetch ${url} after ${maxRetries} attempts: ${lastError?.message}`);
150
}
151
152
/**
153
* Fetches metadata for a specific VS Code release target.
154
* @param target The target platform (e.g., 'cli-linux-x64').
155
* @returns The target metadata.
156
*/
157
public async fetchMetadata(target: string): Promise<ITargetMetadata> {
158
const url = `https://update.code.visualstudio.com/api/versions/commit:${this.commit}/${target}/${this.quality}`;
159
160
this.log(`Fetching metadata for ${target} from ${url}`);
161
const response = await this.fetchNoErrors(url);
162
163
const result = await response.json() as ITargetMetadata;
164
if (result.url === undefined || result.sha256hash === undefined) {
165
this.error(`Invalid metadata response for ${target}: ${JSON.stringify(result)}`);
166
}
167
168
this.log(`Fetched metadata for ${target}: ${JSON.stringify(result)}`);
169
return result;
170
}
171
172
/**
173
* Downloads installer for specified VS Code release target.
174
* @param target The target platform (e.g., 'cli-linux-x64').
175
* @returns The path to the downloaded file.
176
*/
177
public async downloadTarget(target: string): Promise<string> {
178
const { url, sha256hash } = await this.fetchMetadata(target);
179
const filePath = path.join(this.createTempDir(), path.basename(url));
180
181
this.log(`Downloading ${url} to ${filePath}`);
182
const { body } = await this.fetchNoErrors(url);
183
184
const stream = fs.createWriteStream(filePath);
185
await new Promise<void>((resolve, reject) => {
186
body.on('error', reject);
187
stream.on('error', reject);
188
stream.on('finish', resolve);
189
body.pipe(stream);
190
});
191
192
this.log(`Downloaded ${url} to ${filePath}`);
193
this.validateSha256Hash(filePath, sha256hash);
194
195
if (TestContext.authenticodeInclude.test(filePath) && os.platform() === 'win32') {
196
this.validateAuthenticodeSignature(filePath);
197
}
198
199
return filePath;
200
}
201
202
/**
203
* Validates the SHA-256 hash of a file.
204
* @param filePath The path to the file to validate.
205
* @param expectedHash The expected SHA-256 hash in hexadecimal format.
206
*/
207
public validateSha256Hash(filePath: string, expectedHash: string) {
208
this.log(`Validating SHA256 hash for ${filePath}`);
209
210
const buffer = fs.readFileSync(filePath);
211
const hash = createHash('sha256').update(buffer).digest('hex');
212
213
if (hash !== expectedHash) {
214
this.error(`Hash mismatch for ${filePath}: expected ${expectedHash}, got ${hash}`);
215
}
216
}
217
218
/**
219
* Validates the Authenticode signature of a Windows executable.
220
* @param filePath The path to the file to validate.
221
*/
222
public validateAuthenticodeSignature(filePath: string) {
223
this.log(`Validating Authenticode signature for ${filePath}`);
224
225
const result = this.run('powershell', '-Command', `Get-AuthenticodeSignature "${filePath}" | Select-Object -ExpandProperty Status`);
226
if (result.error !== undefined) {
227
this.error(`Failed to run Get-AuthenticodeSignature: ${result.error.message}`);
228
}
229
230
const status = result.stdout.trim();
231
if (status !== 'Valid') {
232
this.error(`Authenticode signature is not valid for ${filePath}: ${status}`);
233
}
234
}
235
236
/**
237
* Validates Authenticode signatures for all executable files in the specified directory.
238
* @param dir The directory to scan for executable files.
239
*/
240
public validateAllAuthenticodeSignatures(dir: string) {
241
const files = fs.readdirSync(dir, { withFileTypes: true });
242
for (const file of files) {
243
const filePath = path.join(dir, file.name);
244
if (file.isDirectory()) {
245
this.validateAllAuthenticodeSignatures(filePath);
246
} else if (TestContext.authenticodeInclude.test(file.name)) {
247
this.validateAuthenticodeSignature(filePath);
248
}
249
}
250
}
251
252
/**
253
* Validates the codesign signature of a macOS binary or app bundle.
254
* @param filePath The path to the file or app bundle to validate.
255
*/
256
public validateCodesignSignature(filePath: string) {
257
this.log(`Validating codesign signature for ${filePath}`);
258
259
const result = this.run('codesign', '--verify', '--deep', '--strict', filePath);
260
if (result.error !== undefined) {
261
this.error(`Failed to run codesign: ${result.error.message}`);
262
}
263
264
if (result.status !== 0) {
265
this.error(`Codesign signature is not valid for ${filePath}: ${result.stderr}`);
266
}
267
}
268
269
/**
270
* Validates codesign signatures for all Mach-O binaries in the specified directory.
271
* @param dir The directory to scan for Mach-O binaries.
272
*/
273
public validateAllCodesignSignatures(dir: string) {
274
const files = fs.readdirSync(dir, { withFileTypes: true });
275
for (const file of files) {
276
const filePath = path.join(dir, file.name);
277
if (TestContext.codesignExclude.test(filePath)) {
278
this.log(`Skipping codesign validation for excluded file: ${filePath}`);
279
} else if (file.isDirectory()) {
280
// For .app bundles, validate the bundle itself, not its contents
281
if (file.name.endsWith('.app') || file.name.endsWith('.framework')) {
282
this.validateCodesignSignature(filePath);
283
} else {
284
this.validateAllCodesignSignatures(filePath);
285
}
286
} else if (this.isMachOBinary(filePath)) {
287
this.validateCodesignSignature(filePath);
288
}
289
}
290
}
291
292
/**
293
* Checks if a file is a Mach-O binary by examining its magic number.
294
* @param filePath The path to the file to check.
295
* @returns True if the file is a Mach-O binary.
296
*/
297
private isMachOBinary(filePath: string): boolean {
298
try {
299
const fd = fs.openSync(filePath, 'r');
300
const buffer = Buffer.alloc(4);
301
fs.readSync(fd, buffer, 0, 4, 0);
302
fs.closeSync(fd);
303
304
// Mach-O magic numbers:
305
// MH_MAGIC: 0xFEEDFACE (32-bit)
306
// MH_CIGAM: 0xCEFAEDFE (32-bit, byte-swapped)
307
// MH_MAGIC_64: 0xFEEDFACF (64-bit)
308
// MH_CIGAM_64: 0xCFFAEDFE (64-bit, byte-swapped)
309
// FAT_MAGIC: 0xCAFEBABE (universal binary)
310
// FAT_CIGAM: 0xBEBAFECA (universal binary, byte-swapped)
311
const magic = buffer.readUInt32BE(0);
312
return magic === 0xFEEDFACE || magic === 0xCEFAEDFE ||
313
magic === 0xFEEDFACF || magic === 0xCFFAEDFE ||
314
magic === 0xCAFEBABE || magic === 0xBEBAFECA;
315
} catch {
316
return false;
317
}
318
}
319
320
/**
321
* Downloads and unpacks the specified VS Code release target.
322
* @param target The target platform (e.g., 'cli-linux-x64').
323
* @returns The path to the unpacked directory.
324
*/
325
public async downloadAndUnpack(target: string): Promise<string> {
326
const filePath = await this.downloadTarget(target);
327
return this.unpackArchive(filePath);
328
}
329
330
/**
331
* Unpacks a .zip or .tar.gz archive to a temporary directory.
332
* @param archivePath The path to the archive file.
333
* @returns The path to the temporary directory where the archive was unpacked.
334
*/
335
public unpackArchive(archivePath: string): string {
336
const dir = this.createTempDir();
337
338
this.log(`Unpacking ${archivePath} to ${dir}`);
339
this.runNoErrors('tar', '-xzf', archivePath, '-C', dir);
340
this.log(`Unpacked ${archivePath} to ${dir}`);
341
342
return dir;
343
}
344
345
/**
346
* Runs a command synchronously.
347
* @param command The command to run.
348
* @param args Optional arguments for the command.
349
* @returns The result of the spawnSync call.
350
*/
351
public run(command: string, ...args: string[]): SpawnSyncReturns<string> {
352
this.log(`Running command: ${command} ${args.join(' ')}`);
353
return spawnSync(command, args, { encoding: 'utf-8' }) as SpawnSyncReturns<string>;
354
}
355
356
/**
357
* Runs a command synchronously and ensures it succeeds.
358
* @param command The command to run.
359
* @param args Optional arguments for the command.
360
* @returns The result of the spawnSync call.
361
*/
362
public runNoErrors(command: string, ...args: string[]): SpawnSyncReturns<string> {
363
const result = this.run(command, ...args);
364
if (result.error !== undefined) {
365
this.error(`Failed to run command: ${result.error.message}`);
366
}
367
368
if (result.status !== 0) {
369
this.error(`Command exited with code ${result.status}: ${result.stderr}`);
370
}
371
372
return result;
373
}
374
375
/**
376
* Kills a process and all its child processes.
377
* @param pid The process ID to kill.
378
*/
379
public killProcessTree(pid: number): void {
380
this.log(`Killing process tree for PID: ${pid}`);
381
if (os.platform() === 'win32') {
382
spawnSync('taskkill', ['/T', '/F', '/PID', pid.toString()]);
383
} else {
384
process.kill(-pid, 'SIGKILL');
385
}
386
this.log(`Killed process tree for PID: ${pid}`);
387
}
388
389
/**
390
* Returns the Windows installation directory for VS Code based on the installation type and quality.
391
* @param type The type of installation ('user' or 'system').
392
* @returns The path to the VS Code installation directory.
393
*/
394
private getWindowsInstallDir(type: 'user' | 'system'): string {
395
let parentDir: string;
396
if (type === 'system') {
397
parentDir = process.env['PROGRAMFILES'] || '';
398
} else {
399
parentDir = path.join(process.env['LOCALAPPDATA'] || '', 'Programs');
400
}
401
402
switch (this.quality) {
403
case 'stable':
404
return path.join(parentDir, 'Microsoft VS Code');
405
case 'insider':
406
return path.join(parentDir, 'Microsoft VS Code Insiders');
407
case 'exploration':
408
return path.join(parentDir, 'Microsoft VS Code Exploration');
409
}
410
}
411
412
/**
413
* Installs a Microsoft Installer package silently.
414
* @param installerPath The path to the installer executable.
415
* @returns The path to the installed VS Code executable.
416
*/
417
public installWindowsApp(type: 'user' | 'system', installerPath: string): string {
418
this.log(`Installing ${installerPath} in silent mode`);
419
this.runNoErrors(installerPath, '/silent', '/mergetasks=!runcode');
420
this.log(`Installed ${installerPath} successfully`);
421
422
const appDir = this.getWindowsInstallDir(type);
423
let entryPoint: string;
424
switch (this.quality) {
425
case 'stable':
426
entryPoint = path.join(appDir, 'Code.exe');
427
break;
428
case 'insider':
429
entryPoint = path.join(appDir, 'Code - Insiders.exe');
430
break;
431
case 'exploration':
432
entryPoint = path.join(appDir, 'Code - Exploration.exe');
433
break;
434
}
435
436
if (!fs.existsSync(entryPoint)) {
437
this.error(`Desktop entry point does not exist: ${entryPoint}`);
438
}
439
440
this.log(`Installed VS Code executable at: ${entryPoint}`);
441
return entryPoint;
442
}
443
444
/**
445
* Uninstalls a Windows application silently.
446
* @param type The type of installation ('user' or 'system').
447
*/
448
public async uninstallWindowsApp(type: 'user' | 'system'): Promise<void> {
449
const appDir = this.getWindowsInstallDir(type);
450
const uninstallerPath = path.join(appDir, 'unins000.exe');
451
if (!fs.existsSync(uninstallerPath)) {
452
this.error(`Uninstaller does not exist: ${uninstallerPath}`);
453
}
454
455
this.log(`Uninstalling VS Code from ${appDir} in silent mode`);
456
this.runNoErrors(uninstallerPath, '/silent');
457
this.log(`Uninstalled VS Code from ${appDir} successfully`);
458
459
await new Promise(resolve => setTimeout(resolve, 2000));
460
if (fs.existsSync(appDir)) {
461
this.error(`Installation directory still exists after uninstall: ${appDir}`);
462
}
463
}
464
465
/**
466
* Prepares a macOS .app bundle for execution by removing the quarantine attribute.
467
* @param bundleDir The directory containing the .app bundle.
468
* @returns The path to the VS Code Electron executable.
469
*/
470
public installMacApp(bundleDir: string): string {
471
let appName: string;
472
switch (this.quality) {
473
case 'stable':
474
appName = 'Visual Studio Code.app';
475
break;
476
case 'insider':
477
appName = 'Visual Studio Code - Insiders.app';
478
break;
479
case 'exploration':
480
appName = 'Visual Studio Code - Exploration.app';
481
break;
482
}
483
484
const entryPoint = path.join(bundleDir, appName, 'Contents/MacOS/Electron');
485
if (!fs.existsSync(entryPoint)) {
486
this.error(`Desktop entry point does not exist: ${entryPoint}`);
487
}
488
489
this.log(`VS Code executable at: ${entryPoint}`);
490
return entryPoint;
491
}
492
493
/**
494
* Installs a Linux RPM package.
495
* @param packagePath The path to the RPM file.
496
* @returns The path to the installed VS Code executable.
497
*/
498
public installRpm(packagePath: string): string {
499
this.log(`Installing ${packagePath} using RPM package manager`);
500
this.runNoErrors('sudo', 'rpm', '-i', packagePath);
501
this.log(`Installed ${packagePath} successfully`);
502
503
const entryPoint = this.getEntryPoint('desktop', '/usr/bin');
504
this.log(`Installed VS Code executable at: ${entryPoint}`);
505
return entryPoint;
506
}
507
508
/**
509
* Installs a Linux DEB package.
510
* @param packagePath The path to the DEB file.
511
* @returns The path to the installed VS Code executable.
512
*/
513
public installDeb(packagePath: string): string {
514
this.log(`Installing ${packagePath} using DEB package manager`);
515
this.runNoErrors('sudo', 'dpkg', '-i', packagePath);
516
this.log(`Installed ${packagePath} successfully`);
517
518
const entryPoint = this.getEntryPoint('desktop', '/usr/bin');
519
this.log(`Installed VS Code executable at: ${entryPoint}`);
520
return entryPoint;
521
}
522
523
/**
524
* Installs a Linux Snap package.
525
* @param packagePath The path to the Snap file.
526
* @returns The path to the installed VS Code executable.
527
*/
528
public installSnap(packagePath: string): string {
529
this.log(`Installing ${packagePath} using Snap package manager`);
530
this.runNoErrors('sudo', 'snap', 'install', packagePath, '--classic', '--dangerous');
531
this.log(`Installed ${packagePath} successfully`);
532
533
const entryPoint = this.getEntryPoint('desktop', '/snap/bin');
534
this.log(`Installed VS Code executable at: ${entryPoint}`);
535
return entryPoint;
536
}
537
538
/**
539
* Returns the entry point executable for the VS Code CLI or Desktop installation in the specified directory.
540
* @param dir The directory of the VS Code installation.
541
* @returns The path to the entry point executable.
542
*/
543
public getEntryPoint(type: 'cli' | 'desktop', dir: string): string {
544
let suffix: string;
545
switch (this.quality) {
546
case 'stable':
547
suffix = type === 'cli' ? '' : '';
548
break;
549
case 'insider':
550
suffix = type === 'cli' ? '-insiders' : ' - Insiders';
551
break;
552
case 'exploration':
553
suffix = type === 'cli' ? '-exploration' : ' - Exploration';
554
break;
555
}
556
557
const extension = os.platform() === 'win32' ? '.exe' : '';
558
const filePath = path.join(dir, `code${suffix}${extension}`);
559
if (!fs.existsSync(filePath)) {
560
this.error(`CLI entry point does not exist: ${filePath}`);
561
}
562
563
return filePath;
564
}
565
566
/**
567
* Returns the entry point executable for the VS Code server in the specified directory.
568
* @param dir The directory containing unpacked server files.
569
* @returns The path to the server entry point executable.
570
*/
571
public getServerEntryPoint(dir: string): string {
572
const serverDir = fs.readdirSync(dir, { withFileTypes: true }).filter(o => o.isDirectory()).at(0)?.name;
573
if (!serverDir) {
574
this.error(`No subdirectories found in server directory: ${dir}`);
575
}
576
577
let filename: string;
578
switch (this.quality) {
579
case 'stable':
580
filename = 'code-server';
581
break;
582
case 'insider':
583
filename = 'code-server-insiders';
584
break;
585
case 'exploration':
586
filename = 'code-server-exploration';
587
break;
588
}
589
590
if (os.platform() === 'win32') {
591
filename += '.cmd';
592
}
593
594
const entryPoint = path.join(dir, serverDir, 'bin', filename);
595
if (!fs.existsSync(entryPoint)) {
596
this.error(`Server entry point does not exist: ${entryPoint}`);
597
}
598
599
return entryPoint;
600
}
601
602
/**
603
* Returns the tunnel URL for the VS Code server including vscode-version parameter.
604
* @param baseUrl The base URL for the VS Code server.
605
* @returns The tunnel URL with vscode-version parameter.
606
*/
607
public getTunnelUrl(baseUrl: string): string {
608
const url = new URL(baseUrl);
609
url.searchParams.set('vscode-version', this.commit);
610
return url.toString();
611
}
612
613
/**
614
* Launches a web browser for UI testing.
615
* @returns The launched Browser instance.
616
*/
617
public async launchBrowser(): Promise<Browser> {
618
this.log(`Launching web browser`);
619
switch (os.platform()) {
620
case 'darwin':
621
return await webkit.launch({ headless: false });
622
case 'win32':
623
return await chromium.launch({ channel: 'msedge', headless: false });
624
default:
625
return await chromium.launch({ channel: 'chrome', headless: false });
626
}
627
}
628
629
/**
630
* Constructs a web server URL with optional token and folder parameters.
631
* @param port The port number of the web server.
632
* @param token The optional authentication token.
633
* @param folder The optional workspace folder path to open.
634
* @returns The constructed web server URL.
635
*/
636
public getWebServerUrl(port: string, token?: string, folder?: string): URL {
637
const url = new URL(`http://localhost:${port}`);
638
if (token) {
639
url.searchParams.set('tkn', token);
640
}
641
if (folder) {
642
folder = folder.replaceAll('\\', '/');
643
if (!folder.startsWith('/')) {
644
folder = `/${folder}`;
645
}
646
url.searchParams.set('folder', folder);
647
}
648
return url;
649
}
650
651
/**
652
* Returns a random alphanumeric token of length 10.
653
*/
654
public getRandomToken(): string {
655
return Array.from({ length: 10 }, () => Math.floor(Math.random() * 36).toString(36)).join('');
656
}
657
658
/**
659
* Returns a random port number between 3000 and 9999.
660
*/
661
public getRandomPort(): string {
662
return String(Math.floor(Math.random() * 7000) + 3000);
663
}
664
}
665
666