diff --git a/.github/skills/component-development/SKILL.md b/.github/skills/component-development/SKILL.md index 8afe75da..331d5381 100644 --- a/.github/skills/component-development/SKILL.md +++ b/.github/skills/component-development/SKILL.md @@ -25,9 +25,13 @@ This skill covers creating new Blazor components that emulate ASP.NET Web Forms - `BaseStyledComponent` - Components with styling - `DataBoundComponent` - Data-bound components 5. **Add unit tests** in `src/BlazorWebFormsComponents.Test/{ComponentName}/` -6. **Add sample page** in `samples/AfterBlazorServerSide/Pages/ControlSamples/` -7. **Create documentation** in `docs/{Category}/{ComponentName}.md` -8. **Update `mkdocs.yml`** and `README.md` +6. **Add sample page** in `samples/AfterBlazorServerSide/Components/Pages/ControlSamples/{ComponentName}/` +7. **Add integration tests** using Playwright in `samples/AfterBlazorServerSide.Tests/` +8. **Create documentation** in `docs/{Category}/{ComponentName}.md` +9. **Update navigation:** + - Add to `samples/AfterBlazorServerSide/Components/Layout/NavMenu.razor` (TreeView) + - Add to `samples/AfterBlazorServerSide/Components/Pages/ComponentList.razor` (home page catalog) + - Update `mkdocs.yml` and `README.md` ### Base Class Selection @@ -54,3 +58,47 @@ Prefix with `On`: - `OnCommand` for command events - `OnSelectedIndexChanged` for selection changes - `OnDataBinding` for data binding events + +### Integration Testing Requirements + +Every component must have integration tests in `samples/AfterBlazorServerSide.Tests/` using Playwright: + +1. **Page load test** in `ControlSampleTests.cs`: + - Add route to the appropriate `[Theory]` test (EditorControl, DataControl, etc.) + - Verifies page loads without console errors or page errors + +2. **Interactive test** in `InteractiveComponentTests.cs` (for interactive components): + - Test user interactions (clicks, input, selection changes) + - Verify component responds correctly to user actions + - Assert no console errors during interaction + +Example page load test entry: +```csharp +[Theory] +[InlineData("/ControlSamples/YourComponent")] +public async Task EditorControl_Loads_WithoutErrors(string path) +``` + +Example interactive test: +```csharp +[Fact] +public async Task YourComponent_Interaction_Works() +{ + var page = await _fixture.NewPageAsync(); + try + { + await page.GotoAsync($"{_fixture.BaseUrl}/ControlSamples/YourComponent"); + // Test interactions... + // Assert expected behavior... + } + finally + { + await page.CloseAsync(); + } +} +``` + +Run integration tests with: +```bash +dotnet test samples/AfterBlazorServerSide.Tests +``` diff --git a/.github/skills/documentation/SKILL.md b/.github/skills/documentation/SKILL.md index 179d8d0c..490ba961 100644 --- a/.github/skills/documentation/SKILL.md +++ b/.github/skills/documentation/SKILL.md @@ -689,7 +689,6 @@ samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Label/Index.razor # 6. Update README.md - [Label](docs/EditorControls/Label.md) ``` - ## Quality Checklist Before submitting documentation: @@ -704,6 +703,13 @@ Before submitting documentation: - [ ] Spell-checked Before submitting sample pages: +- [ ] Includes both demo and source code sections +- [ ] Code block exactly matches the demo +- [ ] HTML entities properly encoded in code block +- [ ] `@` symbols doubled in code block +- [ ] Includes complete `@code` block with all handlers +- [ ] Brief description explains what sample demonstrates +- [ ] Sample is accessible from navigation or component list - [ ] Created in correct folder: `ControlSamples/[ComponentName]/` - [ ] Follows naming convention (Index.razor for main sample) - [ ] Includes `@page` directive with correct route diff --git a/.github/workflows/demo.yml b/.github/workflows/demo.yml new file mode 100644 index 00000000..eac70e9f --- /dev/null +++ b/.github/workflows/demo.yml @@ -0,0 +1,63 @@ +name: Build Demo Sites + +on: + push: + branches: + - 'main' + - 'v*' + paths: + - 'src/**' + - 'samples/**' + - '.github/workflows/demo.yml' + pull_request: + branches: + - 'main' + - 'v*' + paths: + - 'src/**' + - 'samples/**' + - '.github/workflows/demo.yml' + workflow_run: + workflows: ["Integration Tests"] + types: + - completed + +jobs: + build-demos: + runs-on: ubuntu-latest + # Only run if integration tests passed (or if triggered directly without workflow_run) + if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore dependencies + run: | + dotnet restore samples/AfterBlazorServerSide/AfterBlazorServerSide.csproj + dotnet restore samples/AfterBlazorClientSide/AfterBlazorClientSide.csproj + + - name: Publish Server-Side Demo + run: dotnet publish samples/AfterBlazorServerSide/AfterBlazorServerSide.csproj --configuration Release --output ./publish/server-side + + - name: Publish Client-Side Demo (WebAssembly) + run: dotnet publish samples/AfterBlazorClientSide/AfterBlazorClientSide.csproj --configuration Release --output ./publish/client-side + + - name: Upload Server-Side Demo artifact + uses: actions/upload-artifact@v4 + with: + name: demo-server-side + path: ./publish/server-side + + - name: Upload Client-Side Demo artifact + uses: actions/upload-artifact@v4 + with: + name: demo-client-side + path: ./publish/client-side diff --git a/samples/AfterBlazorClientSide/AfterBlazorClientSide.csproj b/samples/AfterBlazorClientSide/AfterBlazorClientSide.csproj index c987d85b..505d347b 100644 --- a/samples/AfterBlazorClientSide/AfterBlazorClientSide.csproj +++ b/samples/AfterBlazorClientSide/AfterBlazorClientSide.csproj @@ -15,6 +15,9 @@ Layout\%(RecursiveDir)%(Filename)%(Extension) + + PreserveNewest + diff --git a/samples/AfterBlazorServerSide.Tests/ControlSampleTests.cs b/samples/AfterBlazorServerSide.Tests/ControlSampleTests.cs index a0a6675f..b6b9be0f 100644 --- a/samples/AfterBlazorServerSide.Tests/ControlSampleTests.cs +++ b/samples/AfterBlazorServerSide.Tests/ControlSampleTests.cs @@ -16,10 +16,17 @@ public ControlSampleTests(PlaywrightFixture fixture) [Theory] [InlineData("/ControlSamples/Button")] [InlineData("/ControlSamples/CheckBox")] + [InlineData("/ControlSamples/CheckBox/Events")] + [InlineData("/ControlSamples/CheckBox/Style")] [InlineData("/ControlSamples/HyperLink")] [InlineData("/ControlSamples/LinkButton")] [InlineData("/ControlSamples/Literal")] [InlineData("/ControlSamples/DropDownList")] + [InlineData("/ControlSamples/Panel")] + [InlineData("/ControlSamples/PlaceHolder")] + [InlineData("/ControlSamples/RadioButton")] + [InlineData("/ControlSamples/RadioButtonList")] + [InlineData("/ControlSamples/TextBox")] public async Task EditorControl_Loads_WithoutErrors(string path) { await VerifyPageLoadsWithoutErrors(path); @@ -49,6 +56,16 @@ public async Task NavigationControl_Loads_WithoutErrors(string path) await VerifyPageLoadsWithoutErrors(path); } + // Menu component tests - Menu has known JS interop requirements that may produce console errors + // Testing separately to verify the page loads and renders content + [Theory] + [InlineData("/ControlSamples/Menu")] + [InlineData("/ControlSamples/Menu/DatabindingSitemap")] + public async Task MenuControl_Loads_AndRendersContent(string path) + { + await VerifyMenuPageLoads(path); + } + // Validation Controls [Theory] [InlineData("/ControlSamples/RequiredFieldValidator")] @@ -77,6 +94,53 @@ public async Task LoginControl_Loads_WithoutErrors(string path) [Theory] [InlineData("/ControlSamples/AdRotator")] public async Task OtherControl_Loads_WithoutErrors(string path) + { + await VerifyPageLoadsWithoutErrors(path); + } + + /// + /// Validates that AdRotator displays an ad with the correct attributes. + /// This specifically tests that Ads.xml is properly deployed and accessible. + /// + [Fact] + public async Task AdRotator_DisplaysAd_WithCorrectAttributes() + { + // Arrange + var page = await _fixture.NewPageAsync(); + + try + { + // Act + var response = await page.GotoAsync($"{_fixture.BaseUrl}/ControlSamples/AdRotator", new PageGotoOptions + { + WaitUntil = WaitUntilState.NetworkIdle, + Timeout = 30000 + }); + + // Assert - Page loads successfully + Assert.NotNull(response); + Assert.True(response.Ok, $"Page failed to load with status: {response.Status}"); + + // Assert - AdRotator component rendered (look for images from Ads.xml) + // The AdRotator renders as: ... + // Look for the specific image sources that come from Ads.xml + var adImage = await page.QuerySelectorAsync("img[src='/img/CSharp.png'], img[src='/img/VB.png']"); + Assert.NotNull(adImage); + Assert.True(await adImage.IsVisibleAsync(), "Ad image should be visible"); + + // Verify alt text is one of the expected values from Ads.xml + var altText = await adImage.GetAttributeAsync("alt"); + Assert.NotNull(altText); + Assert.NotEmpty(altText); + Assert.Contains(altText, new[] { "CSharp", "Visual Basic" }); + } + finally + { + await page.CloseAsync(); + } + } + + private async Task VerifyPageLoadsWithoutErrors(string path) { // Arrange var page = await _fixture.NewPageAsync(); @@ -105,11 +169,11 @@ public async Task OtherControl_Loads_WithoutErrors(string path) Timeout = 30000 }); - // Assert - AdRotator may have issues with file loading, so we just verify page loads + // Assert Assert.NotNull(response); - // Note: AdRotator may return 500 if Ads.xml is not found in production, but that's a known limitation - Assert.True(response.Ok || response.Status == 500, - $"Page {path} failed to load with status: {response.Status}"); + Assert.True(response.Ok, $"Page {path} failed to load with status: {response.Status}"); + Assert.Empty(consoleErrors); + Assert.Empty(pageErrors); } finally { @@ -117,21 +181,17 @@ public async Task OtherControl_Loads_WithoutErrors(string path) } } - private async Task VerifyPageLoadsWithoutErrors(string path) + /// + /// Verifies Menu pages load and render content. Menu component has known JS interop + /// requirements (bwfc.Page.AddScriptElement) that may produce console errors when + /// the JavaScript setup is not configured, but the page should still render. + /// + private async Task VerifyMenuPageLoads(string path) { // Arrange var page = await _fixture.NewPageAsync(); - var consoleErrors = new List(); var pageErrors = new List(); - page.Console += (_, msg) => - { - if (msg.Type == "error") - { - consoleErrors.Add($"{path}: {msg.Text}"); - } - }; - page.PageError += (_, error) => { pageErrors.Add($"{path}: {error}"); @@ -146,10 +206,18 @@ private async Task VerifyPageLoadsWithoutErrors(string path) Timeout = 30000 }); - // Assert + // Assert - Page loads successfully Assert.NotNull(response); Assert.True(response.Ok, $"Page {path} failed to load with status: {response.Status}"); - Assert.Empty(consoleErrors); + + // Assert - Page renders menu content (tables, links, or list items) + var menuContent = await page.Locator("table, a, li, td").AllAsync(); + Assert.NotEmpty(menuContent); + + // Note: We don't check console errors for Menu pages because the Menu component + // requires JavaScript setup (bwfc.Page.AddScriptElement) that may not be configured + // in all environments. The important thing is that the page renders. + Assert.Empty(pageErrors); } finally diff --git a/samples/AfterBlazorServerSide.Tests/InteractiveComponentTests.cs b/samples/AfterBlazorServerSide.Tests/InteractiveComponentTests.cs index 90b1f32b..5f59b60f 100644 --- a/samples/AfterBlazorServerSide.Tests/InteractiveComponentTests.cs +++ b/samples/AfterBlazorServerSide.Tests/InteractiveComponentTests.cs @@ -530,4 +530,300 @@ public async Task Login_FormElements_Present() await page.CloseAsync(); } } + + [Fact] + public async Task Panel_Renders_WithContent() + { + // Arrange + var page = await _fixture.NewPageAsync(); + var consoleErrors = new List(); + + page.Console += (_, msg) => + { + if (msg.Type == "error") + { + consoleErrors.Add(msg.Text); + } + }; + + try + { + // Act + await page.GotoAsync($"{_fixture.BaseUrl}/ControlSamples/Panel", new PageGotoOptions + { + WaitUntil = WaitUntilState.NetworkIdle + }); + + // Verify Panel renders as div elements + var divPanels = await page.Locator("div").AllAsync(); + Assert.NotEmpty(divPanels); + + // Verify fieldset for GroupingText panels + var fieldsets = await page.Locator("fieldset").AllAsync(); + Assert.NotEmpty(fieldsets); + + // Verify legend inside fieldset + var legends = await page.Locator("fieldset legend").AllAsync(); + Assert.NotEmpty(legends); + + // Assert no console errors + Assert.Empty(consoleErrors); + } + finally + { + await page.CloseAsync(); + } + } + + [Fact] + public async Task PlaceHolder_VisibilityToggle_Works() + { + // Arrange + var page = await _fixture.NewPageAsync(); + var consoleErrors = new List(); + + page.Console += (_, msg) => + { + if (msg.Type == "error") + { + consoleErrors.Add(msg.Text); + } + }; + + try + { + // Act + await page.GotoAsync($"{_fixture.BaseUrl}/ControlSamples/PlaceHolder", new PageGotoOptions + { + WaitUntil = WaitUntilState.NetworkIdle + }); + + // Find toggle button + var toggleButton = page.Locator("button:has-text('Hide Content'), button:has-text('Show Content')").First; + Assert.NotNull(toggleButton); + + // Get initial visibility state by checking button text + var initialButtonText = await toggleButton.TextContentAsync(); + + // Click to toggle + await toggleButton.ClickAsync(); + await page.WaitForTimeoutAsync(300); + + // Verify button text changed (indicating toggle worked) + var newButtonText = await toggleButton.TextContentAsync(); + Assert.NotEqual(initialButtonText, newButtonText); + + // Assert no console errors + Assert.Empty(consoleErrors); + } + finally + { + await page.CloseAsync(); + } + } + + [Fact] + public async Task RadioButtonList_Selection_Works() + { + // Arrange + var page = await _fixture.NewPageAsync(); + var consoleErrors = new List(); + + page.Console += (_, msg) => + { + if (msg.Type == "error") + { + consoleErrors.Add(msg.Text); + } + }; + + try + { + // Act + await page.GotoAsync($"{_fixture.BaseUrl}/ControlSamples/RadioButtonList", new PageGotoOptions + { + WaitUntil = WaitUntilState.NetworkIdle + }); + + // Find radio buttons + var radioButtons = await page.Locator("input[type='radio']").AllAsync(); + Assert.NotEmpty(radioButtons); + + // Click a radio button + var firstRadio = page.Locator("input[type='radio']").First; + await firstRadio.ClickAsync(); + await page.WaitForTimeoutAsync(300); + + // Verify it's checked + var isChecked = await firstRadio.IsCheckedAsync(); + Assert.True(isChecked, "Radio button should be checked after clicking"); + + // Assert no console errors + Assert.Empty(consoleErrors); + } + finally + { + await page.CloseAsync(); + } + } + + [Fact] + public async Task RadioButton_Toggle_Works() + { + // Arrange + var page = await _fixture.NewPageAsync(); + var consoleErrors = new List(); + + page.Console += (_, msg) => + { + if (msg.Type == "error") + { + consoleErrors.Add(msg.Text); + } + }; + + try + { + // Act + await page.GotoAsync($"{_fixture.BaseUrl}/ControlSamples/RadioButton", new PageGotoOptions + { + WaitUntil = WaitUntilState.NetworkIdle + }); + + // Find radio buttons + var radioButtons = await page.Locator("input[type='radio']").AllAsync(); + Assert.NotEmpty(radioButtons); + + // Click a radio button + var firstRadio = page.Locator("input[type='radio']").First; + await firstRadio.ClickAsync(); + await page.WaitForTimeoutAsync(300); + + // Verify it's checked + var isChecked = await firstRadio.IsCheckedAsync(); + Assert.True(isChecked, "Radio button should be checked after clicking"); + + // Assert no console errors + Assert.Empty(consoleErrors); + } + finally + { + await page.CloseAsync(); + } + } + + [Fact] + public async Task TextBox_Input_Works() + { + // Arrange + var page = await _fixture.NewPageAsync(); + var consoleErrors = new List(); + + page.Console += (_, msg) => + { + if (msg.Type == "error") + { + consoleErrors.Add(msg.Text); + } + }; + + try + { + // Act + await page.GotoAsync($"{_fixture.BaseUrl}/ControlSamples/TextBox", new PageGotoOptions + { + WaitUntil = WaitUntilState.NetworkIdle + }); + + // Find text inputs (not readonly) + var textInputs = await page.Locator("input[type='text']:not([readonly])").AllAsync(); + Assert.NotEmpty(textInputs); + + // Fill a text input + var firstInput = page.Locator("input[type='text']:not([readonly])").First; + await firstInput.FillAsync("Test Input"); + + // Verify the value was set + var value = await firstInput.InputValueAsync(); + Assert.Equal("Test Input", value); + + // Verify readonly inputs exist + var readonlyInputs = await page.Locator("input[readonly]").AllAsync(); + Assert.NotEmpty(readonlyInputs); + + // Assert no console errors + Assert.Empty(consoleErrors); + } + finally + { + await page.CloseAsync(); + } + } + + [Fact] + public async Task TextBox_MultiLine_Works() + { + // Arrange + var page = await _fixture.NewPageAsync(); + var consoleErrors = new List(); + + page.Console += (_, msg) => + { + if (msg.Type == "error") + { + consoleErrors.Add(msg.Text); + } + }; + + try + { + // Act + await page.GotoAsync($"{_fixture.BaseUrl}/ControlSamples/TextBox", new PageGotoOptions + { + WaitUntil = WaitUntilState.NetworkIdle + }); + + // Verify textarea elements exist (multiline textbox) + var textareas = await page.Locator("textarea").AllAsync(); + Assert.NotEmpty(textareas); + + // Assert no console errors + Assert.Empty(consoleErrors); + } + finally + { + await page.CloseAsync(); + } + } + + [Fact] + public async Task Menu_Renders_WithItems() + { + // Arrange + var page = await _fixture.NewPageAsync(); + // Note: We don't check console errors for Menu because the Menu component + // requires JavaScript setup (bwfc.Page.AddScriptElement) that produces errors + // when not configured. The test verifies the page renders content. + + try + { + // Act + await page.GotoAsync($"{_fixture.BaseUrl}/ControlSamples/Menu", new PageGotoOptions + { + WaitUntil = WaitUntilState.NetworkIdle + }); + + // Verify menu content renders (the Menu component renders tables with menu items) + var menuTables = await page.Locator("table").AllAsync(); + Assert.NotEmpty(menuTables); + + // Verify there are clickable elements (links or cells with menu text) + var menuItems = await page.Locator("td a, td").AllAsync(); + Assert.NotEmpty(menuItems); + } + finally + { + await page.CloseAsync(); + } + } } diff --git a/samples/AfterBlazorServerSide/AfterBlazorServerSide.csproj b/samples/AfterBlazorServerSide/AfterBlazorServerSide.csproj index 4d46fb9e..28c6edc5 100644 --- a/samples/AfterBlazorServerSide/AfterBlazorServerSide.csproj +++ b/samples/AfterBlazorServerSide/AfterBlazorServerSide.csproj @@ -11,7 +11,9 @@ - + + PreserveNewest + diff --git a/samples/AfterBlazorServerSide/wwwroot/css/site.css b/samples/AfterBlazorServerSide/wwwroot/css/site.css index bf58cdec..1945d3ce 100644 --- a/samples/AfterBlazorServerSide/wwwroot/css/site.css +++ b/samples/AfterBlazorServerSide/wwwroot/css/site.css @@ -112,7 +112,8 @@ footer { } .verticalNav { - overflow-y: scroll; + overflow-y: auto; + height: calc(100vh - 3.5rem); } .navbar-toggler { diff --git a/src/BlazorWebFormsComponents.Test/AdRotator/DeploymentVerification.razor b/src/BlazorWebFormsComponents.Test/AdRotator/DeploymentVerification.razor new file mode 100644 index 00000000..520680e4 --- /dev/null +++ b/src/BlazorWebFormsComponents.Test/AdRotator/DeploymentVerification.razor @@ -0,0 +1,68 @@ +@using System.IO + +@code { + /// + /// Integration test to verify that XML files are properly copied to output directory. + /// This test validates the fix for the Azure deployment issue where Ads.xml was missing. + /// Uses Ads1.xml as the test file to verify the deployment mechanism works correctly. + /// + [Fact] + public void AdRotator_DeploymentVerification_AdsXmlExistsInOutputDirectory() + { + // Arrange - Get the path where test XML files should be copied during build + var outputDirectory = AppContext.BaseDirectory; + var adsXmlPath = Path.Combine(outputDirectory, "Ads1.xml"); + + // Act & Assert - Verify the file exists (simulating deployment scenario) + File.Exists(adsXmlPath).ShouldBeTrue($"XML files should be copied to output directory. Expected path: {adsXmlPath}"); + + // Verify the file can be read and contains valid XML + var fileContent = File.ReadAllText(adsXmlPath); + fileContent.ShouldContain(""); + fileContent.ShouldContain(""); + } + + /// + /// Integration test to verify AdRotator can load and render ads from a file in the output directory. + /// This simulates the actual deployment scenario where the component loads from the file system. + /// Uses Ads1.xml to test the deployment mechanism works as expected. + /// + [Fact] + public void AdRotator_DeploymentVerification_LoadsFromOutputDirectory() + { + // Arrange - Use Ads1.xml which should be in output directory + var adsFile = "Ads1.xml"; + + // Act - Render AdRotator with the file from output directory + var cut = Render(@); + + // Assert - Verify the component rendered successfully + var anchor = cut.Find("a"); + anchor.ShouldNotBeNull("AdRotator should render an anchor element when Ads.xml is found"); + + var img = cut.Find("img"); + img.ShouldNotBeNull("AdRotator should render an image element when Ads.xml is found"); + + // Verify the content matches expected values from Ads1.xml + img.Attributes["alt"].Value.ShouldBe("Bing"); + img.Attributes["src"].Value.ShouldBe("bing.png"); + anchor.Attributes["href"].Value.ShouldBe("http://www.bing.com"); + } + + /// + /// Integration test to verify AdRotator handles missing XML files gracefully. + /// This helps identify deployment issues early where files may not be properly deployed. + /// + [Fact] + public void AdRotator_DeploymentVerification_HandlesFileMissingScenario() + { + // Arrange - Use a non-existent file + var nonExistentFile = "NonExistent.xml"; + + // Act & Assert - Should throw FileNotFoundException + Should.Throw(() => + { + var cut = Render(@); + }); + } +}