Другое

Исправление пустого экрана после входа в Blazor Server с помощью Playwright Testing

Решение проблем с пустым экраном после успешного входа при тестировании приложений Blazor Server с помощью Playwright. Узнайте о обработке потока аутентификации и исправлениях инициализации Blazor для надежного E2E-тестирования.

Как можно решить проблему появления пустого экрана после успешного входа в систему при тестировании приложения Blazor Server с помощью Playwright?

У меня есть следующий код для запуска Kestrel-сервера на случайном порту:

csharp
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace Abc;

public sealed class KestrelWebAppFactory : WebApplicationFactory<Program>
{
    protected override IHost CreateHost(IHostBuilder builder)
    {
        // Сначала создаем хост TestServer (требуется WebApplicationFactory)
        var testHost = builder.Build();

        // Перенастраиваем сборщик для использования реального Kestrel-сервера на динамическом порту
        builder.ConfigureWebHost(webHost =>
        {
            webHost.UseKestrel();
            webHost.UseUrls("http://127.0.0.1:0");
        });

        // Создаем и запускаем хост Kestrel
        var kestrelHost = builder.Build();
        kestrelHost.Start();

        // Ждем, пока Kestrel заменит :0 на фактический порт
        var server = kestrelHost.Services.GetRequiredService<IServer>();
        var addressesFeature = server.Features.Get<IServerAddressesFeature>()!;

        // Небольшой цикл ожидания, пока Kestrel опубликует конкретный порт
        var sw = System.Diagnostics.Stopwatch.StartNew();
        string bound = null;
        while (sw.Elapsed < TimeSpan.FromSeconds(5))
        {
            bound = addressesFeature.Addresses.FirstOrDefault(a => !a.EndsWith(":0", StringComparison.Ordinal));
            if (bound is not null) break;
            Thread.Sleep(25);
        }
        if (bound is null)
            throw new InvalidOperationException("Kestrel не опубликовал привязанный адрес.");

        // Указываем HttpClient фабрики на реальный сервер, чтобы Playwright мог его использовать
        ClientOptions.BaseAddress = new Uri(bound);

        // Запускаем и возвращаем хост TestServer (WAF ожидает это)
        testHost.Start();
        return testHost;
    }
}

Затем у меня есть следующий фикстур, который я использую в своих тестах:

csharp
namespace AbcE2ETests;

// Хостит Kestrel-сервер и браузер Playwright для класса тестов.
public class ServerFixture : IAsyncLifetime
{
    private const string PlaywrightContextStoragePath = "/temp/playwrightstate.json";

    public KestrelWebAppFactory Factory { get; private set; }

    public Uri BaseUrl => Factory.ClientOptions.BaseAddress;
    public IPlaywright Playwright { get; private set; }
    public IBrowser Browser { get; private set; }
    public IBrowserContext Context { get; private set; }
    public IPage Page { get; private set; }

    public virtual async Task InitializeAsync()
    {
        Factory = new KestrelWebAppFactory();
        await WaitForServerStartAsync();

        // Инициализируем Playwright и свежий контекст браузера/страницу для этого фикстура
        await SignInAsync();
    }

    public string GetFullUrl(string relativeUrl) => new Uri(BaseUrl, relativeUrl).ToString();

    public async ValueTask EnterValuesByNameAsync(Dictionary<string, string> namesAndValues)
    {
        foreach (var kvp in namesAndValues)
        {
            ILocator input = Page.Locator($"input[name='{kvp.Key}']").First;
            await input.WaitForAsync();
            await input.FocusAsync();
            Task setValueTask = kvp.Value switch
            {
                BrowserConstants.FormValues.Checkbox.Checked => input.CheckAsync(),
                BrowserConstants.FormValues.Checkbox.Unchecked => input.UncheckAsync(),
                _ => input.FillAsync(kvp.Value)
            };
            await setValueTask;
        }
    }

    public async ValueTask PressEnterAsync() =>
        await Page.Keyboard.PressAsync(BrowserConstants.Keys.Enter);

    public async ValueTask NavigateToAsync(string relativeUrl) =>
        await Page.GotoAsync(GetFullUrl(relativeUrl));

    private async Task SignInAsync()
    {
        await InitializePlaywrightAsync(readFromStoragePath: false);
        await NavigateToAsync("/Account/LogIn");
        await EnterValuesByNameAsync(new Dictionary<string, string>
        {
            ["Input.TenantCode"] = "ASL",
            ["Input.Email"] = "mrpmorris@home.com",
            ["Input.Password"] = "SuperSecretPassword"
        });

        await PressEnterAsync();
        await Page.WaitForURLAsync(GetFullUrl("/"));
        await Context.StorageStateAsync(new()
        {
            // относительно папки bin теста
            Path = PlaywrightContextStoragePath
        });
        await DisposePlaywrightAsync();

        await InitializePlaywrightAsync(readFromStoragePath: true);
        await Task.Delay(300_000);
    }

    // Позволяет потомкам настраивать параметры контекста (например, viewport, locale, состояние хранилища)
    protected virtual void ConfigureContextOptions(BrowserNewContextOptions options) { }

    protected async Task WaitForServerStartAsync()
    {
        HttpClient http = Factory.CreateClient();
        var sw = System.Diagnostics.Stopwatch.StartNew();
        while (sw.Elapsed < TimeSpan.FromSeconds(5))
        {
            try
            {
                HttpResponseMessage response = await http.GetAsync(Factory.ClientOptions.BaseAddress);
                if (response.IsSuccessStatusCode)
                    return;
            }
            catch { }
        }
        throw new InvalidOperationException("Сервер не удалось запустить");
    }

    public virtual async Task DisposeAsync()
    {
        await DisposePlaywrightAsync();
        Factory?.Dispose();
        Factory = null;
    }

    private async ValueTask DisposePlaywrightAsync()
    {
        try
        {
            if (Context is not null)
                await Context.CloseAsync();
            if (Browser is not null)
                await Browser.CloseAsync();
        }
        finally
        {
            Playwright?.Dispose();

            Context = null;
            Browser = null;
            Playwright = null;
        }
    }

    private async Task InitializePlaywrightAsync(bool readFromStoragePath)
    {
        Playwright = await Microsoft.Playwright.Playwright.CreateAsync();
        Browser = await Playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
        {
            Headless = false
        });

        var ctxOptions = new BrowserNewContextOptions
        {
            BaseURL = Factory.ClientOptions.BaseAddress.ToString(),
            StorageStatePath = readFromStoragePath ? PlaywrightContextStoragePath : null
        };
        ConfigureContextOptions(ctxOptions);

        Context = await Browser.NewContextAsync(ctxOptions);
        Page = await Context.NewPageAsync();
    }
}

Это отлично работает, за исключением того, что когда вход в систему завершен и я перенаправлен на /, я просто получаю пустой экран.

Проблема пустого экрана после успешного входа при тестировании Blazor Server с Playwright является распространенной проблемой, связанной с потоком аутентификации и поведением рендеринга на стороне сервера в Blazor. Обычно это происходит потому, что Playwright взаимодействует со страницей до полной инициализации Blazor или потому, что состояние аутентификации не поддерживается должным образом между запросами.


Содержание


Общие причины проблемы пустого экрана

Исследования показывают, что проблема пустого экрана после входа в приложения Blazor Server обычно вызвана следующими факторами:

  1. Проблемы с синхронизацией аутентификации: страница перенаправляется до того, как Playwright может правильно захватить состояние аутентификации
  2. Задержки инициализации Blazor: Playwright взаимодействует со страницей до полной инициализации Blazor
  3. Проблемы с состоянием хранилища: файлы cookie или токены аутентификации не сохраняются и не восстанавливаются должным образом
  4. Конфликты рендеринга на стороне сервера: SSR Blazor Server может мешать времени взаимодействия с Playwright

Решения для исправления проблемы пустого экрана

Ожидание инициализации Blazor

Наиболее распространенным решением является явное ожидание инициализации Blazor перед взаимодействием со страницей. Добавьте этот метод в ваш fixture:

csharp
private async Task WaitForBlazorReadyAsync()
{
    // Ожидание готовности Blazor
    await Page.WaitForFunctionAsync(@"() => {
        return window Blazor
            ? window Blazor.state === 'Initialized' 
            : false;
    }");
}

