import { spawnSync, SpawnSyncReturns } from 'child_process';
import { createHash } from 'crypto';
import fs from 'fs';
import fetch, { Response } from 'node-fetch';
import os from 'os';
import path from 'path';
import { Browser, chromium, webkit } from 'playwright';
interface ITargetMetadata {
url: string;
name: string;
version: string;
productVersion: string;
hash: string;
timestamp: number;
sha256hash: string;
supportsFastUpdate: boolean;
}
export class TestContext {
private static readonly authenticodeInclude = /^.+\.(exe|dll|sys|cab|cat|msi|jar|ocx|ps1|psm1|psd1|ps1xml|pssc1)$/i;
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)$/;
private readonly tempDirs = new Set<string>();
private readonly logFile: string;
public constructor(
public readonly quality: 'stable' | 'insider' | 'exploration',
public readonly commit: string,
public readonly verbose: boolean,
) {
const osTempDir = fs.realpathSync(os.tmpdir());
const logDir = fs.mkdtempSync(path.join(osTempDir, 'vscode-sanity-log'));
this.logFile = path.join(logDir, 'sanity.log');
console.log(`Log file: ${this.logFile}`);
}
public get platform(): string {
return `${os.platform()}-${os.arch()}`;
}
public log(message: string) {
const line = `[${new Date().toISOString()}] ${message}\n`;
fs.appendFileSync(this.logFile, line);
if (this.verbose) {
console.log(line.trimEnd());
}
}
public error(message: string): never {
const line = `[${new Date().toISOString()}] ERROR: ${message}\n`;
fs.appendFileSync(this.logFile, line);
console.error(line.trimEnd());
throw new Error(message);
}
public createTempDir(): string {
const osTempDir = fs.realpathSync(os.tmpdir());
const tempDir = fs.mkdtempSync(path.join(osTempDir, 'vscode-sanity'));
this.log(`Created temp directory: ${tempDir}`);
this.tempDirs.add(tempDir);
return tempDir;
}
public ensureDirExists(filePath: string) {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
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.log(`Failed to delete temp directory: ${dir}: ${error}`);
}
}
this.tempDirs.clear();
}
public async fetchNoErrors(url: string): Promise<Response & { body: NodeJS.ReadableStream }> {
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.log(`Retrying fetch (attempt ${attempt + 1}/${maxRetries}) after ${delay}ms`);
await new Promise(resolve => setTimeout(resolve, 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.log(`Fetch attempt ${attempt + 1} failed: ${lastError.message}`);
}
}
this.error(`Failed to fetch ${url} after ${maxRetries} attempts: ${lastError?.message}`);
}
public async fetchMetadata(target: string): Promise<ITargetMetadata> {
const url = `https://update.code.visualstudio.com/api/versions/commit:${this.commit}/${target}/${this.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;
}
public async downloadTarget(target: string): Promise<string> {
const { url, sha256hash } = await this.fetchMetadata(target);
const filePath = path.join(this.createTempDir(), path.basename(url));
this.log(`Downloading ${url} to ${filePath}`);
const { body } = await this.fetchNoErrors(url);
const stream = fs.createWriteStream(filePath);
await new Promise<void>((resolve, reject) => {
body.on('error', reject);
stream.on('error', reject);
stream.on('finish', resolve);
body.pipe(stream);
});
this.log(`Downloaded ${url} to ${filePath}`);
this.validateSha256Hash(filePath, sha256hash);
if (TestContext.authenticodeInclude.test(filePath) && os.platform() === 'win32') {
this.validateAuthenticodeSignature(filePath);
}
return filePath;
}
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}`);
}
}
public validateAuthenticodeSignature(filePath: string) {
this.log(`Validating Authenticode signature for ${filePath}`);
const result = this.run('powershell', '-Command', `Get-AuthenticodeSignature "${filePath}" | Select-Object -ExpandProperty Status`);
if (result.error !== undefined) {
this.error(`Failed to run Get-AuthenticodeSignature: ${result.error.message}`);
}
const status = result.stdout.trim();
if (status !== 'Valid') {
this.error(`Authenticode signature is not valid for ${filePath}: ${status}`);
}
}
public validateAllAuthenticodeSignatures(dir: string) {
const files = fs.readdirSync(dir, { withFileTypes: true });
for (const file of files) {
const filePath = path.join(dir, file.name);
if (file.isDirectory()) {
this.validateAllAuthenticodeSignatures(filePath);
} else if (TestContext.authenticodeInclude.test(file.name)) {
this.validateAuthenticodeSignature(filePath);
}
}
}
public validateCodesignSignature(filePath: string) {
this.log(`Validating codesign signature for ${filePath}`);
const result = this.run('codesign', '--verify', '--deep', '--strict', 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}`);
}
}
public validateAllCodesignSignatures(dir: string) {
const files = fs.readdirSync(dir, { withFileTypes: true });
for (const file of files) {
const filePath = path.join(dir, file.name);
if (TestContext.codesignExclude.test(filePath)) {
this.log(`Skipping codesign validation for excluded file: ${filePath}`);
} else if (file.isDirectory()) {
if (file.name.endsWith('.app') || file.name.endsWith('.framework')) {
this.validateCodesignSignature(filePath);
} else {
this.validateAllCodesignSignatures(filePath);
}
} else if (this.isMachOBinary(filePath)) {
this.validateCodesignSignature(filePath);
}
}
}
private isMachOBinary(filePath: string): boolean {
try {
const fd = fs.openSync(filePath, 'r');
const buffer = Buffer.alloc(4);
fs.readSync(fd, buffer, 0, 4, 0);
fs.closeSync(fd);
const magic = buffer.readUInt32BE(0);
return magic === 0xFEEDFACE || magic === 0xCEFAEDFE ||
magic === 0xFEEDFACF || magic === 0xCFFAEDFE ||
magic === 0xCAFEBABE || magic === 0xBEBAFECA;
} catch {
return false;
}
}
public async downloadAndUnpack(target: string): Promise<string> {
const filePath = await this.downloadTarget(target);
return this.unpackArchive(filePath);
}
public unpackArchive(archivePath: string): string {
const dir = this.createTempDir();
this.log(`Unpacking ${archivePath} to ${dir}`);
this.runNoErrors('tar', '-xzf', archivePath, '-C', dir);
this.log(`Unpacked ${archivePath} to ${dir}`);
return dir;
}
public run(command: string, ...args: string[]): SpawnSyncReturns<string> {
this.log(`Running command: ${command} ${args.join(' ')}`);
return spawnSync(command, args, { encoding: 'utf-8' }) as SpawnSyncReturns<string>;
}
public runNoErrors(command: string, ...args: string[]): SpawnSyncReturns<string> {
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;
}
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}`);
}
private getWindowsInstallDir(type: 'user' | 'system'): string {
let parentDir: string;
if (type === 'system') {
parentDir = process.env['PROGRAMFILES'] || '';
} else {
parentDir = path.join(process.env['LOCALAPPDATA'] || '', 'Programs');
}
switch (this.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');
}
}
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.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;
}
public async uninstallWindowsApp(type: 'user' | 'system'): Promise<void> {
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 new Promise(resolve => setTimeout(resolve, 2000));
if (fs.existsSync(appDir)) {
this.error(`Installation directory still exists after uninstall: ${appDir}`);
}
}
public installMacApp(bundleDir: string): string {
let appName: string;
switch (this.quality) {
case 'stable':
appName = 'Visual Studio Code.app';
break;
case 'insider':
appName = 'Visual Studio Code - Insiders.app';
break;
case 'exploration':
appName = 'Visual Studio Code - Exploration.app';
break;
}
const entryPoint = path.join(bundleDir, appName, 'Contents/MacOS/Electron');
if (!fs.existsSync(entryPoint)) {
this.error(`Desktop entry point does not exist: ${entryPoint}`);
}
this.log(`VS Code executable at: ${entryPoint}`);
return entryPoint;
}
public installRpm(packagePath: string): string {
this.log(`Installing ${packagePath} using RPM package manager`);
this.runNoErrors('sudo', 'rpm', '-i', packagePath);
this.log(`Installed ${packagePath} successfully`);
const entryPoint = this.getEntryPoint('desktop', '/usr/bin');
this.log(`Installed VS Code executable at: ${entryPoint}`);
return entryPoint;
}
public installDeb(packagePath: string): string {
this.log(`Installing ${packagePath} using DEB package manager`);
this.runNoErrors('sudo', 'dpkg', '-i', packagePath);
this.log(`Installed ${packagePath} successfully`);
const entryPoint = this.getEntryPoint('desktop', '/usr/bin');
this.log(`Installed VS Code executable at: ${entryPoint}`);
return entryPoint;
}
public installSnap(packagePath: string): string {
this.log(`Installing ${packagePath} using Snap package manager`);
this.runNoErrors('sudo', 'snap', 'install', packagePath, '--classic', '--dangerous');
this.log(`Installed ${packagePath} successfully`);
const entryPoint = this.getEntryPoint('desktop', '/snap/bin');
this.log(`Installed VS Code executable at: ${entryPoint}`);
return entryPoint;
}
public getEntryPoint(type: 'cli' | 'desktop', dir: string): string {
let suffix: string;
switch (this.quality) {
case 'stable':
suffix = type === 'cli' ? '' : '';
break;
case 'insider':
suffix = type === 'cli' ? '-insiders' : ' - Insiders';
break;
case 'exploration':
suffix = type === 'cli' ? '-exploration' : ' - Exploration';
break;
}
const extension = os.platform() === 'win32' ? '.exe' : '';
const filePath = path.join(dir, `code${suffix}${extension}`);
if (!fs.existsSync(filePath)) {
this.error(`CLI entry point does not exist: ${filePath}`);
}
return filePath;
}
public getServerEntryPoint(dir: string): string {
const serverDir = fs.readdirSync(dir, { withFileTypes: true }).filter(o => o.isDirectory()).at(0)?.name;
if (!serverDir) {
this.error(`No subdirectories found in server directory: ${dir}`);
}
let filename: string;
switch (this.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') {
filename += '.cmd';
}
const entryPoint = path.join(dir, serverDir, 'bin', filename);
if (!fs.existsSync(entryPoint)) {
this.error(`Server entry point does not exist: ${entryPoint}`);
}
return entryPoint;
}
public getTunnelUrl(baseUrl: string): string {
const url = new URL(baseUrl);
url.searchParams.set('vscode-version', this.commit);
return url.toString();
}
public async launchBrowser(): Promise<Browser> {
this.log(`Launching web browser`);
switch (os.platform()) {
case 'darwin':
return await webkit.launch({ headless: false });
case 'win32':
return await chromium.launch({ channel: 'msedge', headless: false });
default:
return await chromium.launch({ channel: 'chrome', headless: false });
}
}
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;
}
public getRandomToken(): string {
return Array.from({ length: 10 }, () => Math.floor(Math.random() * 36).toString(36)).join('');
}
public getRandomPort(): string {
return String(Math.floor(Math.random() * 7000) + 3000);
}
}