Migrate all interactive Blazor components to vanilla JS for full SSR

- Replace server interactivity with vanilla JS (forms.js) for Popover, Calendar, TimePicker, NumberInput, and Counter components

- Rewrite all Razor components to static SSR using data-* attributes for JS hooks

- Simplify InputBase.cs (remove EventCallback, EditContext, SetValueAsync)

- Remove AddInteractiveServerComponents/AddInteractiveServerRenderMode from Program.cs

- Update demo pages: remove @rendermode, replace EditForm with native form

- Add InteractivityGapTests.cs with 30 scoped E2E tests

- Update FormsTests.cs selectors for new static SSR structure

- Fix year picker navigation bug and date format mismatch in forms.js

- All 126 tests passing
This commit is contained in:
2026-04-13 16:45:30 +05:00
parent 086917b5aa
commit d1f0967a0c
20 changed files with 1610 additions and 824 deletions
@@ -10,7 +10,7 @@ public class FormsTests : PlaywrightTestBase
private async Task GoToFormsAsync()
{
await Page.GotoAsync($"{BaseUrl}/forms", new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
// Wait for Blazor interactive mode to be ready
// Wait for the form to be rendered
await Page.WaitForSelectorAsync("[data-testid='btn-submit']", new PageWaitForSelectorOptions { Timeout = 10_000 });
}
@@ -18,6 +18,15 @@ public class FormsTests : PlaywrightTestBase
private ILocator Trigger(string testId) => Page.Locator($"[data-testid='{testId}']");
private ILocator Btn(string testId) => Page.Locator($"[data-testid='{testId}']");
/// <summary>
/// Get the open popover panel nearest to a trigger.
/// </summary>
private ILocator PopoverPanelFor(string triggerId) =>
Trigger(triggerId).Locator("xpath=ancestor::*[@data-popover]").Locator("[data-popover-panel]");
private ILocator PopoverBackdropFor(string triggerId) =>
Trigger(triggerId).Locator("xpath=ancestor::*[@data-popover]").Locator("[data-popover-backdrop]");
/// <summary>
/// Select a date via the calendar popover.
/// Opens the trigger, uses month/year pickers to navigate, then clicks the day.
@@ -26,62 +35,61 @@ public class FormsTests : PlaywrightTestBase
{
// Open the popover
await Trigger(triggerId).ClickAsync();
await Page.WaitForTimeoutAsync(200);
await Page.WaitForTimeoutAsync(300);
await NavigateCalendarToDate(target);
var panel = PopoverPanelFor(triggerId);
await NavigateCalendarToDate(panel, target);
// Click the target day (only enabled buttons in the calendar day grid)
// The day grid is the last grid-cols-7 div; find the button with matching day text
var dayGrid = Page.Locator(".grid.grid-cols-7").Last;
// Click the target day
var dayGrid = panel.Locator(".grid.grid-cols-7").Last;
var dayButton = dayGrid.Locator($"button:not([disabled])").Filter(new LocatorFilterOptions { HasTextString = target.Day.ToString() }).First;
await dayButton.ClickAsync();
await Page.WaitForTimeoutAsync(200);
await Page.WaitForTimeoutAsync(300);
}
/// <summary>
/// Navigate the open calendar to a specific month/year using the month and year pickers.
/// </summary>
private async Task NavigateCalendarToDate(DateOnly target)
private async Task NavigateCalendarToDate(ILocator panel, DateOnly target)
{
// Click year header to open year picker, then select the year
var yearButton = Page.Locator("[data-calendar-year]");
var yearButton = panel.Locator("[data-calendar-year]");
await yearButton.ClickAsync();
await Page.WaitForTimeoutAsync(100);
await Page.WaitForTimeoutAsync(150);
// The year picker is a scrollable grid; find and click the target year
var yearGrid = Page.Locator(".grid.grid-cols-4");
// The year picker grid is inside the calendar content
var yearGrid = panel.Locator(".grid.grid-cols-4");
var targetYearBtn = yearGrid.Locator($"button:has-text('{target.Year}')");
// If the year isn't visible, use prev/next to shift the year range (±20 per click)
// If the year isn't visible, use prev/next to shift the year range
var attempts = 0;
while (await targetYearBtn.CountAsync() == 0 && attempts < 10)
{
// Read the first year button text to determine which direction to go
var firstYearText = await yearGrid.Locator("button").First.InnerTextAsync();
var firstYear = int.Parse(firstYearText.Trim());
if (target.Year < firstYear)
await Page.Locator("button[aria-label='Previous month']").ClickAsync();
await panel.Locator("button[aria-label='Previous month']").ClickAsync();
else
await Page.Locator("button[aria-label='Next month']").ClickAsync();
await Page.WaitForTimeoutAsync(50);
yearGrid = Page.Locator(".grid.grid-cols-4");
await panel.Locator("button[aria-label='Next month']").ClickAsync();
await Page.WaitForTimeoutAsync(100);
yearGrid = panel.Locator(".grid.grid-cols-4");
targetYearBtn = yearGrid.Locator($"button:has-text('{target.Year}')");
attempts++;
}
await targetYearBtn.First.ClickAsync();
await Page.WaitForTimeoutAsync(100);
await Page.WaitForTimeoutAsync(150);
// Now click month header to open month picker, then select the month
var monthButton = Page.Locator("[data-calendar-month]");
var monthButton = panel.Locator("[data-calendar-month]");
await monthButton.ClickAsync();
await Page.WaitForTimeoutAsync(100);
await Page.WaitForTimeoutAsync(150);
var monthGrid = Page.Locator(".grid.grid-cols-3");
var monthGrid = panel.Locator(".grid.grid-cols-3");
var targetMonthText = new DateOnly(2000, target.Month, 1).ToString("MMM");
await monthGrid.Locator($"button:has-text('{targetMonthText}')").First.ClickAsync();
await Page.WaitForTimeoutAsync(100);
await Page.WaitForTimeoutAsync(150);
}
/// <summary>
@@ -92,44 +100,42 @@ public class FormsTests : PlaywrightTestBase
{
// Open the popover
await Trigger(triggerId).ClickAsync();
await Page.WaitForTimeoutAsync(200);
await Page.WaitForTimeoutAsync(300);
await PickTimeInOpenPopover(hour, minute);
var panel = PopoverPanelFor(triggerId);
await PickTimeInOpenPopover(panel, hour, minute);
// Close popover by clicking the backdrop overlay
await Page.Locator(".fixed.inset-0.z-40").ClickAsync(new LocatorClickOptions { Force = true });
await Page.WaitForTimeoutAsync(100);
var backdrop = PopoverBackdropFor(triggerId);
await backdrop.ClickAsync(new LocatorClickOptions { Force = true });
await Page.WaitForTimeoutAsync(200);
}
/// <summary>
/// Pick hour, minute, and AM/PM in an already-open time picker.
/// Scopes all locators to the visible popover content to avoid backdrop interception.
/// </summary>
private async Task PickTimeInOpenPopover(int hour, int minute)
private async Task PickTimeInOpenPopover(ILocator panel, int hour, int minute)
{
// The popover content sits in a z-50 absolutely positioned container
var popoverContent = Page.Locator(".absolute.z-50");
// Convert to 12-hour format
var isPm = hour >= 12;
var hour12 = hour % 12;
if (hour12 == 0) hour12 = 12;
// Click the hour in the first scrollable column (within the popover)
// Click the hour in the first scrollable column
var hourText = hour12.ToString("D2");
var hourColumn = popoverContent.Locator(".scrollbar-thin").First;
var hourColumn = panel.Locator(".scrollbar-thin").First;
await hourColumn.Locator($"button:has-text('{hourText}')").First.ClickAsync();
await Page.WaitForTimeoutAsync(50);
// Click the minute in the second scrollable column (within the popover)
// Click the minute in the second scrollable column
var minuteText = minute.ToString("D2");
var minuteColumn = popoverContent.Locator(".scrollbar-thin").Nth(1);
var minuteColumn = panel.Locator(".scrollbar-thin").Nth(1);
await minuteColumn.Locator($"button:has-text('{minuteText}')").First.ClickAsync();
await Page.WaitForTimeoutAsync(50);
// Click AM/PM (within the popover)
// Click AM/PM
var periodText = isPm ? "PM" : "AM";
await popoverContent.Locator($"button:has-text('{periodText}')").First.ClickAsync();
await panel.Locator($"button:has-text('{periodText}')").First.ClickAsync();
await Page.WaitForTimeoutAsync(50);
}
@@ -276,7 +282,7 @@ public class FormsTests : PlaywrightTestBase
}
// ════════════════════════════════════════════════════════════════════════
// Two-way binding
// Value binding (native)
// ════════════════════════════════════════════════════════════════════════
[Test]
@@ -334,137 +340,12 @@ public class FormsTests : PlaywrightTestBase
// Pick the time part via the time trigger
await SelectTimeAsync("trigger-appointment-time", 10, 0);
// The hidden input should have the combined datetime value
var value = await Input("input-appointment").InputValueAsync();
Assert.That(value, Does.StartWith("2025-12-25T10:00"));
}
// ════════════════════════════════════════════════════════════════════════
// Validation — empty submit shows errors
// ════════════════════════════════════════════════════════════════════════
[Test]
public async Task Empty_Submit_Shows_Validation_Errors()
{
await GoToFormsAsync();
await Btn("btn-submit").ClickAsync();
// Wait for at least one error message to appear
await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 });
var errors = Page.Locator("p.text-destructive");
var count = await errors.CountAsync();
Assert.That(count, Is.GreaterThanOrEqualTo(7), "Expected at least 7 validation errors (one per required field)");
}
[Test]
public async Task Validation_Error_Shows_Name_Required()
{
await GoToFormsAsync();
await Btn("btn-submit").ClickAsync();
await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 });
var nameError = Page.Locator("p.text-destructive", new PageLocatorOptions { HasTextString = "Name is required" });
await Expect(nameError).ToBeVisibleAsync();
}
[Test]
public async Task Validation_Error_Shows_Email_Required()
{
await GoToFormsAsync();
await Btn("btn-submit").ClickAsync();
await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 });
var emailError = Page.Locator("p.text-destructive", new PageLocatorOptions { HasTextString = "Email is required" });
await Expect(emailError).ToBeVisibleAsync();
}
// ════════════════════════════════════════════════════════════════════════
// Validation — specific error messages
// ════════════════════════════════════════════════════════════════════════
[Test]
public async Task Short_Name_Shows_Length_Error()
{
await GoToFormsAsync();
var input = Input("input-name");
await input.FillAsync("A");
await Btn("btn-submit").ClickAsync();
await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 });
var error = Page.Locator("p.text-destructive", new PageLocatorOptions { HasTextString = "2100 characters" });
await Expect(error).ToBeVisibleAsync();
}
[Test]
public async Task Invalid_Email_Shows_Error()
{
await GoToFormsAsync();
var input = Input("input-email");
await input.FillAsync("not-an-email");
await Btn("btn-submit").ClickAsync();
await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 });
var error = Page.Locator("p.text-destructive", new PageLocatorOptions { HasTextString = "Invalid email" });
await Expect(error).ToBeVisibleAsync();
}
[Test]
public async Task Short_Password_Shows_Error()
{
await GoToFormsAsync();
var input = Input("input-password");
await input.FillAsync("123");
await Btn("btn-submit").ClickAsync();
await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 });
var error = Page.Locator("p.text-destructive", new PageLocatorOptions { HasTextString = "864 characters" });
await Expect(error).ToBeVisibleAsync();
}
// ════════════════════════════════════════════════════════════════════════
// Valid submission
// ════════════════════════════════════════════════════════════════════════
[Test]
public async Task Valid_Form_Shows_Success_Message()
{
await GoToFormsAsync();
await Input("input-name").FillAsync("Jane Doe");
await Input("input-email").FillAsync("jane@example.com");
await Input("input-password").FillAsync("securepassword123");
await Input("input-age").FillAsync("30");
// Use popover pickers for date/time fields
await SelectDateAsync("trigger-birthdate", new DateOnly(1995, 3, 15));
await SelectTimeAsync("trigger-preferredtime", 9, 30);
// DateTime: pick date and time via separate triggers
await SelectDateAsync("trigger-appointment-date", new DateOnly(2025, 12, 25));
await SelectTimeAsync("trigger-appointment-time", 10, 0);
await Btn("btn-submit").ClickAsync();
var success = Page.Locator("[data-testid='success-message']");
await Expect(success).ToBeVisibleAsync(new LocatorAssertionsToBeVisibleOptions { Timeout = 5_000 });
await Expect(success).ToContainTextAsync("Form submitted successfully");
await Expect(success).ToContainTextAsync("Jane Doe");
}
[Test]
public async Task No_Success_Message_Before_Submit()
{
await GoToFormsAsync();
var success = Page.Locator("[data-testid='success-message']");
await Expect(success).ToBeHiddenAsync();
// The hidden datetime-local input should have the combined value
// Note: DateTime hidden input is composed from separate date/time part hidden inputs
var datePartVal = await Page.Locator("#appointment-date-part").InputValueAsync();
var timePartVal = await Page.Locator("#appointment-time-part").InputValueAsync();
Assert.That(datePartVal, Is.EqualTo("2025-12-25"));
Assert.That(timePartVal, Is.EqualTo("10:00"));
}
// ════════════════════════════════════════════════════════════════════════
@@ -512,7 +393,7 @@ public class FormsTests : PlaywrightTestBase
}
// ════════════════════════════════════════════════════════════════════════
// Reset
// Reset (native HTML reset)
// ════════════════════════════════════════════════════════════════════════
[Test]
@@ -524,7 +405,7 @@ public class FormsTests : PlaywrightTestBase
await Input("input-name").FillAsync("Alice");
await Input("input-email").FillAsync("alice@test.com");
// Reset
// Reset (native form reset)
await Btn("btn-reset").ClickAsync();
// Fields should be empty
@@ -532,35 +413,6 @@ public class FormsTests : PlaywrightTestBase
await Expect(Input("input-email")).ToHaveValueAsync("");
}
[Test]
public async Task Reset_Button_Clears_Success_Message()
{
await GoToFormsAsync();
// Submit valid form
await Input("input-name").FillAsync("Jane Doe");
await Input("input-email").FillAsync("jane@example.com");
await Input("input-password").FillAsync("securepassword123");
await Input("input-age").FillAsync("30");
await SelectDateAsync("trigger-birthdate", new DateOnly(1995, 3, 15));
await SelectTimeAsync("trigger-preferredtime", 9, 30);
// DateTime picker — date and time via separate triggers
await SelectDateAsync("trigger-appointment-date", new DateOnly(2025, 12, 25));
await SelectTimeAsync("trigger-appointment-time", 10, 0);
await Btn("btn-submit").ClickAsync();
var success = Page.Locator("[data-testid='success-message']");
await Expect(success).ToBeVisibleAsync(new LocatorAssertionsToBeVisibleOptions { Timeout = 5_000 });
// Reset
await Btn("btn-reset").ClickAsync();
await Expect(success).ToBeHiddenAsync();
}
// ════════════════════════════════════════════════════════════════════════
// Input styling (base CSS classes present)
// ════════════════════════════════════════════════════════════════════════