Затем вызывайте этот метод после навигации:

csharp
public async ValueTask NavigateToAsync(string relativeUrl)
{
    await Page.GotoAsync(GetFullUrl(relativeUrl));
    await WaitForBlazorReadyAsync();
}

Улучшение обработки потока аутентификации

Проблема в вашем методе SignInAsync заключается в том, что вы не ожидаете инициализации Blazor после перенаправления при входе. Измените ваш SignInAsync:

csharp
private async Task SignInAsync()
{
    await InitializePlaywrightAsync(readFromStoragePath: false);
    await NavigateToAsync("/Account/LogIn");
    await EnterValuesByNameAsync(new Dictionary<string, string>
    {
        ["Input.TenantCode"] = "ASL",
        ["Input.Email"] = "mrpmorris@home.com",
        ["Input.Password"] = "SuperSecretPassword"
    });

    await PressEnterAsync();
    await Page.WaitForURLAsync(GetFullUrl("/"));
    
    // КРИТИЧЕСКИ ВАЖНО: Ожидание инициализации Blazor после перенаправления
    await WaitForBlazorReadyAsync();
    
    // Дополнительное время для завершения любых асинхронных операций
    await Task.Delay(1000);
    
    await Context.StorageStateAsync(new()
    {
        Path = PlaywrightContextStoragePath
    });
    await DisposePlaywrightAsync();

    await InitializePlaywrightAsync(readFromStoragePath: true);
    await Task.Delay(300_000);
}

Правильная настройка контекста Playwright

Добавьте параметры запуска браузера для более надежной обработки файлов cookie и хранилища:

csharp
private async Task InitializePlaywrightAsync(bool readFromStoragePath)
{
    Playwright = await Microsoft.Playwright.Playwright.CreateAsync();
    Browser = await Playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
    {
        Headless = false,
        Args = new[] { "--disable-blink-features=AutomationControlled" }
    });

    var ctxOptions = new BrowserNewContextOptions
    {
        BaseURL = Factory.ClientOptions.BaseAddress.ToString(),
        StorageStatePath = readFromStoragePath ? PlaywrightContextStoragePath : null,
        IgnoreHTTPSErrors = true,
        ViewportSize = ViewportSize.NoViewport
    };
    
    // Добавление файлов cookie при необходимости
    if (readFromStoragePath)
    {
        ctxOptions.Cookies = await LoadCookiesAsync();
    }
    
    ConfigureContextOptions(ctxOptions);
    Context = await Browser.NewContextAsync(ctxOptions);
    Page = await Context.NewPageAsync();
}

private async Task<List<Cookie>> LoadCookiesAsync()
{
    if (!File.Exists(PlaywrightContextStoragePath))
        return new List<Cookie>();
    
    var json = await File.ReadAllTextAsync(PlaywrightContextStoragePath);
    var state = JsonSerializer.Deserialize<BrowserContextStorageState>(json);
    return state?.Cookies ?? new List<Cookie>();
}

Использование селекторов, специфичных для Blazor

Компоненты Blazor используют разные структуры DOM. Обновите ваш метод EnterValuesByNameAsync для использования селекторов, специфичных для Blazor:

csharp
public async ValueTask EnterValuesByNameAsync(Dictionary<string, string> namesAndValues)
{
    foreach (var kvp in namesAndValues)
    {
        // Попытка нескольких стратегий выбора элементов
        ILocator input = null;
        
        // Сначала пробуем селектор атрибута данных Blazor
        input = Page.Locator($"[data-test-id='{kvp.Key}']");
        if (await input.CountAsync() == 0)
        {
            // Пробуем селектор имени как запасной вариант
            input = Page.Locator($"input[name='{kvp.Key}']");
            if (await input.CountAsync() == 0)
            {
                // Пробуем селектор привязки компонента Blazor
                input = Page.Locator($"input[bind='{kvp.Key}']");
                if (await input.CountAsync() == 0)
                {
                    throw new NotFoundException($"Не удалось найти поле ввода с именем '{kvp.Key}'");
                }
            }
        }
        
        await input.WaitForAsync(new() { State = WaitForSelectorState.Attached });
        await input.FocusAsync();
        
        Task setValueTask = kvp.Value switch
        {
            BrowserConstants.FormValues.Checkbox.Checked => input.CheckAsync(),
            BrowserConstants.FormValues.Checkbox.Unchecked => input.UncheckAsync(),
            _ => input.FillAsync(kvp.Value)
        };
        await setValueTask;
        
        // Инициируем событие изменения
        await input.DispatchEventAsync("input", new() { Type = "input" });
    }
}

