Исправление пустого экрана после входа в Blazor Server с помощью Playwright Testing
Решение проблем с пустым экраном после успешного входа при тестировании приложений Blazor Server с помощью Playwright. Узнайте о обработке потока аутентификации и исправлениях инициализации Blazor для надежного E2E-тестирования.
Как можно решить проблему появления пустого экрана после успешного входа в систему при тестировании приложения Blazor Server с помощью Playwright?
У меня есть следующий код для запуска Kestrel-сервера на случайном порту:
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;
}
}
Затем у меня есть следующий фикстур, который я использую в своих тестах:
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 обычно вызвана следующими факторами:
- Проблемы с синхронизацией аутентификации: страница перенаправляется до того, как Playwright может правильно захватить состояние аутентификации
- Задержки инициализации Blazor: Playwright взаимодействует со страницей до полной инициализации Blazor
- Проблемы с состоянием хранилища: файлы cookie или токены аутентификации не сохраняются и не восстанавливаются должным образом
- Конфликты рендеринга на стороне сервера: SSR Blazor Server может мешать времени взаимодействия с Playwright
Решения для исправления проблемы пустого экрана
Ожидание инициализации Blazor
Наиболее распространенным решением является явное ожидание инициализации Blazor перед взаимодействием со страницей. Добавьте этот метод в ваш fixture:
private async Task WaitForBlazorReadyAsync()
{
// Ожидание готовности Blazor
await Page.WaitForFunctionAsync(@"() => {
return window Blazor
? window Blazor.state === 'Initialized'
: false;
}");
}
Затем вызывайте этот метод после навигации:
public async ValueTask NavigateToAsync(string relativeUrl)
{
await Page.GotoAsync(GetFullUrl(relativeUrl));
await WaitForBlazorReadyAsync();
}
Улучшение обработки потока аутентификации
Проблема в вашем методе SignInAsync заключается в том, что вы не ожидаете инициализации Blazor после перенаправления при входе. Измените ваш SignInAsync:
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 и хранилища:
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:
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 ожиданий:
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 со всеми улучшениями:
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();
}
}
Дополнительные советы по устранению неполадок
- Включите режим отладки: запускайте тесты с
Headless = false, чтобы видеть, что происходит в браузере - Добавьте логирование: добавьте логирование в консоль для отслеживания изменений состояния аутентификации
- Проверьте сетевые запросы: используйте перехват сети Playwright для мониторинга неудачных запросов
- Проверьте аутентификацию: выполните аутентификацию вручную и проверьте инструменты разработчика браузера на наличие ошибок
- Тестируйте без аутентификации: сначала протестируйте без аутентификации, чтобы убедиться, что базовая функциональность работает
Как указано в документации Microsoft Learn, E2E-тестирование с Playwright для Blazor требует тщательной обработки потоков аутентификации и состояний загрузки страницы.
Источники
- How do I test Blazor Server from Playwright? - Stack Overflow
- Using Playwright and the WebApplicationFactory To Test Your Blazor Application – Daniel Donbavand
- Tutorial Unit and E2E Testing in Blazor - Part 2 Playwright Introduction
- End to End Testing using Playwright in Blazor WASM
- Test Razor components in ASP.NET Core Blazor | Microsoft Learn
- Performing End to End Testing in Blazor with Playwright
- GitHub - Blazor web application E2E Testing using Playwright
- End-to-End test a Blazor App with Playwright [Part 1]