Optimize Playwright functional tests with shared multi-tenant server#19081
Optimize Playwright functional tests with shared multi-tenant server#19081
Conversation
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 |
There was a problem hiding this comment.
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
CmsServerthat 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.
| public static IBrowser Browser => _testFixture?.Browser; | ||
| public static string BaseUrl => _testFixture?.BaseUrl; |
There was a problem hiding this comment.
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).
| _refCount++; | ||
| if (_testFixture is not null) | ||
| { | ||
| if (_initError is not null) | ||
| { | ||
| throw new InvalidOperationException("CmsServer initialization failed previously.", _initError); | ||
| } |
There was a problem hiding this comment.
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.
| } | ||
| catch (Exception ex) | ||
| { | ||
| _testFixture = fixture; // Mark as attempted so we don't retry. |
There was a problem hiding this comment.
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).
| } | |
| 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. | |
| } |
| _refCount--; | ||
| if (_refCount <= 0 && _testFixture is not null) | ||
| { | ||
| await _testFixture.DisposeAsync(); | ||
| _testFixture = null; | ||
| _tenantPrefixes = null; |
There was a problem hiding this comment.
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).
| _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; |
| 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; |
There was a problem hiding this comment.
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.
|
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. |
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:
Verified on all databases running locally:
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.