Обработка проблем рендеринга на стороне сервера

В Blazor Server могут возникать конфликты SSR. Добавьте этот вспомогательный метод для обработки специфичных для Blazor ожиданий:

csharp
private async Task WaitForBlazorComponentAsync(string componentSelector)
{
    // Ожидание доступности компонента
    await Page.WaitForSelectorAsync(componentSelector, new() 
    { 
        State = WaitForSelectorState.Attached,
        Timeout = 10000 
    });
    
    // Ожидание завершения рендеринга компонента Blazor
    await Page.WaitForFunctionAsync(@"
        () => {
            const element = document.querySelector(arguments[0]);
            return element && 
                   element.__blazorInternalComponentReference !== undefined &&
                   element.__blazorInternalComponentReference.state === 'Rendered';
        }", new object[] { componentSelector });
}

Полный пример реализации

Вот полный класс ServerFixture со всеми улучшениями:

csharp
public class ServerFixture : IAsyncLifetime
{
    private const string PlaywrightContextStoragePath = "/temp/playwrightstate.json";

    public KestrelWebAppFactory Factory { get; private set; }
    public Uri BaseUrl => Factory.ClientOptions.BaseAddress;
    public IPlaywright Playwright { get; private set; }
    public IBrowser Browser { get; private set; }
    public IBrowserContext Context { get; private set; }
    public IPage Page { get; private set; }

    public virtual async Task InitializeAsync()
    {
        Factory = new KestrelWebAppFactory();
        await WaitForServerStartAsync();
        await SignInAsync();
    }

    public string GetFullUrl(string relativeUrl) => new Uri(BaseUrl, relativeUrl).ToString();

    public async ValueTask EnterValuesByNameAsync(Dictionary<string, string> namesAndValues)
    {
        foreach (var kvp in namesAndValues)
        {
            ILocator input = null;
            
            // Пробуем селекторы, специфичные для Blazor
            input = Page.Locator($"[data-test-id='{kvp.Key}']") ?? 
                   Page.Locator($"input[name='{kvp.Key}']") ?? 
                   Page.Locator($"input[bind='{kvp.Key}']");
            
            if (await input.CountAsync() == 0)
                throw new NotFoundException($"Не удалось найти поле ввода с именем '{kvp.Key}'");
            
            await input.WaitForAsync(new() { State = WaitForSelectorState.Attached });
            await input.FocusAsync();
            
            Task setValueTask = kvp.Value switch
            {
                BrowserConstants.FormValues.Checkbox.Checked => input.CheckAsync(),
                BrowserConstants.FormValues.Checkbox.Unchecked => input.UncheckAsync(),
                _ => input.FillAsync(kvp.Value)
            };
            await setValueTask;
            
            await input.DispatchEventAsync("input", new() { Type = "input" });
        }
    }

    public async ValueTask PressEnterAsync() =>
        await Page.Keyboard.PressAsync(BrowserConstants.Keys.Enter);

    public async ValueTask NavigateToAsync(string relativeUrl)
    {
        await Page.GotoAsync(GetFullUrl(relativeUrl));
        await WaitForBlazorReadyAsync();
    }

    private async Task SignInAsync()
    {
        await InitializePlaywrightAsync(readFromStoragePath: false);
        await NavigateToAsync("/Account/LogIn");
        
        // Ожидание готовности формы входа
        await Page.WaitForSelectorAsync("form input[name='Input.Email']");
        
        await EnterValuesByNameAsync(new Dictionary<string, string>
        {
            ["Input.TenantCode"] = "ASL",
            ["Input.Email"] = "mrpmorris@home.com",
            ["Input.Password"] = "SuperSecretPassword"
        });

        await PressEnterAsync();
        await Page.WaitForURLAsync(GetFullUrl("/"));
        
        // КРИТИЧЕСКИ ВАЖНО: Ожидание инициализации Blazor после перенаправления
        await WaitForBlazorReadyAsync();
        
        // Дополнительное время для завершения любых асинхронных операций
        await Task.Delay(1000);
        
        await Context.StorageStateAsync(new()
        {
            Path = PlaywrightContextStoragePath
        });
        await DisposePlaywrightAsync();

        await InitializePlaywrightAsync(readFromStoragePath: true);
        await Task.Delay(300_000);
    }

    private async Task WaitForBlazorReadyAsync()
    {
        await Page.WaitForFunctionAsync(@"() => {
            return window Blazor
                ? window Blazor.state === 'Initialized' 
                : false;
        }", new() { Timeout = 10000 });
    }

    protected virtual void ConfigureContextOptions(BrowserNewContextOptions options) { }

    protected async Task WaitForServerStartAsync()
    {
        HttpClient http = Factory.CreateClient();
        var sw = System.Diagnostics.Stopwatch.StartNew();
        while (sw.Elapsed < TimeSpan.FromSeconds(5))
        {
            try
            {
                HttpResponseMessage response = await http.GetAsync(Factory.ClientOptions.BaseAddress);
                if (response.IsSuccessStatusCode)
                    return;
            }
            catch { }
        }
        throw new InvalidOperationException("Сервер не удалось запустить");
    }

    public virtual async Task DisposeAsync()
    {
        await DisposePlaywrightAsync();
        Factory?.Dispose();
        Factory = null;
    }

    private async ValueTask DisposePlaywrightAsync()
    {
        try
        {
            if (Context is not null)
                await Context.CloseAsync();
            if (Browser is not null)
                await Browser.CloseAsync();
        }
        finally
        {
            Playwright?.Dispose();
            Context = null;
            Browser = null;
            Playwright = null;
        }
    }

    private async Task InitializePlaywrightAsync(bool readFromStoragePath)
    {
        Playwright = await Microsoft.Playwright.Playwright.CreateAsync();
        Browser = await Playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
        {
            Headless = false,
            Args = new[] { "--disable-blink-features=AutomationControlled" }
        });

        var ctxOptions = new BrowserNewContextOptions
        {
            BaseURL = Factory.ClientOptions.BaseAddress.ToString(),
            StorageStatePath = readFromStoragePath ? PlaywrightContextStoragePath : null,
            IgnoreHTTPSErrors = true,
            ViewportSize = ViewportSize.NoViewport
        };
        
        ConfigureContextOptions(ctxOptions);
        Context = await Browser.NewContextAsync(ctxOptions);
        Page = await Context.NewPageAsync();
    }
}

