File: Services\PlaywrightService.cs
Web Access
Project: src\src\Razor\src\Razor\test\Microsoft.VisualStudioCode.Razor.IntegrationTests\Microsoft.VisualStudioCode.Razor.IntegrationTests.csproj (Microsoft.VisualStudioCode.Razor.IntegrationTests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Microsoft.Playwright;
 
namespace Microsoft.VisualStudioCode.Razor.IntegrationTests.Services;
 
using Playwright = Playwright.Playwright;
 
/// <summary>
/// Manages the Playwright browser connection to VS Code via Chrome DevTools Protocol.
/// </summary>
public class PlaywrightService(IntegrationTestServices testServices) : ServiceBase(testServices)
{
    private IPlaywright? _playwright;
    private IBrowser? _browser;
    private IBrowserContext? _context;
    private IPage? _page;
    private int _screenshotCounter = 0;
 
    /// <summary>
    /// The Playwright page connected to VS Code.
    /// </summary>
    public IPage Page => _page ?? throw new InvalidOperationException("Playwright not connected. Call ConnectAsync first.");
 
    /// <summary>
    /// Takes a screenshot of the VS Code window and saves it to the screenshots directory.
    /// </summary>
    /// <param name="name">A descriptive name for the screenshot (will be sanitized for filename).</param>
    /// <returns>The path to the saved screenshot.</returns>
    public async Task<string> TakeScreenshotAsync(string name)
    {
        if (_page == null)
        {
            TestServices.Logger.Log("Cannot take screenshot - page not connected");
            return string.Empty;
        }
 
        var screenshotsDir = TestServices.Settings.ScreenshotsDir;
        if (string.IsNullOrEmpty(screenshotsDir))
        {
            TestServices.Logger.Log("Cannot take screenshot - ScreenshotsDir not configured");
            return string.Empty;
        }
 
        try
        {
            Directory.CreateDirectory(screenshotsDir);
 
            // Sanitize the name for use in a filename
            var sanitizedName = string.Join("_", name.Split(Path.GetInvalidFileNameChars()));
            var timestamp = DateTime.Now.ToString("HHmmss");
            var filename = $"{++_screenshotCounter:D3}_{timestamp}_{sanitizedName}.png";
            var filepath = Path.Combine(screenshotsDir, filename);
 
            await _page.ScreenshotAsync(new PageScreenshotOptions
            {
                Path = filepath,
                FullPage = true
            });
 
            TestServices.Logger.Log($"Screenshot saved: {filepath}");
            return filepath;
        }
        catch (Exception ex)
        {
            TestServices.Logger.Log($"Failed to take screenshot: {ex.Message}");
            return string.Empty;
        }
    }
 
    /// <summary>
    /// Initializes Playwright and ensures browsers are installed.
    /// </summary>
    public async Task InitializeAsync()
    {
        // Ensure Chromium browser is installed (similar to VS Code auto-install pattern)
        TestServices.Logger.Log("Ensuring Playwright browsers are installed...");
        var exitCode = Microsoft.Playwright.Program.Main(["install", "chromium"]);
        if (exitCode != 0)
        {
            throw new InvalidOperationException($"Playwright browser installation failed with exit code {exitCode}");
        }
 
        _playwright = await Playwright.CreateAsync();
    }
 
    /// <summary>
    /// Connects to VS Code via Chrome DevTools Protocol.
    /// </summary>
    /// <param name="port">The remote debugging port.</param>
    /// <param name="workspaceName">The workspace folder name to find.</param>
    public async Task ConnectAsync(int port, string workspaceName)
    {
        var cdpUrl = $"http://localhost:{port}";
        TestServices.Logger.Log($"Connecting to VS Code via CDP: {cdpUrl}");
 
        var retries = 10; // Increased retries for Linux/CI
        var retryDelay = 3000; // 3 seconds between retries
 
        while (retries > 0)
        {
            try
            {
                TestServices.Logger.Log($"CDP connection attempt ({11 - retries}/10)...");
 
                // Add timeout to the CDP connection
                using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
                var connectTask = _playwright!.Chromium.ConnectOverCDPAsync(cdpUrl);
                var timeoutTask = Task.Delay(Timeout.Infinite, cts.Token);
 
                var completedTask = await Task.WhenAny(connectTask, timeoutTask);
                if (completedTask == timeoutTask)
                {
                    throw new TimeoutException("CDP connection timed out after 30 seconds");
                }
 
                _browser = await connectTask;
                TestServices.Logger.Log("CDP connection established, looking for workspace page...");
 
                // Find the page that has the workspace open (look for the workbench with our folder)
                _page = await FindWorkspacePageAsync(workspaceName);
 
                if (_page != null)
                {
                    _context = _page.Context;
                    TestServices.Logger.Log("Connected to VS Code workspace window successfully");
                    return;
                }
 
                // Fallback to first available page if we couldn't find the workspace
                _context = _browser.Contexts.FirstOrDefault() ?? await _browser.NewContextAsync();
                _page = _context.Pages.FirstOrDefault() ?? await _context.NewPageAsync();
                TestServices.Logger.Log("Connected to VS Code (fallback to first page)");
                return;
            }
            catch (Exception ex)
            {
                TestServices.Logger.Log($"Failed to connect (attempt {11 - retries}), retrying... ({ex.GetType().Name}: {ex.Message})");
                await Task.Delay(retryDelay);
                retries--;
            }
        }
 
        throw new InvalidOperationException("Failed to connect to VS Code via CDP after multiple retries");
    }
 
    private async Task<IPage?> FindWorkspacePageAsync(string workspaceName)
    {
        TestServices.Logger.Log($"Looking for workspace page with folder: {workspaceName}");
 
        var pagesWithWorkbench = new List<IPage>();
        foreach (var context in _browser!.Contexts)
        {
            foreach (var page in context.Pages)
            {
                try
                {
                    // Check if this page has the VS Code workbench
                    var workbenchLocator = page.Locator(".monaco-workbench");
                    if (await workbenchLocator.CountAsync() == 0)
                    {
                        continue;
                    }
 
                    pagesWithWorkbench.Add(page);
 
                    // Check if the title or explorer contains our workspace name
                    var title = await page.TitleAsync();
                    TestServices.Logger.Log($"Found VS Code page with title: {title}");
 
                    if (title.Contains(workspaceName, StringComparison.OrdinalIgnoreCase))
                    {
                        TestServices.Logger.Log("Matched workspace by title");
                        return page;
                    }
 
                    // Also check for explorer view showing the folder (use First since multiple elements match)
                    var explorerTitleLocator = page.Locator(".explorer-folders-view .monaco-icon-label");
                    if (await explorerTitleLocator.CountAsync() > 0)
                    {
                        var explorerText = await explorerTitleLocator.First.TextContentAsync();
                        TestServices.Logger.Log($"Explorer shows: {explorerText}");
                        if (explorerText?.Contains(workspaceName, StringComparison.OrdinalIgnoreCase) == true)
                        {
                            TestServices.Logger.Log("Matched workspace by explorer");
                            return page;
                        }
                    }
                }
                catch (Exception ex)
                {
                    TestServices.Logger.Log($"Error checking page: {ex.Message}");
                }
            }
        }
 
        if (pagesWithWorkbench.Count == 1)
        {
            TestServices.Logger.Log("Only one VS Code page found, using it");
            return pagesWithWorkbench[0];
        }
 
        TestServices.Logger.Log($"Found {pagesWithWorkbench.Count} VS Code pages, could not determine correct one");
        return null;
    }
 
    /// <summary>
    /// Disposes the Playwright resources.
    /// </summary>
    public async Task DisposeAsync()
    {
        // VS Code may have already closed (crashed or exited early).
        // Wrap each close in try-catch to ensure we clean up as much as possible.
        if (_page != null)
        {
            try
            {
                await _page.CloseAsync();
            }
            catch (PlaywrightException)
            {
                // Page already closed or connection lost, ignore
            }
 
            _page = null;
        }
 
        if (_context != null)
        {
            try
            {
                await _context.CloseAsync();
            }
            catch (PlaywrightException)
            {
                // Context already closed or connection lost, ignore
            }
 
            _context = null;
        }
 
        if (_browser != null)
        {
            try
            {
                await _browser.CloseAsync();
            }
            catch (PlaywrightException)
            {
                // Browser already closed or connection lost, ignore
            }
 
            _browser = null;
        }
 
        _playwright?.Dispose();
        _playwright = null;
    }
}