Skip to content

Optimize Playwright functional tests with shared multi-tenant server#19081

Draft
Skrypt wants to merge 1 commit intomainfrom
skrypt/playwright-optimize
Draft

Optimize Playwright functional tests with shared multi-tenant server#19081
Skrypt wants to merge 1 commit intomainfrom
skrypt/playwright-optimize

Conversation

@Skrypt
Copy link
Copy Markdown
Contributor

@Skrypt Skrypt commented Mar 28, 2026

Replace 6 separate CMS server instances (one per recipe fixture) with a single shared server using SaaS multi-tenancy. Each recipe (Blog, Agency, etc.) is now a child tenant with its own URL prefix, eliminating redundant server startups, database creations, and browser-based setup.

Key changes:

  • Add static CmsServer that starts one CMS host with AutoSetup and creates all recipe tenants upfront during initialization
  • Configure OrchardCore.AutoSetup via builder.Configuration so the Default tenant is provisioned on first request without browser form filling
  • Convert EnsureCleanDatabase to async (EnsureCleanDatabaseAsync) and extract it from ConfigureServices into the async StartCmsAsync call path
  • Update all CMS test classes to navigate via tenant prefix (/{prefix}/)
  • Refactor SaasFixture to reuse the shared server for its parent tenant

Verified on all databases running locally:

Database Tests Result Duration
SQLite 12/12 Passed 29s
PostgreSQL 12/12 Passed 31s
MySQL 12/12 Passed 42s
SQL Server 12/12 Passed 31s

PR execution time for all databases: 4m 26s

Main branch execution time for all databases: 4m 8s

Comments

To be honest I'm pushing this as a draft because I want to see if we can optimize this to be faster but I don't think it will be making a huge difference. I need to compare with what I merged already.

So actually these changes were supposed to be an optimization for perf and they end up taking more time than what I originally did.

Replace 6 separate CMS server instances (one per recipe fixture) with a single
shared server using SaaS multi-tenancy. Each recipe (Blog, Agency, etc.) is now
a child tenant with its own URL prefix, eliminating redundant server startups,
database creations, and browser-based setup.

Key changes:
- Add static CmsServer that starts one CMS host with AutoSetup and creates all
  recipe tenants upfront during initialization
- Configure OrchardCore.AutoSetup via builder.Configuration so the Default
  tenant is provisioned on first request without browser form filling
- Convert EnsureCleanDatabase to async (EnsureCleanDatabaseAsync) and extract
  it from ConfigureServices into the async StartCmsAsync call path
- Update all CMS test classes to navigate via tenant prefix (/{prefix}/)
- Refactor SaasFixture to reuse the shared server for its parent tenant

Verified on all databases:

| Database | Tests | Result | Duration |
|----------|-------|--------|----------|
| SQLite | 12/12 | **Passed** | 29s |
| PostgreSQL | 12/12 | **Passed** | 31s |
| MySQL | 12/12 | **Passed** | 42s |
| SQL Server | 12/12 | **Passed** | 31s |
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR refactors the Playwright functional CMS recipe tests to reuse a single shared CMS host by enabling SaaS multi-tenancy, creating each recipe as a child tenant accessed via a URL prefix to reduce redundant server/database setup work.

Changes:

  • Introduces a static shared CmsServer that starts one CMS host (AutoSetup SaaS) and provisions recipe tenants up front.
  • Updates recipe test fixtures/tests to navigate and authenticate via /{tenantPrefix}.
  • Moves external DB cleanup into an async pre-host-build path and wires AutoSetup configuration through builder.Configuration.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
test/OrchardCore.Tests.Functional/Tests/Cms/SaasFixture.cs Switches SaaS tests to use the shared CmsServer lifecycle and browser/server.
test/OrchardCore.Tests.Functional/Tests/Cms/MigrationsTests.cs Uses the recipe-tenant URL prefix for navigation and admin auth.
test/OrchardCore.Tests.Functional/Tests/Cms/HeadlessTests.cs Uses the recipe-tenant URL prefix for navigation and admin auth.
test/OrchardCore.Tests.Functional/Tests/Cms/ComingSoonTests.cs Uses the recipe-tenant URL prefix for navigation and admin auth.
test/OrchardCore.Tests.Functional/Tests/Cms/CmsServerFixture.cs Adds the shared static CMS server initializer + tenant provisioning logic.
test/OrchardCore.Tests.Functional/Tests/Cms/CmsRecipeFixture.cs Refactors recipe fixtures to acquire/release the shared server and expose Prefix.
test/OrchardCore.Tests.Functional/Tests/Cms/BlogTests.cs Uses the recipe-tenant URL prefix for navigation and admin auth.
test/OrchardCore.Tests.Functional/Tests/Cms/AgencyTests.cs Uses the recipe-tenant URL prefix for navigation and admin auth.
test/OrchardCore.Tests.Functional/Helpers/OrchardTestServer.cs Adds AutoSetup configuration support and async DB cleanup prior to host build.
test/OrchardCore.Tests.Functional/Helpers/OrchardTestFixture.cs Passes the new AutoSetup recipe option into StartCmsAsync.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +25 to +26
public static IBrowser Browser => _testFixture?.Browser;
public static string BaseUrl => _testFixture?.BaseUrl;
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Browser and BaseUrl are declared non-nullable but can currently return null when AcquireAsync() hasn’t run (or after ReleaseAsync()), which can lead to confusing NREs in consumers. Consider throwing a clear InvalidOperationException when _testFixture is null (or make these properties nullable and update call sites accordingly).

