/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { spawn, spawnSync, SpawnSyncReturns } from 'child_process'; import { createHash } from 'crypto'; import fs from 'fs'; import { test } from 'mocha'; import fetch, { Response } from 'node-fetch'; import os from 'os'; import path from 'path'; import { Browser, chromium, Page, webkit } from 'playwright'; import { Capability, detectCapabilities } from './detectors.js'; /** * Response from https://update.code.visualstudio.com/api/versions/commit:// */ interface ITargetMetadata { url: string; name: string; version: string; productVersion: string; hash: string; timestamp: number; sha256hash: string; supportsFastUpdate: boolean; } /** * Provides context and utilities for VS Code sanity tests. */ export class TestContext { private static readonly authenticodeInclude = /^.+\.(exe|dll|sys|cab|cat|msi|jar|ocx|ps1|psm1|psd1|ps1xml|pssc1)$/i; private static readonly versionInfoInclude = /^.+\.(exe|dll|node|msi)$/i; private static readonly versionInfoExclude = /^(dxil\.dll|ffmpeg\.dll|msalruntime\.dll)$/i; private static readonly dpkgLockError = /dpkg frontend lock was locked by another process|unable to acquire the dpkg frontend lock|could not get lock \/var\/lib\/dpkg\/lock-frontend/i; private readonly tempDirs = new Set(); private readonly wslTempDirs = new Set(); private nextPort = 3010; private currentTestName: string | undefined; public constructor(public readonly options: Readonly<{ quality: 'stable' | 'insider' | 'exploration'; commit: string; verbose: boolean; cleanup: boolean; checkSigning: boolean; headlessBrowser: boolean; downloadOnly: boolean; screenshotsDir: string | undefined; }>) { } /** * Returns true if the current process is running as root. */ public readonly isRootUser = process.getuid?.() === 0; /** * Returns the detected capabilities of the current system. */ public readonly capabilities = detectCapabilities(); /** * Returns the OS temp directory with expanded long names on Windows. */ public readonly osTempDir = (() => { let tempDir = fs.realpathSync(os.tmpdir()); // On Windows, expand short 8.3 file names to long names if (os.platform() === 'win32') { tempDir = fs.realpathSync.native(tempDir); } return tempDir; })(); /** * Runs a test only if the required capabilities are present. * @param name The name of the test. * @param require The required capabilities for the test. * @param fn The test function. * @returns The Mocha test object or void if the test is skipped. */ public test(name: string, require: Capability[], fn: () => Promise): Mocha.Test | void { if (!this.options.downloadOnly && require.some(o => !this.capabilities.has(o))) { return; } const self = this; return test(name, async function () { self.currentTestName = name; self.log(`Starting test: ${name}`); const homeDir = os.homedir(); process.chdir(homeDir); self.log(`Changed working directory to: ${homeDir}`); try { await fn(); } catch (error) { self.log(`Test failed with error: ${error instanceof Error ? error.message : String(error)}`); throw error; } finally { self.currentTestName = undefined; process.chdir(homeDir); self.log(`Changed working directory to: ${homeDir}`); if (self.options.cleanup) { self.cleanup(); } self.log(`Finished test: ${name}`); } }); } /** * The console outputs collected during the current test. */ public consoleOutputs: string[] = []; /** * Logs a message with a timestamp. */ public log(message: string) { const line = `[${new Date().toISOString()}] ${message}`; this.consoleOutputs.push(line); if (this.options.verbose) { console.log(line); } } /** * Logs an error message and throws an Error. */ public error(message: string): never { const line = `[${new Date().toISOString()}] ERROR: ${message}`; this.consoleOutputs.push(line); console.error(`##vso[task.logissue type=error]${line}`); throw new Error(message); } /** * Logs a warning message with a timestamp. */ public warn(message: string) { const line = `[${new Date().toISOString()}] WARNING: ${message}`; this.consoleOutputs.push(line); console.warn(`##vso[task.logissue type=warning]${line}`); } /** * Returns a promise that resolves after the specified delay in milliseconds. * @param delay The delay in milliseconds to wait before resolving the promise. */ private timeout(delay: number) { return new Promise(resolve => setTimeout(resolve, delay)); } /** * Creates a new temporary directory and returns its path. */ public createTempDir(): string { const tempDir = fs.mkdtempSync(path.join(this.osTempDir, 'vscode-sanity')); this.log(`Created temp directory: ${tempDir}`); this.tempDirs.add(tempDir); return tempDir; } /** * Creates a new temporary directory in WSL and returns its path. */ public createWslTempDir(): string { const tempDir = `/tmp/vscode-sanity-${Date.now()}-${Math.random().toString(36).slice(2)}`; this.log(`Creating WSL temp directory: ${tempDir}`); this.runNoErrors('wsl', 'mkdir', '-p', tempDir); this.wslTempDirs.add(tempDir); return tempDir; } /** * Deletes a directory in WSL. * @param dir The WSL directory path to delete. */ public deleteWslDir(dir: string): void { this.log(`Deleting WSL directory: ${dir}`); this.runNoErrors('wsl', 'rm', '-rf', dir); } /** * Converts a Windows path to a WSL path. * @param windowsPath The Windows path to convert (e.g., 'C:\Users\test'). * @returns The WSL path (e.g., '/mnt/c/Users/test'). */ public toWslPath(windowsPath: string): string { return windowsPath .replace(/^([A-Za-z]):/, (_, drive) => `/mnt/${drive.toLowerCase()}`) .replaceAll('\\', '/'); } /** * Returns the name of the default WSL distribution. * @returns The default WSL distribution name (e.g., 'Ubuntu'). */ public getDefaultWslDistro(): string { const result = this.runNoErrors('wsl', '--list', '--quiet'); const distro = result.stdout.trim().split('\n')[0].replace(/\0/g, '').trim(); if (!distro) { this.error('No WSL distribution found'); } this.log(`Default WSL distribution: ${distro}`); return distro; } /** * Ensures that the directory for the specified file path exists. */ public ensureDirExists(filePath: string) { const dir = path.dirname(filePath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } } /** * Cleans up all temporary directories created during the test run. */ public cleanup() { for (const dir of this.tempDirs) { this.log(`Deleting temp directory: ${dir}`); try { fs.rmSync(dir, { recursive: true, force: true }); this.log(`Deleted temp directory: ${dir}`); } catch (error) { this.warn(`Failed to delete temp directory: ${dir}: ${error}`); } } this.tempDirs.clear(); for (const dir of this.wslTempDirs) { try { this.deleteWslDir(dir); } catch (error) { this.warn(`Failed to delete WSL temp directory: ${dir}: ${error}`); } } this.wslTempDirs.clear(); } /** * Fetches a URL and ensures there are no errors. * @param url The URL to fetch. * @returns The fetch Response object. */ public async fetchNoErrors(url: string): Promise { const maxRetries = 5; let lastError: Error | undefined; for (let attempt = 0; attempt < maxRetries; attempt++) { if (attempt > 0) { const delay = Math.pow(2, attempt - 1) * 1000; this.warn(`Retrying fetch (attempt ${attempt + 1}/${maxRetries}) after ${delay}ms`); await this.timeout(delay); } try { const response = await fetch(url); if (!response.ok) { lastError = new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`); continue; } if (response.body === null) { lastError = new Error(`Response body is null for ${url}`); continue; } return response as Response & { body: NodeJS.ReadableStream }; } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); this.warn(`Fetch attempt ${attempt + 1} failed: ${lastError.message}`); } } this.error(`Failed to fetch ${url} after ${maxRetries} attempts: ${lastError?.message}`); } /** * Fetches metadata for a specific VS Code release target. * @param target The target platform (e.g., 'cli-linux-x64'). * @returns The target metadata. */ public async fetchMetadata(target: string): Promise { const url = `https://update.code.visualstudio.com/api/versions/commit:${this.options.commit}/${target}/${this.options.quality}`; this.log(`Fetching metadata for ${target} from ${url}`); const response = await this.fetchNoErrors(url); const result = await response.json() as ITargetMetadata; if (result.url === undefined || result.sha256hash === undefined) { this.error(`Invalid metadata response for ${target}: ${JSON.stringify(result)}`); } this.log(`Fetched metadata for ${target}: ${JSON.stringify(result)}`); return result; } /** * Downloads installer for specified VS Code release target. * @param target The target platform (e.g., 'cli-linux-x64'). * @returns The path to the downloaded file. */ public async downloadTarget(target: string): Promise { const { url, sha256hash } = await this.fetchMetadata(target); const filePath = path.join(this.createTempDir(), path.basename(url)); this.log(`Downloading ${url} to ${filePath}`); this.runNoErrors('curl', '-fSL', '--retry', '5', '--retry-delay', '2', '--retry-all-errors', '-o', filePath, url); this.log(`Downloaded ${url} to ${filePath}`); this.validateSha256Hash(filePath, sha256hash); return filePath; } /** * Validates the SHA-256 hash of a file. * @param filePath The path to the file to validate. * @param expectedHash The expected SHA-256 hash in hexadecimal format. */ public validateSha256Hash(filePath: string, expectedHash: string) { this.log(`Validating SHA256 hash for ${filePath}`); const buffer = fs.readFileSync(filePath); const hash = createHash('sha256').update(buffer).digest('hex'); if (hash !== expectedHash) { this.error(`Hash mismatch for ${filePath}: expected ${expectedHash}, got ${hash}`); } } /** * Validates the Authenticode signature of a Windows executable. * @param filePath The path to the file to validate. */ public validateAuthenticodeSignature(filePath: string) { if (!this.options.checkSigning || !this.capabilities.has('windows')) { this.log(`Skipping Authenticode signature validation for ${filePath} (signing checks disabled)`); return; } this.log(`Validating Authenticode signature for ${filePath}`); this.validateAuthenticodeSignaturesForFiles([filePath]); } /** * Collects all files matching the Authenticode include pattern from the specified directory recursively. */ private collectAuthenticodeFiles(dir: string, files: string[]): void { const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const filePath = path.join(dir, entry.name); if (entry.isDirectory()) { this.collectAuthenticodeFiles(filePath, files); } else if (TestContext.authenticodeInclude.test(entry.name)) { files.push(filePath); } } } /** * Validates Authenticode signatures for the specified list of files in a single PowerShell call. */ private validateAuthenticodeSignaturesForFiles(files: string[]): void { if (files.length === 0) { return; } const fileList = files.map(file => `"${file}"`).join(','); const command = `@(${fileList}) | ForEach-Object { $sig = Get-AuthenticodeSignature $_; "$($sig.Path)|$($sig.Status)" }`; const result = this.runNoErrors('powershell', '-NoProfile', '-Command', command); const invalid: string[] = []; for (const line of result.stdout.trim().split('\n')) { const [, filePath, status] = /^(.+)\|(\w+)$/.exec(line.trim()) ?? []; if (filePath) { if (status === 'Valid') { this.log(`Authenticode signature is valid for ${filePath}`); } else { invalid.push(`${filePath}: ${status}`); } } } if (invalid.length > 0) { this.error(`Authenticode signatures are not valid for:\n${invalid.join('\n')}`); } } /** * Validates Authenticode signatures for all executable files in the specified directory. * @param dir The directory to scan for executable files. */ public validateAllAuthenticodeSignatures(dir: string) { if (!this.options.checkSigning || !this.capabilities.has('windows')) { this.log(`Skipping Authenticode signature validation for ${dir} (signing checks disabled)`); return; } const files: string[] = []; this.collectAuthenticodeFiles(dir, files); this.log(`Found ${files.length} file(s) to validate Authenticode signatures`); this.validateAuthenticodeSignaturesForFiles(files); } /** * Collects all files matching the VersionInfo include pattern from the specified directory recursively. */ private collectVersionInfoFiles(dir: string, files: string[]): void { const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const filePath = path.join(dir, entry.name); if (entry.isDirectory()) { this.collectVersionInfoFiles(filePath, files); } else if (TestContext.versionInfoInclude.test(entry.name)) { if (TestContext.versionInfoExclude.test(entry.name)) { this.log(`Skipping excluded file from VersionInfo validation: ${filePath}`); } else { files.push(filePath); } } } } /** * Validates that a Windows binary has a non-empty ProductName in VersionInfo. * @param filePath The path to the file to validate. */ public validateVersionInfo(filePath: string) { if (!this.options.checkSigning || !this.capabilities.has('windows')) { this.log(`Skipping VersionInfo validation for ${filePath} (signing checks disabled)`); return; } this.log(`Validating VersionInfo for ${filePath}`); this.validateVersionInfoForFiles([filePath]); } /** * Validates VersionInfo ProductName for the specified list of files in a single PowerShell call. */ private validateVersionInfoForFiles(files: string[]): void { if (files.length === 0) { return; } const fileList = files.map(file => `"${file}"`).join(','); const command = `@(${fileList}) | ForEach-Object { $vi = (Get-Item $_).VersionInfo; "$($_.ToString())|$($vi.ProductName)" }`; const result = this.runNoErrors('powershell', '-NoProfile', '-Command', command); const invalid: string[] = []; for (const line of result.stdout.trim().split('\n')) { const [, filePath, productName] = /^(.+)\|(.*)$/.exec(line.trim()) ?? []; if (filePath) { if (productName && productName.trim().length > 0) { this.log(`VersionInfo ProductName is set for ${filePath}: ${productName.trim()}`); } else { invalid.push(filePath); } } } if (invalid.length > 0) { this.error(`VersionInfo ProductName is missing or empty for:\n${invalid.join('\n')}`); } } /** * Validates that all .exe, .dll, .node and .msi binaries in the specified directory have a non-empty ProductName in VersionInfo. * @param dir The directory to scan for binary files. */ public validateAllVersionInfo(dir: string) { if (!this.options.checkSigning || !this.capabilities.has('windows')) { this.log(`Skipping VersionInfo validation for ${dir} (signing checks disabled)`); return; } const files: string[] = []; this.collectVersionInfoFiles(dir, files); this.log(`Found ${files.length} file(s) to validate VersionInfo`); this.validateVersionInfoForFiles(files); } /** * Validates the codesign signature of a macOS binary or app bundle. * @param filePath The path to the file or app bundle to validate. */ public validateCodesignSignature(filePath: string) { if (!this.options.checkSigning || !this.capabilities.has('darwin')) { this.log(`Skipping codesign signature validation for ${filePath} (signing checks disabled)`); return; } this.log(`Validating codesign signature for ${filePath}`); const result = this.run('codesign', '--verify', '--deep', '--strict', '--verbose=2', filePath); if (result.error !== undefined) { this.error(`Failed to run codesign: ${result.error.message}`); } if (result.status !== 0) { this.error(`Codesign signature is not valid for ${filePath}: ${result.stderr}`); } this.log(`Validating notarization for ${filePath}`); const notaryResult = this.run('spctl', '--assess', '--type', 'open', '--context', 'context:primary-signature', '--verbose=2', filePath); if (notaryResult.error !== undefined) { this.error(`Failed to run spctl: ${notaryResult.error.message}`); } if (notaryResult.status !== 0) { this.error(`Notarization is not valid for ${filePath}: ${notaryResult.stderr}`); } } /** * Validates codesign signatures for all Mach-O binaries in the specified directory. * @param dir The directory to scan for Mach-O binaries. */ public validateAllCodesignSignatures(dir: string) { if (!this.options.checkSigning || !this.capabilities.has('darwin')) { this.log(`Skipping codesign signature validation for ${dir} (signing checks disabled)`); return; } const files = fs.readdirSync(dir, { withFileTypes: true }); for (const file of files) { const filePath = path.join(dir, file.name); if (file.isDirectory()) { // For .app bundles, validate the bundle itself, not its contents if (file.name.endsWith('.app') || file.name.endsWith('.framework')) { this.validateCodesignSignature(filePath); } else { this.validateAllCodesignSignatures(filePath); } } else if (this.isMachOBinary(filePath)) { this.validateCodesignSignature(filePath); } } } /** * Checks if a file is a Mach-O binary by examining its magic number. * @param filePath The path to the file to check. * @returns True if the file is a Mach-O binary. */ private isMachOBinary(filePath: string): boolean { try { const file = fs.openSync(filePath, 'r'); const buffer = Buffer.alloc(4); fs.readSync(file, buffer, 0, 4, 0); fs.closeSync(file); const magic = buffer.readUInt32BE(0); return magic === 0xFEEDFACE || magic === 0xCEFAEDFE || magic === 0xFEEDFACF || magic === 0xCFFAEDFE || magic === 0xCAFEBABE || magic === 0xBEBAFECA; } catch { return false; } } /** * Downloads and unpacks the specified VS Code release target. * @param target The target platform (e.g., 'cli-linux-x64'). * @returns The path to the unpacked directory. */ public async downloadAndUnpack(target: string): Promise { const filePath = await this.downloadTarget(target); return this.unpackArchive(filePath); } /** * Unpacks a .zip or .tar.gz archive to a temporary directory. * @param archivePath The path to the archive file. * @returns The path to the temporary directory where the archive was unpacked. */ public unpackArchive(archivePath: string): string { const dir = this.createTempDir(); this.log(`Unpacking ${archivePath} to ${dir}`); this.runNoErrors('tar', '-xzf', archivePath, '-C', dir, '--no-same-permissions'); this.log(`Unpacked ${archivePath} to ${dir}`); return dir; } /** * Mounts a macOS DMG file and returns the mount point. * @param dmgPath The path to the DMG file. * @returns The path to the mounted volume. */ public mountDmg(dmgPath: string): string { this.log(`Mounting DMG ${dmgPath}`); const result = this.runNoErrors('hdiutil', 'attach', dmgPath, '-nobrowse', '-readonly'); // Parse the output to find the mount point (last column of the last line) const lines = result.stdout.trim().split('\n'); const lastLine = lines[lines.length - 1]; const mountPoint = lastLine.split('\t').pop()?.trim(); if (!mountPoint || !fs.existsSync(mountPoint)) { this.error(`Failed to find mount point for DMG ${dmgPath}`); } this.log(`Mounted DMG at ${mountPoint}`); return mountPoint; } /** * Unmounts a macOS DMG volume. * @param mountPoint The path to the mounted volume. */ public unmountDmg(mountPoint: string): void { this.log(`Unmounting DMG ${mountPoint}`); this.runNoErrors('hdiutil', 'detach', mountPoint); this.log(`Unmounted DMG ${mountPoint}`); } /** * Runs a command synchronously. * @param command The command to run. * @param args Optional arguments for the command. * @returns The result of the spawnSync call. */ public run(command: string, ...args: string[]): SpawnSyncReturns { this.log(`Running command: ${command} ${args.join(' ')}`); return spawnSync(command, args, { encoding: 'utf-8' }) as SpawnSyncReturns; } /** * Runs a command synchronously and ensures it succeeds. * @param command The command to run. * @param args Optional arguments for the command. * @returns The result of the spawnSync call. */ public runNoErrors(command: string, ...args: string[]): SpawnSyncReturns { const result = this.run(command, ...args); if (result.error !== undefined) { this.error(`Failed to run command: ${result.error.message}`); } if (result.status !== 0) { this.error(`Command exited with code ${result.status}: ${result.stderr}`); } return result; } /** * Runs a command with sudo if not running as root, and ensures it succeeds. * @param command The command to run. * @param args Optional arguments for the command. * @returns The result of the spawnSync call. */ private runSudoNoErrors(command: string, ...args: string[]): SpawnSyncReturns { if (this.isRootUser) { return this.runNoErrors(command, ...args); } else { return this.runNoErrors('sudo', command, ...args); } } /** * Runs a dpkg command with retries if the frontend lock is busy, and ensures it succeeds. */ private async runDpkgNoErrors(...args: string[]) { const command = this.isRootUser ? 'dpkg' : 'sudo'; const commandArgs = this.isRootUser ? args : ['dpkg', ...args]; const maxRetries = 5; let lastError: string | undefined; for (let attempt = 0; attempt < maxRetries; attempt++) { if (attempt > 0) { const delay = Math.pow(2, attempt - 1) * 1000; this.log(`Retrying dpkg command (attempt ${attempt + 1}/${maxRetries}) after ${delay}ms`); await this.timeout(delay); } const result = this.run(command, ...commandArgs); if (result.error !== undefined) { lastError = `Failed to run command: ${result.error.message}`; break; } if (result.status === 0) { return; } lastError = `Command exited with code ${result.status}: ${result.stderr}`; const output = `${result.stdout}${result.stderr}`; if (!TestContext.dpkgLockError.test(output)) { break; } this.log(`dpkg lock is busy, waiting for the other package manager process to finish`); } this.error(lastError ?? `Command failed after ${maxRetries} attempts because the dpkg frontend lock remained busy`); } /** * Kills a process and all its child processes. * @param pid The process ID to kill. */ public killProcessTree(pid: number): void { this.log(`Killing process tree for PID: ${pid}`); if (os.platform() === 'win32') { spawnSync('taskkill', ['/T', '/F', '/PID', pid.toString()]); } else { process.kill(-pid, 'SIGKILL'); } this.log(`Killed process tree for PID: ${pid}`); } /** * Returns the Windows installation directory for VS Code based on the installation type and quality. * @param type The type of installation ('user' or 'system'). * @returns The path to the VS Code installation directory. */ private getWindowsInstallDir(type: 'user' | 'system'): string { let parentDir: string; if (type === 'system') { parentDir = process.env['ProgramW6432'] || process.env['PROGRAMFILES'] || ''; } else { parentDir = path.join(process.env['LOCALAPPDATA'] || '', 'Programs'); } switch (this.options.quality) { case 'stable': return path.join(parentDir, 'Microsoft VS Code'); case 'insider': return path.join(parentDir, 'Microsoft VS Code Insiders'); case 'exploration': return path.join(parentDir, 'Microsoft VS Code Exploration'); } } /** * Installs a Microsoft Installer package silently. * @param installerPath The path to the installer executable. * @returns The path to the installed VS Code executable. */ public installWindowsApp(type: 'user' | 'system', installerPath: string): string { this.log(`Installing ${installerPath} in silent mode`); this.runNoErrors(installerPath, '/silent', '/mergetasks=!runcode'); this.log(`Installed ${installerPath} successfully`); const appDir = this.getWindowsInstallDir(type); let entryPoint: string; switch (this.options.quality) { case 'stable': entryPoint = path.join(appDir, 'Code.exe'); break; case 'insider': entryPoint = path.join(appDir, 'Code - Insiders.exe'); break; case 'exploration': entryPoint = path.join(appDir, 'Code - Exploration.exe'); break; } if (!fs.existsSync(entryPoint)) { this.error(`Desktop entry point does not exist: ${entryPoint}`); } this.log(`Installed VS Code executable at: ${entryPoint}`); return entryPoint; } /** * Uninstalls a Windows application silently. * @param type The type of installation ('user' or 'system'). */ public async uninstallWindowsApp(type: 'user' | 'system') { const appDir = this.getWindowsInstallDir(type); const uninstallerPath = path.join(appDir, 'unins000.exe'); if (!fs.existsSync(uninstallerPath)) { this.error(`Uninstaller does not exist: ${uninstallerPath}`); } this.log(`Uninstalling VS Code from ${appDir} in silent mode`); this.runNoErrors(uninstallerPath, '/silent'); this.log(`Uninstalled VS Code from ${appDir} successfully`); await this.timeout(2000); if (fs.existsSync(appDir)) { this.error(`Installation directory still exists after uninstall: ${appDir}`); } } /** * Installs VS Code Linux DEB package. * @param packagePath The path to the DEB file. * @returns The path to the installed VS Code executable. */ public async installDeb(packagePath: string): Promise { this.log(`Installing ${packagePath} using DEB package manager`); await this.runDpkgNoErrors('-i', packagePath); this.log(`Installed ${packagePath} successfully`); const name = this.getLinuxBinaryName(); const entryPoint = path.join('/usr/share', name, name); this.log(`Installed VS Code executable at: ${entryPoint}`); return entryPoint; } /** * Uninstalls VS Code Linux DEB package. */ public async uninstallDeb() { const name = this.getLinuxBinaryName(); const packagePath = path.join('/usr/share', name, name); this.log(`Uninstalling DEB package ${packagePath}`); await this.runDpkgNoErrors('-r', name); this.log(`Uninstalled DEB package ${packagePath} successfully`); await this.timeout(1000); if (fs.existsSync(packagePath)) { this.error(`Package still exists after uninstall: ${packagePath}`); } } /** * Installs VS Code Linux RPM package. * @param packagePath The path to the RPM file. * @returns The path to the installed VS Code executable. */ public installRpm(packagePath: string): string { this.log(`Installing ${packagePath} using RPM package manager`); this.runSudoNoErrors('rpm', '-i', packagePath); this.log(`Installed ${packagePath} successfully`); const name = this.getLinuxBinaryName(); const entryPoint = path.join('/usr/share', name, name); this.log(`Installed VS Code executable at: ${entryPoint}`); return entryPoint; } /** * Uninstalls VS Code Linux RPM package. */ public async uninstallRpm() { const name = this.getLinuxBinaryName(); const packagePath = path.join('/usr/bin', name); this.log(`Uninstalling RPM package ${packagePath}`); this.runSudoNoErrors('rpm', '-e', name); this.log(`Uninstalled RPM package ${packagePath} successfully`); await this.timeout(1000); if (fs.existsSync(packagePath)) { this.error(`Package still exists after uninstall: ${packagePath}`); } } /** * Installs VS Code Linux Snap package. * @param packagePath The path to the Snap file. * @returns The path to the installed VS Code executable. */ public installSnap(packagePath: string): string { this.log(`Installing ${packagePath} using Snap package manager`); this.runSudoNoErrors('snap', 'install', packagePath, '--classic', '--dangerous'); this.log(`Installed ${packagePath} successfully`); // Snap wrapper scripts are in /snap/bin, but actual Electron binary is in /snap//current/usr/share/ const name = this.getLinuxBinaryName(); const entryPoint = `/snap/${name}/current/usr/share/${name}/${name}`; this.log(`Installed VS Code executable at: ${entryPoint}`); return entryPoint; } /** * Uninstalls VS Code Linux Snap package. */ public async uninstallSnap() { const name = this.getLinuxBinaryName(); const packagePath = path.join('/snap/bin', name); this.log(`Uninstalling Snap package ${packagePath}`); this.runSudoNoErrors('snap', 'remove', name); this.log(`Uninstalled Snap package ${packagePath} successfully`); await this.timeout(1000); if (fs.existsSync(packagePath)) { this.error(`Package still exists after uninstall: ${packagePath}`); } } /** * Returns the Linux binary name based on quality. */ private getLinuxBinaryName(): string { switch (this.options.quality) { case 'stable': return 'code'; case 'insider': return 'code-insiders'; case 'exploration': return 'code-exploration'; } } /** * Returns the entry point executable for the VS Code Desktop installation in the specified directory. * @param dir The directory of the VS Code installation. * @returns The path to the entry point executable. */ public getDesktopEntryPoint(dir: string): string { let filePath: string = ''; switch (os.platform()) { case 'darwin': { let appName: string; let binaryName: string; switch (this.options.quality) { case 'stable': appName = 'Visual Studio Code.app'; binaryName = 'Code'; break; case 'insider': appName = 'Visual Studio Code - Insiders.app'; binaryName = 'Code - Insiders'; break; case 'exploration': appName = 'Visual Studio Code - Exploration.app'; binaryName = 'Code - Exploration'; break; } filePath = path.join(dir, appName, 'Contents/MacOS', binaryName); break; } case 'linux': { let binaryName: string; switch (this.options.quality) { case 'stable': binaryName = `code`; break; case 'insider': binaryName = `code-insiders`; break; case 'exploration': binaryName = `code-exploration`; break; } filePath = path.join(dir, binaryName); break; } case 'win32': { let exeName: string; switch (this.options.quality) { case 'stable': exeName = 'Code.exe'; break; case 'insider': exeName = 'Code - Insiders.exe'; break; case 'exploration': exeName = 'Code - Exploration.exe'; break; } filePath = path.join(dir, exeName); break; } } if (!filePath || !fs.existsSync(filePath)) { this.error(`Desktop entry point does not exist: ${filePath}`); } return filePath; } /** * Returns the entry point executable for the VS Code CLI in the specified directory. * @param dir The directory containing unpacked CLI files. * @returns The path to the CLI entry point executable. */ public getCliEntryPoint(dir: string): string { let filename: string; switch (this.options.quality) { case 'stable': filename = 'code'; break; case 'insider': filename = 'code-insiders'; break; case 'exploration': filename = 'code-exploration'; break; } if (os.platform() === 'win32') { filename += '.exe'; } const entryPoint = path.join(dir, filename); if (!fs.existsSync(entryPoint)) { this.error(`CLI entry point does not exist: ${entryPoint}`); } return entryPoint; } /** * Returns the entry point executable for the VS Code server in the specified directory. * @param dir The directory containing unpacked server files. * @param forWsl If true, returns the Linux entry point (for running in WSL on Windows). * @returns The path to the server entry point executable. */ public getServerEntryPoint(dir: string, forWsl = false): string { let filename: string; switch (this.options.quality) { case 'stable': filename = 'code-server'; break; case 'insider': filename = 'code-server-insiders'; break; case 'exploration': filename = 'code-server-exploration'; break; } if (os.platform() === 'win32' && !forWsl) { filename += '.cmd'; } const entryPoint = path.join(this.getFirstSubdirectory(dir), 'bin', filename); if (!fs.existsSync(entryPoint)) { this.error(`Server entry point does not exist: ${entryPoint}`); } return entryPoint; } /** * Returns the first subdirectory within the specified directory. */ public getFirstSubdirectory(dir: string): string { const subDir = fs.readdirSync(dir, { withFileTypes: true }).filter(o => o.isDirectory()).at(0)?.name; if (!subDir) { this.error(`No subdirectories found in directory: ${dir}`); } return path.join(dir, subDir); } /** * Creates a portable data directory in the specified unpacked VS Code directory. * @param dir The directory where VS Code was unpacked. * @returns The path to the created portable data directory. */ public createPortableDataDir(dir: string): string { const dataDir = path.join(dir, os.platform() === 'darwin' ? 'code-portable-data' : 'data'); this.log(`Creating portable data directory: ${dataDir}`); fs.mkdirSync(dataDir, { recursive: true }); this.log(`Created portable data directory: ${dataDir}`); return dataDir; } /** * Launches a web browser for UI testing. * @returns The launched Browser instance. */ public async launchBrowser(): Promise { this.log(`Launching web browser`); const headless = this.options.headlessBrowser; switch (os.platform()) { case 'darwin': { return await webkit.launch({ headless }); } case 'win32': { const executablePath = process.env['PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH'] ?? 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe'; this.log(`Using Chromium executable at: ${executablePath}`); return await chromium.launch({ headless, executablePath }); } case 'linux': default: { const executablePath = process.env['PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH'] ?? '/usr/bin/chromium-browser'; this.log(`Using Chromium executable at: ${executablePath}`); return await chromium.launch({ headless, executablePath, args: [ '--disable-gpu', '--disable-gpu-compositing', '--disable-software-rasterizer', '--no-zygote', ] }); } } } /** * Awaits a page promise and sets the default timeout. * @param pagePromise The promise that resolves to a Page. * @returns The page with the timeout configured. */ public async getPage(pagePromise: Promise): Promise { const page = await pagePromise; page.setDefaultTimeout(3 * 60 * 1000); return page; } /** * Captures a screenshot of the current page if one is active. */ public async captureScreenshot(page: Page) { if (!this.currentTestName) { return; } try { const screenshotDir = this.options.screenshotsDir ?? path.join(this.osTempDir, 'vscode-sanity-screenshots'); fs.mkdirSync(screenshotDir, { recursive: true }); const sanitizedName = this.currentTestName.replace(/[^a-zA-Z0-9_-]/g, '_'); const screenshotPath = path.join(screenshotDir, `${sanitizedName}.png`); await page.screenshot({ path: screenshotPath, fullPage: true }); this.log(`Screenshot saved to: ${screenshotPath}`); } catch (e) { this.warn(`Failed to capture screenshot: ${e instanceof Error ? e.message : String(e)}`); } } /** * Constructs a web server URL with optional token and folder parameters. * @param port The port number of the web server. * @param token The optional authentication token. * @param folder The optional workspace folder path to open. * @returns The constructed web server URL. */ public getWebServerUrl(port: string, token?: string, folder?: string): URL { const url = new URL(`http://localhost:${port}`); if (token) { url.searchParams.set('tkn', token); } if (folder) { folder = folder.replaceAll('\\', '/'); if (!folder.startsWith('/')) { folder = `/${folder}`; } url.searchParams.set('folder', folder); } return url; } /** * Returns the tunnel URL for the VS Code server. * @param baseUrl The base URL for *vscode.dev/tunnel connection. * @param workspaceDir Optional folder path to open * @returns The tunnel URL with folder in pathname. */ public getTunnelUrl(baseUrl: string, workspaceDir?: string): string { const url = new URL(baseUrl); url.searchParams.set('vscode-version', this.options.commit); if (workspaceDir) { let folder = workspaceDir.replaceAll('\\', '/'); if (!folder.startsWith('/')) { folder = `/${folder}`; } url.pathname = url.pathname.replace(/\/+$/, '') + folder; } return url.toString(); } /** * Returns a random alphanumeric token of length 10. */ public getRandomToken(): string { return Array.from({ length: 10 }, () => Math.floor(Math.random() * 36).toString(36)).join(''); } /** * Returns a unique port number, starting from 3010 and incrementing. */ public getUniquePort(): string { return String(this.nextPort++); } /** * Returns the default WSL server extensions directory path. * @returns The path to the extensions directory (e.g., '~/.vscode-server-insiders/extensions'). */ public getWslServerExtensionsDir(): string { let serverDir: string; switch (this.options.quality) { case 'stable': serverDir = '.vscode-server'; break; case 'insider': serverDir = '.vscode-server-insiders'; break; case 'exploration': serverDir = '.vscode-server-exploration'; break; } return `~/${serverDir}/extensions`; } /** * Runs a VS Code command-line application (such as server or CLI). * @param name The name of the app as it will appear in logs. * @param command Command to run. * @param args Arguments for the command. * @param onLine Callback to handle output lines. */ public async runCliApp(name: string, command: string, args: string[], onLine: (text: string) => Promise) { this.log(`Starting ${name} with command line: ${command} ${args.join(' ')}`); const app = spawn(command, args, { shell: /\.(sh|cmd)$/.test(command), detached: !this.capabilities.has('windows'), stdio: ['ignore', 'pipe', 'pipe'] }); try { await new Promise((resolve, reject) => { app.stderr.on('data', (data) => { const text = `[${name}] ${data.toString().trim()}`; if (/ECONNRESET|ECONNABORTED/.test(text)) { this.log(text); } else { reject(new Error(text)); } }); let terminated = false; app.stdout.on('data', (data) => { const text = data.toString().trim(); if (/\berror\b/.test(text)) { reject(new Error(`[${name}] ${text}`)); } for (const line of text.split('\n')) { this.log(`[${name}] ${line}`); onLine(line).then((result) => { if (terminated = !!result) { this.log(`Terminating ${name} process`); resolve(); } }).catch(reject); } }); app.on('error', reject); app.on('exit', (code) => { if (code === 0) { resolve(); } else if (!terminated) { reject(new Error(`[${name}] Exited with code ${code}`)); } }); }); } finally { this.killProcessTree(app.pid!); } } }