Дополнительные советы по устранению неполадок

  1. Включите режим отладки: запускайте тесты с Headless = false, чтобы видеть, что происходит в браузере
  2. Добавьте логирование: добавьте логирование в консоль для отслеживания изменений состояния аутентификации
  3. Проверьте сетевые запросы: используйте перехват сети Playwright для мониторинга неудачных запросов
  4. Проверьте аутентификацию: выполните аутентификацию вручную и проверьте инструменты разработчика браузера на наличие ошибок
  5. Тестируйте без аутентификации: сначала протестируйте без аутентификации, чтобы убедиться, что базовая функциональность работает

Как указано в документации Microsoft Learn, E2E-тестирование с Playwright для Blazor требует тщательной обработки потоков аутентификации и состояний загрузки страницы.


Источники

  1. How do I test Blazor Server from Playwright? - Stack Overflow
  2. Using Playwright and the WebApplicationFactory To Test Your Blazor Application – Daniel Donbavand
  3. Tutorial Unit and E2E Testing in Blazor - Part 2 Playwright Introduction
  4. End to End Testing using Playwright in Blazor WASM
  5. Test Razor components in ASP.NET Core Blazor | Microsoft Learn
  6. Performing End to End Testing in Blazor with Playwright
  7. GitHub - Blazor web application E2E Testing using Playwright
  8. End-to-End test a Blazor App with Playwright [Part 1]
Авторы
Проверено модерацией
Модерация