diff --git a/test/sanity/src/cli.test.ts b/test/sanity/src/cli.test.ts index 1b574c75b3d..c7bde580826 100644 --- a/test/sanity/src/cli.test.ts +++ b/test/sanity/src/cli.test.ts @@ -92,47 +92,39 @@ export function setup(context: TestContext) { process.chdir(workspaceDir); context.log(`Changed current directory to: ${workspaceDir}`); - const cliDataDir = context.createTempDir(); - const userDataDir = context.createTempDir(); - const serverDataDir = context.createTempDir(); - const extensionsDir = context.createTempDir(); const args = [ - '--cli-data-dir', cliDataDir, - '--user-data-dir', userDataDir, + '--cli-data-dir', context.createTempDir(), + '--user-data-dir', context.createTempDir(), 'tunnel', '--accept-server-license-terms', - '--server-data-dir', serverDataDir, - '--extensions-dir', extensionsDir, + '--server-data-dir', context.createTempDir(), + '--extensions-dir', context.createTempDir(), ]; - context.log(`Running CLI ${entryPoint} with args: ${args.join(' ')}`); - const cli = spawn(entryPoint, args); + context.log(`Running CLI ${entryPoint} with args ${args.join(' ')}`); + const cli = spawn(entryPoint, args, { detached: true }); cli.stderr.on('data', (data) => { context.error(`[CLI Error] ${data.toString().trim()}`); }); - let tunnelUrl: string | undefined = undefined; cli.stdout.on('data', (data) => { - const text = data.toString(); - text.trim().split('\n').forEach((line: string) => { + const text = data.toString().trim(); + text.split('\n').forEach((line: string) => { context.log(`[CLI Output] ${line}`); }); - const match = /Open this link in your browser (https:\/\/.+)/.exec(text); + const match = /Using GitHub for authentication/.exec(text); if (match !== null) { - tunnelUrl = context.getTunnelUrl(match[1]); - context.log(`Tunnel URL: ${tunnelUrl}`); - cli.kill(); + context.log(`CLI started successfully and is waiting for authentication`); + context.killProcessTree(cli.pid!); } }); await new Promise((resolve, reject) => { cli.on('error', reject); - cli.on('exit', () => resolve()); + cli.on('exit', resolve); }); - - assert.ok(tunnelUrl, 'Expected to receive a tunnel URL from the CLI'); } }); } diff --git a/test/sanity/src/context.ts b/test/sanity/src/context.ts index 6228aefb0cb..31741c3223d 100644 --- a/test/sanity/src/context.ts +++ b/test/sanity/src/context.ts @@ -9,6 +9,7 @@ import fs from 'fs'; import fetch, { Response } from 'node-fetch'; import os from 'os'; import path from 'path'; +import { Browser, chromium } from 'playwright'; /** * Response from https://update.code.visualstudio.com/api/versions/commit:// @@ -282,6 +283,43 @@ export class TestContext { return result; } + /** + * 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['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'); + } + } + /** * Installs a Microsoft Installer package silently. * @param installerPath The path to the installer executable. @@ -292,22 +330,17 @@ export class TestContext { this.runNoErrors(installerPath, '/silent', '/mergetasks=!runcode'); this.log(`Installed ${installerPath} successfully`); - const varName = type === 'system' ? 'PROGRAMFILES' : 'LOCALAPPDATA'; - const parentDir = process.env[varName]; - if (parentDir === undefined) { - this.error(`Environment variable ${varName} is not defined`); - } - + const appDir = this.getWindowsInstallDir(type); let entryPoint: string; switch (this.quality) { case 'stable': - entryPoint = path.join(parentDir, 'Microsoft VS Code', 'Code.exe'); + entryPoint = path.join(appDir, 'Code.exe'); break; case 'insider': - entryPoint = path.join(parentDir, 'Microsoft VS Code Insiders', 'Code - Insiders.exe'); + entryPoint = path.join(appDir, 'Code - Insiders.exe'); break; case 'exploration': - entryPoint = path.join(parentDir, 'Microsoft VS Code Exploration', 'Code - Exploration.exe'); + entryPoint = path.join(appDir, 'Code - Exploration.exe'); break; } @@ -319,6 +352,27 @@ export class TestContext { return entryPoint; } + /** + * Uninstalls a Windows application silently. + * @param type The type of installation ('user' or 'system'). + */ + public async uninstallWindowsApp(type: 'user' | 'system'): Promise { + 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}`); + } + } + /** * Prepares a macOS .app bundle for execution by removing the quarantine attribute. * @param bundleDir The directory containing the .app bundle. @@ -426,6 +480,42 @@ export class TestContext { return filePath; } + /** + * Returns the entry point executable for the VS Code server in the specified directory. + * @param dir The directory containing unpacked server files. + * @returns The path to the server entry point executable. + */ + 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; + } + /** * Returns the tunnel URL for the VS Code server including vscode-version parameter. * @param baseUrl The base URL for the VS Code server. @@ -436,4 +526,14 @@ export class TestContext { url.searchParams.set('vscode-version', this.commit); return url.toString(); } + + /** + * Launches a web browser for UI testing. + * @returns The launched Browser instance. + */ + public async launchBrowser(): Promise { + this.log(`Launching web browser`); + const channel = os.platform() === 'win32' ? 'msedge' : 'chrome'; + return await chromium.launch({ channel, headless: false }); + } } diff --git a/test/sanity/src/desktop.test.ts b/test/sanity/src/desktop.test.ts index 4c5cd544e8f..20c1d7c1f47 100644 --- a/test/sanity/src/desktop.test.ts +++ b/test/sanity/src/desktop.test.ts @@ -3,16 +3,15 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import assert from 'assert'; -import fs from 'fs'; import path from 'path'; import { _electron } from 'playwright'; import { TestContext } from './context'; +import { UITest } from './uiTest'; export function setup(context: TestContext) { describe('Desktop', () => { if (context.platform === 'darwin-x64') { - it('darwin', async () => { + it('desktop-darwin', async () => { const dir = await context.downloadAndUnpack('darwin'); const entryPoint = context.installMacApp(dir); await testDesktopApp(entryPoint); @@ -20,7 +19,7 @@ export function setup(context: TestContext) { } if (context.platform === 'darwin-arm64') { - it('darwin-arm64', async () => { + it('desktop-darwin-arm64', async () => { const dir = await context.downloadAndUnpack('darwin-arm64'); const entryPoint = context.installMacApp(dir); await testDesktopApp(entryPoint); @@ -28,7 +27,7 @@ export function setup(context: TestContext) { } if (context.platform.startsWith('darwin-')) { - it('darwin-universal', async () => { + it('desktop-darwin-universal', async () => { const dir = await context.downloadAndUnpack('darwin-universal'); const entryPoint = context.installMacApp(dir); await testDesktopApp(entryPoint); @@ -36,7 +35,7 @@ export function setup(context: TestContext) { } if (context.platform === 'linux-arm64') { - it('linux-arm64', async () => { + it('desktop-linux-arm64', async () => { const dir = await context.downloadAndUnpack('linux-arm64'); const entryPoint = context.getEntryPoint('desktop', dir); await testDesktopApp(entryPoint); @@ -44,7 +43,7 @@ export function setup(context: TestContext) { } if (context.platform === 'linux-arm') { - it('linux-armhf', async () => { + it('desktop-linux-armhf', async () => { const dir = await context.downloadAndUnpack('linux-armhf'); const entryPoint = context.getEntryPoint('desktop', dir); await testDesktopApp(entryPoint); @@ -52,7 +51,7 @@ export function setup(context: TestContext) { } if (context.platform === 'linux-arm64') { - it('linux-deb-arm64', async () => { + it('desktop-linux-deb-arm64', async () => { const packagePath = await context.downloadTarget('linux-deb-arm64'); const entryPoint = context.installDeb(packagePath); await testDesktopApp(entryPoint); @@ -60,7 +59,7 @@ export function setup(context: TestContext) { } if (context.platform === 'linux-arm') { - it('linux-deb-armhf', async () => { + it('desktop-linux-deb-armhf', async () => { const packagePath = await context.downloadTarget('linux-deb-armhf'); const entryPoint = context.installDeb(packagePath); await testDesktopApp(entryPoint); @@ -68,7 +67,7 @@ export function setup(context: TestContext) { } if (context.platform === 'linux-x64') { - it('linux-deb-x64', async () => { + it('desktop-linux-deb-x64', async () => { const packagePath = await context.downloadTarget('linux-deb-x64'); const entryPoint = context.installDeb(packagePath); await testDesktopApp(entryPoint); @@ -76,7 +75,7 @@ export function setup(context: TestContext) { } if (context.platform === 'linux-arm64') { - it('linux-rpm-arm64', async () => { + it('desktop-linux-rpm-arm64', async () => { const packagePath = await context.downloadTarget('linux-rpm-arm64'); const entryPoint = context.installRpm(packagePath); await testDesktopApp(entryPoint); @@ -84,7 +83,7 @@ export function setup(context: TestContext) { } if (context.platform === 'linux-arm') { - it('linux-rpm-armhf', async () => { + it('desktop-linux-rpm-armhf', async () => { const packagePath = await context.downloadTarget('linux-rpm-armhf'); const entryPoint = context.installRpm(packagePath); await testDesktopApp(entryPoint); @@ -92,7 +91,7 @@ export function setup(context: TestContext) { } if (context.platform === 'linux-x64') { - it('linux-rpm-x64', async () => { + it('desktop-linux-rpm-x64', async () => { const packagePath = await context.downloadTarget('linux-rpm-x64'); const entryPoint = context.installRpm(packagePath); await testDesktopApp(entryPoint); @@ -100,7 +99,7 @@ export function setup(context: TestContext) { } if (context.platform === 'linux-x64') { - it('linux-snap-x64', async () => { + it('desktop-linux-snap-x64', async () => { const packagePath = await context.downloadTarget('linux-snap-x64'); const entryPoint = context.installSnap(packagePath); await testDesktopApp(entryPoint); @@ -108,7 +107,7 @@ export function setup(context: TestContext) { } if (context.platform === 'linux-x64') { - it('linux-x64', async () => { + it('desktop-linux-x64', async () => { const dir = await context.downloadAndUnpack('linux-x64'); const entryPoint = context.getEntryPoint('desktop', dir); await testDesktopApp(entryPoint); @@ -116,17 +115,18 @@ export function setup(context: TestContext) { } if (context.platform === 'win32-arm64') { - it('win32-arm64', async () => { + it('desktop-win32-arm64', async () => { const packagePath = await context.downloadTarget('win32-arm64'); context.validateSignature(packagePath); const entryPoint = context.installWindowsApp('system', packagePath); context.validateAllSignatures(path.dirname(entryPoint)); await testDesktopApp(entryPoint); + await context.uninstallWindowsApp('system'); }); } if (context.platform === 'win32-arm64') { - it('win32-arm64-archive', async () => { + it('desktop-win32-arm64-archive', async () => { const dir = await context.downloadAndUnpack('win32-arm64-archive'); context.validateAllSignatures(dir); const entryPoint = context.getEntryPoint('desktop', dir); @@ -135,27 +135,29 @@ export function setup(context: TestContext) { } if (context.platform === 'win32-arm64') { - it('win32-arm64-user', async () => { + it('desktop-win32-arm64-user', async () => { const packagePath = await context.downloadTarget('win32-arm64-user'); context.validateSignature(packagePath); const entryPoint = context.installWindowsApp('user', packagePath); context.validateAllSignatures(path.dirname(entryPoint)); await testDesktopApp(entryPoint); + await context.uninstallWindowsApp('user'); }); } if (context.platform === 'win32-x64') { - it('win32-x64', async () => { + it('desktop-win32-x64', async () => { const packagePath = await context.downloadTarget('win32-x64'); context.validateSignature(packagePath); const entryPoint = context.installWindowsApp('system', packagePath); context.validateAllSignatures(path.dirname(entryPoint)); await testDesktopApp(entryPoint); + await context.uninstallWindowsApp('system'); }); } if (context.platform === 'win32-x64') { - it('win32-x64-archive', async () => { + it('desktop-win32-x64-archive', async () => { const dir = await context.downloadAndUnpack('win32-x64-archive'); context.validateAllSignatures(dir); const entryPoint = context.getEntryPoint('desktop', dir); @@ -164,73 +166,34 @@ export function setup(context: TestContext) { } if (context.platform === 'win32-x64') { - it('win32-x64-user', async () => { + it('desktop-win32-x64-user', async () => { const packagePath = await context.downloadTarget('win32-x64-user'); context.validateSignature(packagePath); const entryPoint = context.installWindowsApp('user', packagePath); context.validateAllSignatures(path.dirname(entryPoint)); await testDesktopApp(entryPoint); + await context.uninstallWindowsApp('user'); }); } - async function testDesktopApp(executablePath: string) { - const extensionsDir = context.createTempDir(); - const dataDir = context.createTempDir(); - const workspaceDir = context.createTempDir(); - const args = ['--extensions-dir', extensionsDir, '--user-data-dir', dataDir, workspaceDir]; + async function testDesktopApp(entryPoint: string) { + const test = new UITest(context); + const args = [ + '--extensions-dir', test.extensionsDir, + '--user-data-dir', test.userDataDir, + test.workspaceDir + ]; - context.log(`Start VS Code: ${executablePath} with args: ${args.join(' ')}`); - const app = await _electron.launch({ executablePath, args }); + context.log(`Starting VS Code ${entryPoint} with args ${args.join(' ')}`); + const app = await _electron.launch({ executablePath: entryPoint, args }); const window = await app.firstWindow(); - context.log('Dismiss workspace trust dialog'); - await window.getByText('Yes, I trust the authors').click(); + await test.run(window); - context.log('Focus Explorer view'); - await window.keyboard.press('Control+Shift+E'); - - context.log('Click New File button'); - await window.getByLabel('New File...').click(); - - context.log('Type file name'); - await window.locator('input').fill('helloWorld.txt'); - await window.keyboard.press('Enter'); - - context.log('Focus the code editor'); - await window.getByText(/Start typing/).focus(); - - context.log('Type some content into the file'); - await window.keyboard.type('Hello, World!'); - - context.log('Save the file'); - await window.keyboard.press('Control+S'); - - context.log('Open Extensions view'); - await window.keyboard.press('Control+Shift+X'); - await window.waitForSelector('.extension-list-item'); - - context.log('Type extension name to search for'); - await window.keyboard.type('GitHub Pull Requests'); - await new Promise(resolve => setTimeout(resolve, 2000)); - - context.log('Click Install on the first extension in the list'); - await window.locator('.extension-action:not(.disabled)', { hasText: /Install/ }).first().click(); - - context.log('Wait for extension to be installed'); - await window.locator('.extension-action:not(.disabled)', { hasText: /Uninstall/ }).waitFor(); - - context.log('Close the application'); + context.log('Closing the application'); await app.close(); - context.log('Verify file contents'); - const filePath = `${workspaceDir}/helloWorld.txt`; - const fileContents = fs.readFileSync(filePath, 'utf-8'); - assert.strictEqual(fileContents, 'Hello, World!'); - - context.log('Verify extension is installed'); - const extensions = fs.readdirSync(extensionsDir); - const hasExtension = extensions.some(ext => ext.startsWith('github.vscode-pull-request-github')); - assert.strictEqual(hasExtension, true); + test.validate(); } }); } diff --git a/test/sanity/src/server.test.ts b/test/sanity/src/server.test.ts index a79eded4994..b07375800d8 100644 --- a/test/sanity/src/server.test.ts +++ b/test/sanity/src/server.test.ts @@ -3,49 +3,65 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import assert from 'assert'; +import { spawn } from 'child_process'; import { TestContext } from './context'; export function setup(context: TestContext) { describe('Server', () => { if (context.platform === 'linux-arm64') { it('server-alpine-arm64', async () => { - await context.downloadAndUnpack('server-alpine-arm64'); + const dir = await context.downloadAndUnpack('server-alpine-arm64'); + const entryPoint = context.getServerEntryPoint(dir); + await testServer(entryPoint); }); } if (context.platform === 'linux-x64') { it('server-alpine-x64', async () => { - await context.downloadAndUnpack('server-linux-alpine'); + const dir = await context.downloadAndUnpack('server-linux-alpine'); + const entryPoint = context.getServerEntryPoint(dir); + await testServer(entryPoint); }); } if (context.platform === 'darwin-arm64') { it('server-darwin-arm64', async () => { - await context.downloadAndUnpack('server-darwin-arm64'); + const dir = await context.downloadAndUnpack('server-darwin-arm64'); + const entryPoint = context.getServerEntryPoint(dir); + await testServer(entryPoint); }); } if (context.platform === 'darwin-x64') { it('server-darwin-x64', async () => { - await context.downloadAndUnpack('server-darwin'); + const dir = await context.downloadAndUnpack('server-darwin'); + const entryPoint = context.getServerEntryPoint(dir); + await testServer(entryPoint); }); } if (context.platform === 'linux-arm64') { it('server-linux-arm64', async () => { - await context.downloadAndUnpack('server-linux-arm64'); + const dir = await context.downloadAndUnpack('server-linux-arm64'); + const entryPoint = context.getServerEntryPoint(dir); + await testServer(entryPoint); }); } if (context.platform === 'linux-arm') { it('server-linux-armhf', async () => { - await context.downloadAndUnpack('server-linux-armhf'); + const dir = await context.downloadAndUnpack('server-linux-armhf'); + const entryPoint = context.getServerEntryPoint(dir); + await testServer(entryPoint); }); } if (context.platform === 'linux-x64') { it('server-linux-x64', async () => { - await context.downloadAndUnpack('server-linux-x64'); + const dir = await context.downloadAndUnpack('server-linux-x64'); + const entryPoint = context.getServerEntryPoint(dir); + await testServer(entryPoint); }); } @@ -53,6 +69,8 @@ export function setup(context: TestContext) { it('server-win32-arm64', async () => { const dir = await context.downloadAndUnpack('server-win32-arm64'); context.validateAllSignatures(dir); + const entryPoint = context.getServerEntryPoint(dir); + await testServer(entryPoint); }); } @@ -60,6 +78,48 @@ export function setup(context: TestContext) { it('server-win32-x64', async () => { const dir = await context.downloadAndUnpack('server-win32-x64'); context.validateAllSignatures(dir); + const entryPoint = context.getServerEntryPoint(dir); + await testServer(entryPoint); + }); + } + + async function testServer(entryPoint: string) { + const args = ['--accept-server-license-terms', '--connection-token', '12345']; + + context.log(`Starting server ${entryPoint} with args ${args.join(' ')}`); + const server = spawn(entryPoint, args, { shell: true }); + + server.stderr.on('data', (data) => { + context.error(`[Server Error] ${data.toString().trim()}`); + }); + + server.stdout.on('data', (data) => { + const text = data.toString().trim(); + text.split('\n').forEach((line: string) => { + context.log(`[Server Output] ${line}`); + }); + + const port = /Extension host agent listening on (\d+)/.exec(text)?.[1]; + if (port) { + (async function () { + try { + const url = `http://localhost:${port}/version`; + context.log(`Fetching ${url}`); + const response = await fetch(url); + assert.equal(response.status, 200); + assert.equal(await response.text(), context.commit); + } catch (error) { + assert.fail(error instanceof Error ? error.message : String(error)); + } finally { + context.killProcessTree(server.pid!); + } + })(); + } + }); + + await new Promise((resolve, reject) => { + server.on('error', reject); + server.on('exit', resolve); }); } }); diff --git a/test/sanity/src/serverWeb.test.ts b/test/sanity/src/serverWeb.test.ts index 369fe90eb70..f711bf4dac4 100644 --- a/test/sanity/src/serverWeb.test.ts +++ b/test/sanity/src/serverWeb.test.ts @@ -3,63 +3,147 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import assert from 'assert'; +import { spawn } from 'child_process'; +import path from 'path'; import { TestContext } from './context'; +import { UITest } from './uiTest'; export function setup(context: TestContext) { describe('Server Web', () => { if (context.platform === 'linux-arm64') { - it('server-alpine-arm64-web', async () => { - await context.downloadAndUnpack('server-alpine-arm64-web'); + it('server-web-alpine-arm64', async () => { + const dir = await context.downloadAndUnpack('server-alpine-arm64-web'); + const entryPoint = context.getServerEntryPoint(dir); + await testServer(entryPoint); }); } if (context.platform === 'linux-x64') { - it('server-alpine-x64-web', async () => { - await context.downloadAndUnpack('server-linux-alpine-web'); + it('server-web-alpine-x64', async () => { + const dir = await context.downloadAndUnpack('server-linux-alpine-web'); + const entryPoint = context.getServerEntryPoint(dir); + await testServer(entryPoint); }); } if (context.platform === 'darwin-arm64') { - it('server-darwin-arm64-web', async () => { - await context.downloadAndUnpack('server-darwin-arm64-web'); + it('server-web-darwin-arm64', async () => { + const dir = await context.downloadAndUnpack('server-darwin-arm64-web'); + const entryPoint = context.getServerEntryPoint(dir); + await testServer(entryPoint); }); } if (context.platform === 'darwin-x64') { - it('server-darwin-x64-web', async () => { - await context.downloadAndUnpack('server-darwin-web'); + it('server-web-darwin-x64', async () => { + const dir = await context.downloadAndUnpack('server-darwin-web'); + const entryPoint = context.getServerEntryPoint(dir); + await testServer(entryPoint); }); } if (context.platform === 'linux-arm64') { - it('server-linux-arm64-web', async () => { - await context.downloadAndUnpack('server-linux-arm64-web'); + it('server-web-linux-arm64', async () => { + const dir = await context.downloadAndUnpack('server-linux-arm64-web'); + const entryPoint = context.getServerEntryPoint(dir); + await testServer(entryPoint); }); } if (context.platform === 'linux-arm') { - it('server-linux-armhf-web', async () => { - await context.downloadAndUnpack('server-linux-armhf-web'); + it('server-web-linux-armhf', async () => { + const dir = await context.downloadAndUnpack('server-linux-armhf-web'); + const entryPoint = context.getServerEntryPoint(dir); + await testServer(entryPoint); }); } if (context.platform === 'linux-x64') { - it('server-linux-x64-web', async () => { - await context.downloadAndUnpack('server-linux-x64-web'); + it('server-web-linux-x64', async () => { + const dir = await context.downloadAndUnpack('server-linux-x64-web'); + const entryPoint = context.getServerEntryPoint(dir); + await testServer(entryPoint); }); } if (context.platform === 'win32-arm64') { - it('server-win32-arm64-web', async () => { + it('server-web-win32-arm64', async () => { const dir = await context.downloadAndUnpack('server-win32-arm64-web'); context.validateAllSignatures(dir); + const entryPoint = context.getServerEntryPoint(dir); + await testServer(entryPoint); }); } if (context.platform === 'win32-x64') { - it('server-win32-x64-web', async () => { + it('server-web-win32-x64', async () => { const dir = await context.downloadAndUnpack('server-win32-x64-web'); context.validateAllSignatures(dir); + const entryPoint = context.getServerEntryPoint(dir); + await testServer(entryPoint); + }); + } + + async function testServer(entryPoint: string) { + const token = '12345'; + const test = new UITest(context); + const args = [ + '--accept-server-license-terms', + '--connection-token', token, + '--server-data-dir', context.createTempDir(), + '--extensions-dir', test.extensionsDir, + '--user-data-dir', test.userDataDir + ]; + + context.log(`Starting server ${entryPoint} with args ${args.join(' ')}`); + const server = spawn(entryPoint, args, { shell: true }); + + server.stderr.on('data', (data) => { + context.error(`[Server Error] ${data.toString().trim()}`); + }); + + server.stdout.on('data', (data) => { + const text = data.toString().trim(); + text.split('\n').forEach((line: string) => { + context.log(`[Server Output] ${line}`); + }); + + const port = /Extension host agent listening on (\d+)/.exec(text)?.[1]; + if (port) { + (async function () { + try { + const browser = await context.launchBrowser(); + const page = await browser.newPage(); + + const url = `http://localhost:${port}?tkn=${token}&folder=/${test.workspaceDir.replaceAll(path.sep, '/')}`; + context.log(`Navigating to ${url}`); + await page.goto(url, { waitUntil: 'networkidle' }); + + context.log('Waiting for the workbench to load'); + await page.waitForSelector('.monaco-workbench'); + + context.log('Verifying page title contains "Visual Studio Code"'); + assert.match(await page.title(), /Visual Studio Code/); + + await test.run(page); + + context.log('Closing browser'); + await browser.close(); + + test.validate(); + } catch (error) { + assert.fail(error instanceof Error ? error.message : String(error)); + } finally { + context.killProcessTree(server.pid!); + } + })(); + } + }); + + await new Promise((resolve, reject) => { + server.on('error', reject); + server.on('exit', resolve); }); } }); diff --git a/test/sanity/src/uiTest.ts b/test/sanity/src/uiTest.ts new file mode 100644 index 00000000000..c79127c4178 --- /dev/null +++ b/test/sanity/src/uiTest.ts @@ -0,0 +1,132 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import fs from 'fs'; +import { Page } from 'playwright'; +import { TestContext } from './context'; + +/** + * UI Test helper class to perform common UI actions and verifications. + */ +export class UITest { + private _extensionsDir: string | undefined; + private _workspaceDir: string | undefined; + private _userDataDir: string | undefined; + + constructor(private readonly context: TestContext) { + } + + /** + * The directory where extensions are installed. + */ + public get extensionsDir(): string { + return this._extensionsDir ??= this.context.createTempDir(); + } + + /** + * The workspace directory used for testing. + */ + public get workspaceDir(): string { + return this._workspaceDir ??= this.context.createTempDir(); + } + + /** + * The user data directory used for testing. + */ + public get userDataDir(): string { + return this._userDataDir ??= this.context.createTempDir(); + } + + /** + * Run the UI test actions. + */ + public async run(page: Page) { + await this.dismissWorkspaceTrustDialog(page); + await this.createTextFile(page); + await this.installExtension(page); + } + + /** + * Validate the results of the UI test actions. + */ + public async validate() { + this.verifyTextFileCreated(); + this.verifyExtensionInstalled(); + } + + /** + * Dismiss the workspace trust dialog. + */ + private async dismissWorkspaceTrustDialog(page: Page) { + this.context.log('Dismissing workspace trust dialog'); + await page.getByText('Yes, I trust the authors').click(); + await page.waitForTimeout(500); + } + + /** + * Create a new text file in the editor with some content and save it. + */ + private async createTextFile(page: Page) { + this.context.log('Focusing Explorer view'); + await page.keyboard.press('Control+Shift+E'); + + this.context.log('Clicking New File button'); + await page.getByLabel('New File...').click(); + + this.context.log('Typing file name'); + await page.locator('input').fill('helloWorld.txt'); + await page.keyboard.press('Enter'); + + this.context.log('Focusing the code editor'); + await page.getByText(/Start typing/).focus(); + + this.context.log('Typing some content into the file'); + await page.keyboard.type('Hello, World!'); + + this.context.log('Saving the file'); + await page.keyboard.press('Control+S'); + await page.waitForTimeout(1000); + } + + /** + * Verify that the text file was created with the expected content. + */ + private verifyTextFileCreated() { + this.context.log('Verifying file contents'); + const filePath = `${this.workspaceDir}/helloWorld.txt`; + const fileContents = fs.readFileSync(filePath, 'utf-8'); + assert.strictEqual(fileContents, 'Hello, World!'); + } + + /** + * Install GitHub Pull Requests extension from the Extensions view. + */ + private async installExtension(page: Page) { + this.context.log('Opening Extensions view'); + await page.keyboard.press('Control+Shift+X'); + await page.waitForSelector('.extension-list-item'); + + this.context.log('Typing extension name to search for'); + await page.keyboard.type('GitHub Pull Requests'); + await page.waitForTimeout(2000); + + this.context.log('Clicking Install on the first extension in the list'); + await page.locator('.extension-action:not(.disabled)', { hasText: /Install/ }).first().click(); + + this.context.log('Waiting for extension to be installed'); + await page.locator('.extension-action:not(.disabled)', { hasText: /Uninstall/ }).waitFor(); + } + + /** + * Verify that the GitHub Pull Requests extension is installed. + */ + private verifyExtensionInstalled() { + this.context.log('Verifying extension is installed'); + const extensions = fs.readdirSync(this.extensionsDir); + const hasExtension = extensions.some(ext => ext.startsWith('github.vscode-pull-request-github')); + assert.strictEqual(hasExtension, true); + } +}