Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/test/sanity/src/context.ts
5237 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 { spawn, spawnSync, SpawnSyncReturns } from 'child_process';
7
import { createHash } from 'crypto';
8
import fs from 'fs';
9
import { test } from 'mocha';
10
import fetch, { Response } from 'node-fetch';
11
import os from 'os';
12
import path from 'path';
13
import { Browser, chromium, Page, webkit } from 'playwright';
14
import { Capability, detectCapabilities } from './detectors.js';
15
16
/**
17
* Response from https://update.code.visualstudio.com/api/versions/commit:<commit>/<target>/<quality>
18
*/
19
interface ITargetMetadata {
20
url: string;
21
name: string;
22
version: string;
23
productVersion: string;
24
hash: string;
25
timestamp: number;
26
sha256hash: string;
27
supportsFastUpdate: boolean;
28
}
29
30
/**
31
* Provides context and utilities for VS Code sanity tests.
32
*/
33
export class TestContext {
34
private static readonly authenticodeInclude = /^.+\.(exe|dll|sys|cab|cat|msi|jar|ocx|ps1|psm1|psd1|ps1xml|pssc1)$/i;
35
36
private readonly tempDirs = new Set<string>();
37
private readonly wslTempDirs = new Set<string>();
38
private nextPort = 3010;
39
40
public constructor(public readonly options: Readonly<{
41
quality: 'stable' | 'insider' | 'exploration';
42
commit: string;
43
verbose: boolean;
44
cleanup: boolean;
45
checkSigning: boolean;
46
headlessBrowser: boolean;
47
downloadOnly: boolean;
48
}>) {
49
}
50
51
/**
52
* Returns true if the current process is running as root.
53
*/
54
public readonly isRootUser = process.getuid?.() === 0;
55
56
/**
57
* Returns the detected capabilities of the current system.
58
*/
59
public readonly capabilities = detectCapabilities();
60
61
/**
62
* Returns the OS temp directory with expanded long names on Windows.
63
*/
64
public readonly osTempDir = (function () {
65
let tempDir = fs.realpathSync(os.tmpdir());
66
67
// On Windows, expand short 8.3 file names to long names
68
if (os.platform() === 'win32') {
69
const result = spawnSync('powershell', ['-Command', `(Get-Item "${tempDir}").FullName`], { encoding: 'utf-8' });
70
if (result.status === 0 && result.stdout) {
71
tempDir = result.stdout.trim();
72
}
73
}
74
75
return tempDir;
76
})();
77
78
/**
79
* Runs a test only if the required capabilities are present.
80
* @param name The name of the test.
81
* @param require The required capabilities for the test.
82
* @param fn The test function.
83
* @returns The Mocha test object or void if the test is skipped.
84
*/
85
public test(name: string, require: Capability[], fn: () => Promise<void>): Mocha.Test | void {
86
if (!this.options.downloadOnly && require.some(o => !this.capabilities.has(o))) {
87
return;
88
}
89
90
const self = this;
91
return test(name, async function () {
92
self.log(`Starting test: ${name}`);
93
94
const homeDir = os.homedir();
95
process.chdir(homeDir);
96
self.log(`Changed working directory to: ${homeDir}`);
97
98
try {
99
await fn();
100
101
} catch (error) {
102
self.log(`Test failed with error: ${error instanceof Error ? error.message : String(error)}`);
103
throw error;
104
105
} finally {
106
process.chdir(homeDir);
107
self.log(`Changed working directory to: ${homeDir}`);
108
109
if (self.options.cleanup) {
110
self.cleanup();
111
}
112
113
self.log(`Finished test: ${name}`);
114
}
115
});
116
}
117
118
/**
119
* The console outputs collected during the current test.
120
*/
121
public consoleOutputs: string[] = [];
122
123
/**
124
* Logs a message with a timestamp.
125
*/
126
public log(message: string) {
127
const line = `[${new Date().toISOString()}] ${message}`;
128
this.consoleOutputs.push(line);
129
if (this.options.verbose) {
130
console.log(line);
131
}
132
}
133
134
/**
135
* Logs an error message and throws an Error.
136
*/
137
public error(message: string): never {
138
const line = `[${new Date().toISOString()}] ERROR: ${message}`;
139
this.consoleOutputs.push(line);
140
console.error(line);
141
throw new Error(message);
142
}
143
144
/**
145
* Creates a new temporary directory and returns its path.
146
*/
147
public createTempDir(): string {
148
const tempDir = fs.mkdtempSync(path.join(this.osTempDir, 'vscode-sanity'));
149
this.log(`Created temp directory: ${tempDir}`);
150
this.tempDirs.add(tempDir);
151
return tempDir;
152
}
153
154
/**
155
* Creates a new temporary directory in WSL and returns its path.
156
*/
157
public createWslTempDir(): string {
158
const tempDir = `/tmp/vscode-sanity-${Date.now()}-${Math.random().toString(36).slice(2)}`;
159
this.log(`Creating WSL temp directory: ${tempDir}`);
160
this.runNoErrors('wsl', 'mkdir', '-p', tempDir);
161
this.wslTempDirs.add(tempDir);
162
return tempDir;
163
}
164
165
/**
166
* Deletes a directory in WSL.
167
* @param dir The WSL directory path to delete.
168
*/
169
public deleteWslDir(dir: string): void {
170
this.log(`Deleting WSL directory: ${dir}`);
171
this.runNoErrors('wsl', 'rm', '-rf', dir);
172
}
173
174
/**
175
* Converts a Windows path to a WSL path.
176
* @param windowsPath The Windows path to convert (e.g., 'C:\Users\test').
177
* @returns The WSL path (e.g., '/mnt/c/Users/test').
178
*/
179
public toWslPath(windowsPath: string): string {
180
return windowsPath
181
.replace(/^([A-Za-z]):/, (_, drive) => `/mnt/${drive.toLowerCase()}`)
182
.replaceAll('\\', '/');
183
}
184
185
/**
186
* Returns the name of the default WSL distribution.
187
* @returns The default WSL distribution name (e.g., 'Ubuntu').
188
*/
189
public getDefaultWslDistro(): string {
190
const result = this.runNoErrors('wsl', '--list', '--quiet');
191
const distro = result.stdout.trim().split('\n')[0].replace(/\0/g, '').trim();
192
if (!distro) {
193
this.error('No WSL distribution found');
194
}
195
this.log(`Default WSL distribution: ${distro}`);
196
return distro;
197
}
198
199
/**
200
* Ensures that the directory for the specified file path exists.
201
*/
202
public ensureDirExists(filePath: string) {
203
const dir = path.dirname(filePath);
204
if (!fs.existsSync(dir)) {
205
fs.mkdirSync(dir, { recursive: true });
206
}
207
}
208
209
/**
210
* Cleans up all temporary directories created during the test run.
211
*/
212
public cleanup() {
213
for (const dir of this.tempDirs) {
214
this.log(`Deleting temp directory: ${dir}`);
215
try {
216
fs.rmSync(dir, { recursive: true, force: true });
217
this.log(`Deleted temp directory: ${dir}`);
218
} catch (error) {
219
this.log(`Failed to delete temp directory: ${dir}: ${error}`);
220
}
221
}
222
this.tempDirs.clear();
223
224
for (const dir of this.wslTempDirs) {
225
try {
226
this.deleteWslDir(dir);
227
} catch (error) {
228
this.log(`Failed to delete WSL temp directory: ${dir}: ${error}`);
229
}
230
}
231
this.wslTempDirs.clear();
232
}
233
234
/**
235
* Fetches a URL and ensures there are no errors.
236
* @param url The URL to fetch.
237
* @returns The fetch Response object.
238
*/
239
public async fetchNoErrors(url: string): Promise<Response & { body: NodeJS.ReadableStream }> {
240
const maxRetries = 5;
241
let lastError: Error | undefined;
242
243
for (let attempt = 0; attempt < maxRetries; attempt++) {
244
if (attempt > 0) {
245
const delay = Math.pow(2, attempt - 1) * 1000;
246
this.log(`Retrying fetch (attempt ${attempt + 1}/${maxRetries}) after ${delay}ms`);
247
await new Promise(resolve => setTimeout(resolve, delay));
248
}
249
250
try {
251
const response = await fetch(url);
252
if (!response.ok) {
253
lastError = new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
254
continue;
255
}
256
257
if (response.body === null) {
258
lastError = new Error(`Response body is null for ${url}`);
259
continue;
260
}
261
262
return response as Response & { body: NodeJS.ReadableStream };
263
} catch (error) {
264
lastError = error instanceof Error ? error : new Error(String(error));
265
this.log(`Fetch attempt ${attempt + 1} failed: ${lastError.message}`);
266
}
267
}
268
269
this.error(`Failed to fetch ${url} after ${maxRetries} attempts: ${lastError?.message}`);
270
}
271
272
/**
273
* Fetches metadata for a specific VS Code release target.
274
* @param target The target platform (e.g., 'cli-linux-x64').
275
* @returns The target metadata.
276
*/
277
public async fetchMetadata(target: string): Promise<ITargetMetadata> {
278
const url = `https://update.code.visualstudio.com/api/versions/commit:${this.options.commit}/${target}/${this.options.quality}`;
279
280
this.log(`Fetching metadata for ${target} from ${url}`);
281
const response = await this.fetchNoErrors(url);
282
283
const result = await response.json() as ITargetMetadata;
284
if (result.url === undefined || result.sha256hash === undefined) {
285
this.error(`Invalid metadata response for ${target}: ${JSON.stringify(result)}`);
286
}
287
288
this.log(`Fetched metadata for ${target}: ${JSON.stringify(result)}`);
289
return result;
290
}
291
292
/**
293
* Downloads installer for specified VS Code release target.
294
* @param target The target platform (e.g., 'cli-linux-x64').
295
* @returns The path to the downloaded file.
296
*/
297
public async downloadTarget(target: string): Promise<string> {
298
const { url, sha256hash } = await this.fetchMetadata(target);
299
const filePath = path.join(this.createTempDir(), path.basename(url));
300
301
this.log(`Downloading ${url} to ${filePath}`);
302
const { body } = await this.fetchNoErrors(url);
303
304
const stream = fs.createWriteStream(filePath);
305
await new Promise<void>((resolve, reject) => {
306
body.on('error', reject);
307
stream.on('error', reject);
308
stream.on('finish', resolve);
309
body.pipe(stream);
310
});
311
312
this.log(`Downloaded ${url} to ${filePath}`);
313
this.validateSha256Hash(filePath, sha256hash);
314
315
return filePath;
316
}
317
318
/**
319
* Validates the SHA-256 hash of a file.
320
* @param filePath The path to the file to validate.
321
* @param expectedHash The expected SHA-256 hash in hexadecimal format.
322
*/
323
public validateSha256Hash(filePath: string, expectedHash: string) {
324
this.log(`Validating SHA256 hash for ${filePath}`);
325
326
const buffer = fs.readFileSync(filePath);
327
const hash = createHash('sha256').update(buffer).digest('hex');
328
329
if (hash !== expectedHash) {
330
this.error(`Hash mismatch for ${filePath}: expected ${expectedHash}, got ${hash}`);
331
}
332
}
333
334
/**
335
* Validates the Authenticode signature of a Windows executable.
336
* @param filePath The path to the file to validate.
337
*/
338
public validateAuthenticodeSignature(filePath: string) {
339
if (!this.options.checkSigning || !this.capabilities.has('windows')) {
340
this.log(`Skipping Authenticode signature validation for ${filePath} (signing checks disabled)`);
341
return;
342
}
343
344
this.log(`Validating Authenticode signature for ${filePath}`);
345
346
const result = this.run('powershell', '-Command', `Get-AuthenticodeSignature "${filePath}" | Select-Object -ExpandProperty Status`);
347
if (result.error !== undefined) {
348
this.error(`Failed to run Get-AuthenticodeSignature: ${result.error.message}`);
349
}
350
351
const status = result.stdout.trim();
352
if (status !== 'Valid') {
353
this.error(`Authenticode signature is not valid for ${filePath}: ${status}`);
354
}
355
}
356
357
/**
358
* Validates Authenticode signatures for all executable files in the specified directory.
359
* @param dir The directory to scan for executable files.
360
*/
361
public validateAllAuthenticodeSignatures(dir: string) {
362
if (!this.options.checkSigning || !this.capabilities.has('windows')) {
363
this.log(`Skipping Authenticode signature validation for ${dir} (signing checks disabled)`);
364
return;
365
}
366
367
const files = fs.readdirSync(dir, { withFileTypes: true });
368
for (const file of files) {
369
const filePath = path.join(dir, file.name);
370
if (file.isDirectory()) {
371
this.validateAllAuthenticodeSignatures(filePath);
372
} else if (TestContext.authenticodeInclude.test(file.name)) {
373
this.validateAuthenticodeSignature(filePath);
374
}
375
}
376
}
377
378
/**
379
* Validates the codesign signature of a macOS binary or app bundle.
380
* @param filePath The path to the file or app bundle to validate.
381
*/
382
public validateCodesignSignature(filePath: string) {
383
if (!this.options.checkSigning || !this.capabilities.has('darwin')) {
384
this.log(`Skipping codesign signature validation for ${filePath} (signing checks disabled)`);
385
return;
386
}
387
388
this.log(`Validating codesign signature for ${filePath}`);
389
390
const result = this.run('codesign', '--verify', '--deep', '--strict', '--verbose=2', filePath);
391
if (result.error !== undefined) {
392
this.error(`Failed to run codesign: ${result.error.message}`);
393
}
394
395
if (result.status !== 0) {
396
this.error(`Codesign signature is not valid for ${filePath}: ${result.stderr}`);
397
}
398
399
this.log(`Validating notarization for ${filePath}`);
400
401
const notaryResult = this.run('spctl', '--assess', '--type', 'open', '--context', 'context:primary-signature', '--verbose=2', filePath);
402
if (notaryResult.error !== undefined) {
403
this.error(`Failed to run spctl: ${notaryResult.error.message}`);
404
}
405
406
if (notaryResult.status !== 0) {
407
this.error(`Notarization is not valid for ${filePath}: ${notaryResult.stderr}`);
408
}
409
}
410
411
/**
412
* Validates codesign signatures for all Mach-O binaries in the specified directory.
413
* @param dir The directory to scan for Mach-O binaries.
414
*/
415
public validateAllCodesignSignatures(dir: string) {
416
if (!this.options.checkSigning || !this.capabilities.has('darwin')) {
417
this.log(`Skipping codesign signature validation for ${dir} (signing checks disabled)`);
418
return;
419
}
420
421
const files = fs.readdirSync(dir, { withFileTypes: true });
422
for (const file of files) {
423
const filePath = path.join(dir, file.name);
424
if (file.isDirectory()) {
425
// For .app bundles, validate the bundle itself, not its contents
426
if (file.name.endsWith('.app') || file.name.endsWith('.framework')) {
427
this.validateCodesignSignature(filePath);
428
} else {
429
this.validateAllCodesignSignatures(filePath);
430
}
431
} else if (this.isMachOBinary(filePath)) {
432
this.validateCodesignSignature(filePath);
433
}
434
}
435
}
436
437
/**
438
* Checks if a file is a Mach-O binary by examining its magic number.
439
* @param filePath The path to the file to check.
440
* @returns True if the file is a Mach-O binary.
441
*/
442
private isMachOBinary(filePath: string): boolean {
443
try {
444
const file = fs.openSync(filePath, 'r');
445
const buffer = Buffer.alloc(4);
446
fs.readSync(file, buffer, 0, 4, 0);
447
fs.closeSync(file);
448
const magic = buffer.readUInt32BE(0);
449
return magic === 0xFEEDFACE || magic === 0xCEFAEDFE || magic === 0xFEEDFACF ||
450
magic === 0xCFFAEDFE || magic === 0xCAFEBABE || magic === 0xBEBAFECA;
451
} catch {
452
return false;
453
}
454
}
455
456
/**
457
* Downloads and unpacks the specified VS Code release target.
458
* @param target The target platform (e.g., 'cli-linux-x64').
459
* @returns The path to the unpacked directory.
460
*/
461
public async downloadAndUnpack(target: string): Promise<string> {
462
const filePath = await this.downloadTarget(target);
463
return this.unpackArchive(filePath);
464
}
465
466
/**
467
* Unpacks a .zip or .tar.gz archive to a temporary directory.
468
* @param archivePath The path to the archive file.
469
* @returns The path to the temporary directory where the archive was unpacked.
470
*/
471
public unpackArchive(archivePath: string): string {
472
const dir = this.createTempDir();
473
474
this.log(`Unpacking ${archivePath} to ${dir}`);
475
this.runNoErrors('tar', '-xzf', archivePath, '-C', dir, '--no-same-permissions');
476
this.log(`Unpacked ${archivePath} to ${dir}`);
477
478
return dir;
479
}
480
481
/**
482
* Mounts a macOS DMG file and returns the mount point.
483
* @param dmgPath The path to the DMG file.
484
* @returns The path to the mounted volume.
485
*/
486
public mountDmg(dmgPath: string): string {
487
this.log(`Mounting DMG ${dmgPath}`);
488
const result = this.runNoErrors('hdiutil', 'attach', dmgPath, '-nobrowse', '-readonly');
489
490
// Parse the output to find the mount point (last column of the last line)
491
const lines = result.stdout.trim().split('\n');
492
const lastLine = lines[lines.length - 1];
493
const mountPoint = lastLine.split('\t').pop()?.trim();
494
495
if (!mountPoint || !fs.existsSync(mountPoint)) {
496
this.error(`Failed to find mount point for DMG ${dmgPath}`);
497
}
498
499
this.log(`Mounted DMG at ${mountPoint}`);
500
return mountPoint;
501
}
502
503
/**
504
* Unmounts a macOS DMG volume.
505
* @param mountPoint The path to the mounted volume.
506
*/
507
public unmountDmg(mountPoint: string): void {
508
this.log(`Unmounting DMG ${mountPoint}`);
509
this.runNoErrors('hdiutil', 'detach', mountPoint);
510
this.log(`Unmounted DMG ${mountPoint}`);
511
}
512
513
/**
514
* Runs a command synchronously.
515
* @param command The command to run.
516
* @param args Optional arguments for the command.
517
* @returns The result of the spawnSync call.
518
*/
519
public run(command: string, ...args: string[]): SpawnSyncReturns<string> {
520
this.log(`Running command: ${command} ${args.join(' ')}`);
521
return spawnSync(command, args, { encoding: 'utf-8' }) as SpawnSyncReturns<string>;
522
}
523
524
/**
525
* Runs a command synchronously and ensures it succeeds.
526
* @param command The command to run.
527
* @param args Optional arguments for the command.
528
* @returns The result of the spawnSync call.
529
*/
530
public runNoErrors(command: string, ...args: string[]): SpawnSyncReturns<string> {
531
const result = this.run(command, ...args);
532
if (result.error !== undefined) {
533
this.error(`Failed to run command: ${result.error.message}`);
534
}
535
536
if (result.status !== 0) {
537
this.error(`Command exited with code ${result.status}: ${result.stderr}`);
538
}
539
540
return result;
541
}
542
543
/**
544
* Kills a process and all its child processes.
545
* @param pid The process ID to kill.
546
*/
547
public killProcessTree(pid: number): void {
548
this.log(`Killing process tree for PID: ${pid}`);
549
if (os.platform() === 'win32') {
550
spawnSync('taskkill', ['/T', '/F', '/PID', pid.toString()]);
551
} else {
552
process.kill(-pid, 'SIGKILL');
553
}
554
this.log(`Killed process tree for PID: ${pid}`);
555
}
556
557
/**
558
* Returns the Windows installation directory for VS Code based on the installation type and quality.
559
* @param type The type of installation ('user' or 'system').
560
* @returns The path to the VS Code installation directory.
561
*/
562
private getWindowsInstallDir(type: 'user' | 'system'): string {
563
let parentDir: string;
564
if (type === 'system') {
565
parentDir = process.env['ProgramW6432'] || process.env['PROGRAMFILES'] || '';
566
} else {
567
parentDir = path.join(process.env['LOCALAPPDATA'] || '', 'Programs');
568
}
569
570
switch (this.options.quality) {
571
case 'stable':
572
return path.join(parentDir, 'Microsoft VS Code');
573
case 'insider':
574
return path.join(parentDir, 'Microsoft VS Code Insiders');
575
case 'exploration':
576
return path.join(parentDir, 'Microsoft VS Code Exploration');
577
}
578
}
579
580
/**
581
* Installs a Microsoft Installer package silently.
582
* @param installerPath The path to the installer executable.
583
* @returns The path to the installed VS Code executable.
584
*/
585
public installWindowsApp(type: 'user' | 'system', installerPath: string): string {
586
this.log(`Installing ${installerPath} in silent mode`);
587
this.runNoErrors(installerPath, '/silent', '/mergetasks=!runcode');
588
this.log(`Installed ${installerPath} successfully`);
589
590
const appDir = this.getWindowsInstallDir(type);
591
let entryPoint: string;
592
switch (this.options.quality) {
593
case 'stable':
594
entryPoint = path.join(appDir, 'Code.exe');
595
break;
596
case 'insider':
597
entryPoint = path.join(appDir, 'Code - Insiders.exe');
598
break;
599
case 'exploration':
600
entryPoint = path.join(appDir, 'Code - Exploration.exe');
601
break;
602
}
603
604
if (!fs.existsSync(entryPoint)) {
605
this.error(`Desktop entry point does not exist: ${entryPoint}`);
606
}
607
608
this.log(`Installed VS Code executable at: ${entryPoint}`);
609
return entryPoint;
610
}
611
612
/**
613
* Uninstalls a Windows application silently.
614
* @param type The type of installation ('user' or 'system').
615
*/
616
public async uninstallWindowsApp(type: 'user' | 'system') {
617
const appDir = this.getWindowsInstallDir(type);
618
const uninstallerPath = path.join(appDir, 'unins000.exe');
619
if (!fs.existsSync(uninstallerPath)) {
620
this.error(`Uninstaller does not exist: ${uninstallerPath}`);
621
}
622
623
this.log(`Uninstalling VS Code from ${appDir} in silent mode`);
624
this.runNoErrors(uninstallerPath, '/silent');
625
this.log(`Uninstalled VS Code from ${appDir} successfully`);
626
627
await new Promise(resolve => setTimeout(resolve, 2000));
628
if (fs.existsSync(appDir)) {
629
this.error(`Installation directory still exists after uninstall: ${appDir}`);
630
}
631
}
632
633
/**
634
* Installs VS Code Linux DEB package.
635
* @param packagePath The path to the DEB file.
636
* @returns The path to the installed VS Code executable.
637
*/
638
public installDeb(packagePath: string): string {
639
this.log(`Installing ${packagePath} using DEB package manager`);
640
if (this.isRootUser) {
641
this.runNoErrors('dpkg', '-i', packagePath);
642
} else {
643
this.runNoErrors('sudo', 'dpkg', '-i', packagePath);
644
}
645
this.log(`Installed ${packagePath} successfully`);
646
647
const name = this.getLinuxBinaryName();
648
const entryPoint = path.join('/usr/share', name, name);
649
this.log(`Installed VS Code executable at: ${entryPoint}`);
650
return entryPoint;
651
}
652
653
/**
654
* Uninstalls VS Code Linux DEB package.
655
*/
656
public async uninstallDeb() {
657
const name = this.getLinuxBinaryName();
658
const packagePath = path.join('/usr/share', name, name);
659
660
this.log(`Uninstalling DEB package ${packagePath}`);
661
if (this.isRootUser) {
662
this.runNoErrors('dpkg', '-r', name);
663
} else {
664
this.runNoErrors('sudo', 'dpkg', '-r', name);
665
}
666
this.log(`Uninstalled DEB package ${packagePath} successfully`);
667
668
await new Promise(resolve => setTimeout(resolve, 1000));
669
if (fs.existsSync(packagePath)) {
670
this.error(`Package still exists after uninstall: ${packagePath}`);
671
}
672
}
673
674
/**
675
* Installs VS Code Linux RPM package.
676
* @param packagePath The path to the RPM file.
677
* @returns The path to the installed VS Code executable.
678
*/
679
public installRpm(packagePath: string): string {
680
this.log(`Installing ${packagePath} using RPM package manager`);
681
if (this.isRootUser) {
682
this.runNoErrors('rpm', '-i', packagePath);
683
} else {
684
this.runNoErrors('sudo', 'rpm', '-i', packagePath);
685
}
686
this.log(`Installed ${packagePath} successfully`);
687
688
const name = this.getLinuxBinaryName();
689
const entryPoint = path.join('/usr/share', name, name);
690
this.log(`Installed VS Code executable at: ${entryPoint}`);
691
return entryPoint;
692
}
693
694
/**
695
* Uninstalls VS Code Linux RPM package.
696
*/
697
public async uninstallRpm() {
698
const name = this.getLinuxBinaryName();
699
const packagePath = path.join('/usr/bin', name);
700
701
this.log(`Uninstalling RPM package ${packagePath}`);
702
if (this.isRootUser) {
703
this.runNoErrors('rpm', '-e', name);
704
} else {
705
this.runNoErrors('sudo', 'rpm', '-e', name);
706
}
707
this.log(`Uninstalled RPM package ${packagePath} successfully`);
708
709
await new Promise(resolve => setTimeout(resolve, 1000));
710
if (fs.existsSync(packagePath)) {
711
this.error(`Package still exists after uninstall: ${packagePath}`);
712
}
713
}
714
715
/**
716
* Installs VS Code Linux Snap package.
717
* @param packagePath The path to the Snap file.
718
* @returns The path to the installed VS Code executable.
719
*/
720
public installSnap(packagePath: string): string {
721
this.log(`Installing ${packagePath} using Snap package manager`);
722
if (this.isRootUser) {
723
this.runNoErrors('snap', 'install', packagePath, '--classic', '--dangerous');
724
} else {
725
this.runNoErrors('sudo', 'snap', 'install', packagePath, '--classic', '--dangerous');
726
}
727
this.log(`Installed ${packagePath} successfully`);
728
729
// Snap wrapper scripts are in /snap/bin, but actual Electron binary is in /snap/<package>/current/usr/share/
730
const name = this.getLinuxBinaryName();
731
const entryPoint = `/snap/${name}/current/usr/share/${name}/${name}`;
732
this.log(`Installed VS Code executable at: ${entryPoint}`);
733
return entryPoint;
734
}
735
736
/**
737
* Uninstalls VS Code Linux Snap package.
738
*/
739
public async uninstallSnap() {
740
const name = this.getLinuxBinaryName();
741
const packagePath = path.join('/snap/bin', name);
742
743
this.log(`Uninstalling Snap package ${packagePath}`);
744
if (this.isRootUser) {
745
this.runNoErrors('snap', 'remove', name);
746
} else {
747
this.runNoErrors('sudo', 'snap', 'remove', name);
748
}
749
this.log(`Uninstalled Snap package ${packagePath} successfully`);
750
751
await new Promise(resolve => setTimeout(resolve, 1000));
752
if (fs.existsSync(packagePath)) {
753
this.error(`Package still exists after uninstall: ${packagePath}`);
754
}
755
}
756
757
/**
758
* Returns the Linux binary name based on quality.
759
*/
760
private getLinuxBinaryName(): string {
761
switch (this.options.quality) {
762
case 'stable':
763
return 'code';
764
case 'insider':
765
return 'code-insiders';
766
case 'exploration':
767
return 'code-exploration';
768
}
769
}
770
771
/**
772
* Returns the entry point executable for the VS Code Desktop installation in the specified directory.
773
* @param dir The directory of the VS Code installation.
774
* @returns The path to the entry point executable.
775
*/
776
public getDesktopEntryPoint(dir: string): string {
777
let filePath: string = '';
778
779
switch (os.platform()) {
780
case 'darwin': {
781
let appName: string;
782
let binaryName: string;
783
switch (this.options.quality) {
784
case 'stable':
785
appName = 'Visual Studio Code.app';
786
binaryName = 'Code';
787
break;
788
case 'insider':
789
appName = 'Visual Studio Code - Insiders.app';
790
binaryName = 'Code - Insiders';
791
break;
792
case 'exploration':
793
appName = 'Visual Studio Code - Exploration.app';
794
binaryName = 'Code - Exploration';
795
break;
796
}
797
filePath = path.join(dir, appName, 'Contents/MacOS', binaryName);
798
break;
799
}
800
case 'linux': {
801
let binaryName: string;
802
switch (this.options.quality) {
803
case 'stable':
804
binaryName = `code`;
805
break;
806
case 'insider':
807
binaryName = `code-insiders`;
808
break;
809
case 'exploration':
810
binaryName = `code-exploration`;
811
break;
812
}
813
filePath = path.join(dir, binaryName);
814
break;
815
}
816
case 'win32': {
817
let exeName: string;
818
switch (this.options.quality) {
819
case 'stable':
820
exeName = 'Code.exe';
821
break;
822
case 'insider':
823
exeName = 'Code - Insiders.exe';
824
break;
825
case 'exploration':
826
exeName = 'Code - Exploration.exe';
827
break;
828
}
829
filePath = path.join(dir, exeName);
830
break;
831
}
832
}
833
834
if (!filePath || !fs.existsSync(filePath)) {
835
this.error(`Desktop entry point does not exist: ${filePath}`);
836
}
837
838
return filePath;
839
}
840
841
/**
842
* Returns the entry point executable for the VS Code CLI in the specified directory.
843
* @param dir The directory containing unpacked CLI files.
844
* @returns The path to the CLI entry point executable.
845
*/
846
public getCliEntryPoint(dir: string): string {
847
let filename: string;
848
switch (this.options.quality) {
849
case 'stable':
850
filename = 'code';
851
break;
852
case 'insider':
853
filename = 'code-insiders';
854
break;
855
case 'exploration':
856
filename = 'code-exploration';
857
break;
858
}
859
860
if (os.platform() === 'win32') {
861
filename += '.exe';
862
}
863
864
const entryPoint = path.join(dir, filename);
865
if (!fs.existsSync(entryPoint)) {
866
this.error(`CLI entry point does not exist: ${entryPoint}`);
867
}
868
869
return entryPoint;
870
}
871
872
/**
873
* Returns the entry point executable for the VS Code server in the specified directory.
874
* @param dir The directory containing unpacked server files.
875
* @param forWsl If true, returns the Linux entry point (for running in WSL on Windows).
876
* @returns The path to the server entry point executable.
877
*/
878
public getServerEntryPoint(dir: string, forWsl = false): string {
879
let filename: string;
880
switch (this.options.quality) {
881
case 'stable':
882
filename = 'code-server';
883
break;
884
case 'insider':
885
filename = 'code-server-insiders';
886
break;
887
case 'exploration':
888
filename = 'code-server-exploration';
889
break;
890
}
891
892
if (os.platform() === 'win32' && !forWsl) {
893
filename += '.cmd';
894
}
895
896
const entryPoint = path.join(this.getFirstSubdirectory(dir), 'bin', filename);
897
if (!fs.existsSync(entryPoint)) {
898
this.error(`Server entry point does not exist: ${entryPoint}`);
899
}
900
901
return entryPoint;
902
}
903
904
/**
905
* Returns the first subdirectory within the specified directory.
906
*/
907
public getFirstSubdirectory(dir: string): string {
908
const subDir = fs.readdirSync(dir, { withFileTypes: true }).filter(o => o.isDirectory()).at(0)?.name;
909
if (!subDir) {
910
this.error(`No subdirectories found in directory: ${dir}`);
911
}
912
return path.join(dir, subDir);
913
}
914
915
/**
916
* Creates a portable data directory in the specified unpacked VS Code directory.
917
* @param dir The directory where VS Code was unpacked.
918
* @returns The path to the created portable data directory.
919
*/
920
public createPortableDataDir(dir: string): string {
921
const dataDir = path.join(dir, os.platform() === 'darwin' ? 'code-portable-data' : 'data');
922
923
this.log(`Creating portable data directory: ${dataDir}`);
924
fs.mkdirSync(dataDir, { recursive: true });
925
this.log(`Created portable data directory: ${dataDir}`);
926
927
return dataDir;
928
}
929
930
/**
931
* Launches a web browser for UI testing.
932
* @returns The launched Browser instance.
933
*/
934
public async launchBrowser(): Promise<Browser> {
935
this.log(`Launching web browser`);
936
const headless = this.options.headlessBrowser;
937
switch (os.platform()) {
938
case 'darwin': {
939
return await webkit.launch({ headless });
940
}
941
case 'win32': {
942
const executablePath = process.env['PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH'] ?? 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe';
943
this.log(`Using Chromium executable at: ${executablePath}`);
944
return await chromium.launch({ headless, executablePath });
945
}
946
case 'linux':
947
default: {
948
const executablePath = process.env['PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH'] ?? '/usr/bin/chromium-browser';
949
this.log(`Using Chromium executable at: ${executablePath}`);
950
return await chromium.launch({
951
headless,
952
executablePath,
953
args: [
954
'--disable-gpu',
955
'--disable-gpu-compositing',
956
'--disable-software-rasterizer',
957
'--no-zygote',
958
]
959
});
960
}
961
}
962
}
963
964
/**
965
* Awaits a page promise and sets the default timeout.
966
* @param pagePromise The promise that resolves to a Page.
967
* @returns The page with the timeout configured.
968
*/
969
public async getPage(pagePromise: Promise<Page>): Promise<Page> {
970
const page = await pagePromise;
971
page.setDefaultTimeout(3 * 60 * 1000);
972
return page;
973
}
974
975
/**
976
* Constructs a web server URL with optional token and folder parameters.
977
* @param port The port number of the web server.
978
* @param token The optional authentication token.
979
* @param folder The optional workspace folder path to open.
980
* @returns The constructed web server URL.
981
*/
982
public getWebServerUrl(port: string, token?: string, folder?: string): URL {
983
const url = new URL(`http://localhost:${port}`);
984
if (token) {
985
url.searchParams.set('tkn', token);
986
}
987
if (folder) {
988
folder = folder.replaceAll('\\', '/');
989
if (!folder.startsWith('/')) {
990
folder = `/${folder}`;
991
}
992
url.searchParams.set('folder', folder);
993
}
994
return url;
995
}
996
997
/**
998
* Returns the tunnel URL for the VS Code server.
999
* @param baseUrl The base URL for *vscode.dev/tunnel connection.
1000
* @param workspaceDir Optional folder path to open
1001
* @returns The tunnel URL with folder in pathname.
1002
*/
1003
public getTunnelUrl(baseUrl: string, workspaceDir?: string): string {
1004
const url = new URL(baseUrl);
1005
url.searchParams.set('vscode-version', this.options.commit);
1006
if (workspaceDir) {
1007
let folder = workspaceDir.replaceAll('\\', '/');
1008
if (!folder.startsWith('/')) {
1009
folder = `/${folder}`;
1010
}
1011
url.pathname = url.pathname.replace(/\/+$/, '') + folder;
1012
}
1013
return url.toString();
1014
}
1015
1016
/**
1017
* Returns a random alphanumeric token of length 10.
1018
*/
1019
public getRandomToken(): string {
1020
return Array.from({ length: 10 }, () => Math.floor(Math.random() * 36).toString(36)).join('');
1021
}
1022
1023
/**
1024
* Returns a unique port number, starting from 3010 and incrementing.
1025
*/
1026
public getUniquePort(): string {
1027
return String(this.nextPort++);
1028
}
1029
1030
/**
1031
* Returns the default WSL server extensions directory path.
1032
* @returns The path to the extensions directory (e.g., '~/.vscode-server-insiders/extensions').
1033
*/
1034
public getWslServerExtensionsDir(): string {
1035
let serverDir: string;
1036
switch (this.options.quality) {
1037
case 'stable':
1038
serverDir = '.vscode-server';
1039
break;
1040
case 'insider':
1041
serverDir = '.vscode-server-insiders';
1042
break;
1043
case 'exploration':
1044
serverDir = '.vscode-server-exploration';
1045
break;
1046
}
1047
return `~/${serverDir}/extensions`;
1048
}
1049
1050
/**
1051
* Runs a VS Code command-line application (such as server or CLI).
1052
* @param name The name of the app as it will appear in logs.
1053
* @param command Command to run.
1054
* @param args Arguments for the command.
1055
* @param onLine Callback to handle output lines.
1056
*/
1057
public async runCliApp(name: string, command: string, args: string[], onLine: (text: string) => Promise<boolean | void | undefined>) {
1058
this.log(`Starting ${name} with command line: ${command} ${args.join(' ')}`);
1059
1060
const app = spawn(command, args, {
1061
shell: /\.(sh|cmd)$/.test(command),
1062
detached: !this.capabilities.has('windows'),
1063
stdio: ['ignore', 'pipe', 'pipe']
1064
});
1065
1066
try {
1067
await new Promise<void>((resolve, reject) => {
1068
app.stderr.on('data', (data) => {
1069
const text = `[${name}] ${data.toString().trim()}`;
1070
if (/ECONNRESET/.test(text)) {
1071
this.log(text);
1072
} else {
1073
reject(new Error(text));
1074
}
1075
});
1076
1077
let terminated = false;
1078
app.stdout.on('data', (data) => {
1079
const text = data.toString().trim();
1080
if (/\berror\b/.test(text)) {
1081
reject(new Error(`[${name}] ${text}`));
1082
}
1083
1084
for (const line of text.split('\n')) {
1085
this.log(`[${name}] ${line}`);
1086
onLine(line).then((result) => {
1087
if (terminated = !!result) {
1088
this.log(`Terminating ${name} process`);
1089
resolve();
1090
}
1091
}).catch(reject);
1092
}
1093
});
1094
1095
app.on('error', reject);
1096
app.on('exit', (code) => {
1097
if (code === 0) {
1098
resolve();
1099
} else if (!terminated) {
1100
reject(new Error(`[${name}] Exited with code ${code}`));
1101
}
1102
});
1103
});
1104
} finally {
1105
this.killProcessTree(app.pid!);
1106
}
1107
}
1108
}
1109
1110