Copilot uses AI. Check for mistakes.
Comment on lines +41 to +47
_refCount++;
if (_testFixture is not null)
{
if (_initError is not null)
{
throw new InvalidOperationException("CmsServer initialization failed previously.", _initError);
}
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AcquireAsync() increments _refCount before checking whether initialization previously failed. If _initError is set, the method throws but the ref-count stays incremented, which can prevent the server from ever being disposed. Suggest checking _initError before incrementing, or decrementing _refCount on the error path.

Copilot uses AI. Check for mistakes.
Comment on lines +58 to +61
}
catch (Exception ex)
{
_testFixture = fixture; // Mark as attempted so we don't retry.
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When fixture.InitializeAsync() throws, the catch block stores _testFixture/_initError but never disposes the partially created OrchardTestFixture, leaking Playwright/browser/server resources. It’d be safer to await fixture.DisposeAsync() in the catch (and also reset/clear _initError when a subsequent successful initialization occurs).

Suggested change
}
catch (Exception ex)
{
_testFixture = fixture; // Mark as attempted so we don't retry.
// Clear any previous initialization error on successful initialization.
_initError = null;
}
catch (Exception ex)
{
try
{
await fixture.DisposeAsync();
}
catch
{
// Ignore disposal exceptions to preserve the original initialization error.
}

Copilot uses AI. Check for mistakes.
Comment on lines +121 to +126
_refCount--;
if (_refCount <= 0 && _testFixture is not null)
{
await _testFixture.DisposeAsync();
_testFixture = null;
_tenantPrefixes = null;
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ReleaseAsync() disposes the shared fixture but doesn’t reset _initError/_refCount. If initialization previously failed and the fixture is later released, a subsequent successful initialization would still leave _initError set and cause all future AcquireAsync() calls to throw. Consider clearing _initError when tearing down (and optionally guarding against _refCount going negative).

Suggested change
_refCount--;
if (_refCount <= 0 && _testFixture is not null)
{
await _testFixture.DisposeAsync();
_testFixture = null;
_tenantPrefixes = null;
if (_refCount > 0)
{
_refCount--;
}
else
{
_refCount = 0;
}
if (_refCount == 0 && _testFixture is not null)
{
await _testFixture.DisposeAsync();
_testFixture = null;
_tenantPrefixes = null;
_initError = null;

Copilot uses AI. Check for mistakes.
Comment on lines +232 to +240
builder.Configuration["OrchardCore:OrchardCore_AutoSetup:Tenants:0:ShellName"] = "Default";
builder.Configuration["OrchardCore:OrchardCore_AutoSetup:Tenants:0:SiteName"] = $"Testing {autoSetupRecipe}";
builder.Configuration["OrchardCore:OrchardCore_AutoSetup:Tenants:0:SiteTimeZone"] = "America/New_York";
builder.Configuration["OrchardCore:OrchardCore_AutoSetup:Tenants:0:AdminUsername"] = config.Username;
builder.Configuration["OrchardCore:OrchardCore_AutoSetup:Tenants:0:AdminEmail"] = config.Email;
builder.Configuration["OrchardCore:OrchardCore_AutoSetup:Tenants:0:AdminPassword"] = config.Password;
builder.Configuration["OrchardCore:OrchardCore_AutoSetup:Tenants:0:DatabaseProvider"] = dbProvider;
builder.Configuration["OrchardCore:OrchardCore_AutoSetup:Tenants:0:DatabaseConnectionString"] = connString;
builder.Configuration["OrchardCore:OrchardCore_AutoSetup:Tenants:0:RecipeName"] = autoSetupRecipe;
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AutoSetup sets SiteTimeZone to a hard-coded IANA ID ("America/New_York"). This can be brittle across environments (e.g., differing time zone ID support/config) and it isn’t required for these tests to be location-specific. Consider using a more universally supported value like "UTC" (or deriving from TimeZoneInfo.Utc.Id) to reduce setup fragility.

Copilot uses AI. Check for mistakes.
@Piedone
Copy link
Copy Markdown
Member

Piedone commented Mar 28, 2026

I don't recommend this. UI tests, as unit tests, are best to run from a completely clean state, so they don't interfere with each other, causing false positives or it being unclear which recipe/test a given issue corresponds to. Also, using sub-tenants for every test can mask issues of using them with a single Default tenant, which is the primary use case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants