diff --git a/.ai-team/agents/bishop/history.md b/.ai-team/agents/bishop/history.md index cf9b7aa3a..b651604b1 100644 --- a/.ai-team/agents/bishop/history.md +++ b/.ai-team/agents/bishop/history.md @@ -105,3 +105,64 @@ Added `Convert-PageLifecycleMethods` (GAP-05) and `Convert-EventHandlerSignature - GAP-05: Page_Load OnInitializedAsync, Page_Init OnInitialized, Page_PreRender OnAfterRenderAsync(bool firstRender) - GAP-07: Standard EventArgs handlers strip both params; specialized *EventArgs handlers keep the EventArgs param, strip sender - Updated 6 expected test files. All 21 L1 tests pass at 100% line accuracy. + +## Phase 3: Code-Behind C# Transforms — TC13-TC21 (2026-03-30) + +Ported all 10 code-behind transforms from PowerShell regex patterns to C# classes implementing `ICodeBehindTransform`. + +### Transforms Built (in pipeline order) +| Order | Class | Coverage | +|-------|-------|----------| +| 10 | `TodoHeaderTransform` | Injects migration guidance header | +| 100 | `UsingStripTransform` | Strips System.Web.*, Microsoft.AspNet.*, Microsoft.Owin.*, Owin usings | +| 200 | `BaseClassStripTransform` | Removes `: System.Web.UI.Page` etc. from partial classes | +| 300 | `ResponseRedirectTransform` | `Response.Redirect()` → `NavigationManager.NavigateTo()` + [Inject] injection | +| 400 | `SessionDetectTransform` | Detects `Session["key"]`, generates migration guidance block | +| 410 | `ViewStateDetectTransform` | Detects `ViewState["key"]`, generates field replacement suggestions | +| 500 | `IsPostBackTransform` | Unwraps simple `if (!IsPostBack)` guards (brace-counting), TODO for else clauses | +| 600 | `PageLifecycleTransform` | Page_Load→OnInitializedAsync, Page_Init→OnInitialized, Page_PreRender→OnAfterRenderAsync | +| 700 | `EventHandlerSignatureTransform` | Strips (object sender, EventArgs e); keeps specialized EventArgs | +| 800 | `DataBindTransform` | Cross-file DataSource/DataBind handling + InjectItemsAttributes for markup | +| 900 | `UrlCleanupTransform` | .aspx URL literals → clean routes | + +### Infrastructure Changes +- Added `TransformCodeBehind()` public method to `MigrationPipeline` for test access +- Registered all 11 code-behind transforms in `Program.cs` DI container +- Activated real pipeline in `TestHelpers.CreateDefaultPipeline()` (replaced TODO stubs) +- Activated real assertions in `L1TransformTests` for both markup and code-behind tests +- Fixed TC20/TC21 expected markup files: `OnClick="@Handler"` matches EventWiringTransform output + +### Key Learnings +- **IDE0007 enforcement:** Project .editorconfig treats `var` preference as error. Always use `var` over explicit types. +- **Transform ordering matters:** ResponseRedirect strips `~/` but preserves `.aspx`; UrlCleanup then handles `.aspx` patterns on `"~/..."` and relative NavigateTo forms. URLs like `/Products.aspx` survive because URL cleanup patterns don't match leading `/`. +- **TodoHeader as standalone transform (Order 10):** Splitting the TODO header into its own transform class keeps Session/ViewState detect transforms cleaner — they find the marker and insert after it. +- **Test discovery was previously placeholder:** The old tests only verified input ≠ expected. Wiring real pipeline exposed TC20/TC21 markup mismatches from EventWiringTransform `@` prefix. +- **All 72 tests pass:** 21 markup + 8 code-behind + 4 infrastructure + 39 unit tests. + +### Phase 4: Scaffolding, Config Transforms, and Full Pipeline Wiring (Bishop) + +Ported scaffolding, config transforms, and OutputWriter from bwfc-migrate.ps1 to C#. Wired the full `migrate` command pipeline. + +#### New Files (9 total) +| File | Purpose | +|------|---------| +| `Config/DatabaseProviderDetector.cs` | 3-pass DB provider detection from Web.config (providerName → conn string pattern → EntityClient inner) | +| `Config/WebConfigTransformer.cs` | Parses Web.config appSettings + connectionStrings → appsettings.json (XDocument/LINQ to XML) | +| `Io/OutputWriter.cs` | Centralized file writer: dry-run support, UTF-8 no BOM, directory creation, file tracking | +| `Scaffolding/ProjectScaffolder.cs` | Generates .csproj, Program.cs, _Imports.razor, App.razor, Routes.razor, launchSettings.json | +| `Scaffolding/GlobalUsingsGenerator.cs` | Generates GlobalUsings.cs with Blazor infrastructure + conditional Identity usings | +| `Scaffolding/ShimGenerator.cs` | Generates WebFormsShims.cs + conditional IdentityShims.cs | + +#### Pipeline Changes +- `MigrationPipeline.ExecuteAsync()` now runs: scaffold → config → per-file transforms → report +- `MigrationReport` enhanced: JSON serialization (`--report`), console summary, manual items tracking +- `Program.cs` DI wires all new services: ProjectScaffolder, GlobalUsingsGenerator, ShimGenerator, WebConfigTransformer, DatabaseProviderDetector, OutputWriter +- 2-param constructor preserved on `MigrationPipeline` for backward-compatible test usage + +#### Key Patterns +- **ProjectScaffolder detects HasModels/HasIdentity** from source directory structure (Models/, Account/, Login.aspx, Register.aspx) — adjusts .csproj packages and Program.cs boilerplate accordingly +- **WebConfigTransformer skips built-in connection names** (LocalSqlServer, LocalMySqlServer) — matches PS behavior +- **DatabaseProviderDetector maps 4 providers:** SqlClient→SqlServer, SQLite→Sqlite, Npgsql→PostgreSQL, MySql→MySql +- **OutputWriter respects dry-run** — logs what would be written without touching disk +- **All templates are string literals** — no external template files, matching PS approach +- **Build clean:** 0 errors for both CLI and test projects diff --git a/.ai-team/agents/rogue/history.md b/.ai-team/agents/rogue/history.md index 78abfe896..78ea1d558 100644 --- a/.ai-team/agents/rogue/history.md +++ b/.ai-team/agents/rogue/history.md @@ -145,3 +145,26 @@ Updated all 12 LoginStatus bUnit tests: replaced manual `Mock` required because the TestData/inputs/*.aspx.cs files are real C# (Web Forms code-behind) that reference System.Web.UI — without exclusion they're compiled as project source and fail. +- `TestHelpers.cs`: `NormalizeContent()` ported from Run-L1Tests.ps1 (CRLF→LF, TrimEnd per line, strip trailing blanks), `GetTestDataRoot()` with fallback directory walk, `DiscoverTestCases()` / `DiscoverCodeBehindTestCases()` auto-discovery. `CreateDefaultPipeline()` stubbed with TODO comments and full transform ordering from architecture doc. +- `L1TransformTests.cs`: `[Theory][MemberData]` parameterized tests — 21 markup tests + 8 code-behind tests + 3 data integrity facts. Pipeline calls stubbed; currently asserts test data is loadable and input≠expected. Ready for Bishop to wire up. +- `CliTests.cs`: 13 System.CommandLine tests — migrate/convert commands exist with correct options, analyze command does NOT exist, parse validation for valid/invalid args. Builds own RootCommand matching target architecture spec. +- 7 TransformUnit stubs (AspPrefix, Expression, PageDirective, AttributeStrip, FormWrapper, ContentWrapper, UrlReference): 2-4 focused tests each, testing ONE transform in isolation. Each has TODO markers for real transform instantiation. +- `Usings.cs`: `global using Xunit;` + +**Key learnings:** +- TestData `.aspx.cs` files MUST be excluded from `` — they're Web Forms code-behind with `System.Web.UI.Page` base class that can't compile on net10.0. Use `` + `` pattern. +- Pipeline interfaces (`IMarkupTransform`, `ICodeBehindTransform`, `FileMetadata`) don't exist yet in src/ — Bishop is building them. All pipeline-dependent code uses TODO comments so the test project compiles independently. +- System.CommandLine tests work by reconstructing the command tree locally rather than trying to invoke Program.Main — this decouples from Bishop's refactoring of Program.cs. +- Test case count: 21 TC* cases (TC01-TC21), of which 8 have code-behind pairs (TC13-TC16, TC18-TC21). Total 29 input files + 29 expected files = 58 TestData files. + diff --git a/.squad/decisions.md b/.squad/decisions.md index 121389afe..381fef982 100644 --- a/.squad/decisions.md +++ b/.squad/decisions.md @@ -13861,3 +13861,201 @@ Tests are written *ahead of implementation* so Cyclops has a clear, testable con **Why this matters:** End-users invoking the `bwfc-migration` or `migration-standards` skills will now get accurate guidance on Phase 1 capabilities instead of being told to handle these items manually. + +# Bishop Phase 2: GAP-05 + GAP-07 Code-Behind Transforms + +**Date:** 2025-07-24 +**Author:** Bishop (Migration Tooling Dev) +**Status:** Implemented + +## Decisions Made + +### D1: GAP-07 Event Handler — Type-name matching over inheritance check +**Decision:** Determine whether to strip both params or keep specialized EventArgs by checking if the type name is *exactly* `EventArgs` (strip both) vs. ends with `EventArgs` but is longer (keep specialized). +**Rationale:** PowerShell regex can't inspect the C# type hierarchy. String matching on `\w*EventArgs` is sufficient because all Web Forms EventArgs subtypes follow the naming convention `*EventArgs`. This avoids false positives on non-EventArgs types like `string` or `int`. +**Risk:** If a custom EventArgs subclass is named exactly `EventArgs` (impossible per C# rules) it would be incorrectly stripped. Extremely low risk. + +### D2: GAP-05 Lifecycle — `await base.OnInitializedAsync()` injection +**Decision:** Inject `await base.OnInitializedAsync();` at the start of the converted `OnInitializedAsync` body. +**Rationale:** Blazor requires calling the base lifecycle method. Missing this call is a common source of bugs when components use `WebFormsPageBase` which has initialization logic in the base. + +### D3: GAP-05 PreRender — `if (firstRender)` guard wrapping +**Decision:** Wrap the entire `Page_PreRender` body in `if (firstRender) { ... }` when converting to `OnAfterRenderAsync`. +**Rationale:** `Page_PreRender` runs once before the first render. `OnAfterRenderAsync` runs after *every* render. The `firstRender` guard preserves the original single-execution semantics. + +### D4: Transform ordering — Lifecycle before Event Handlers +**Decision:** Run GAP-05 (lifecycle) before GAP-07 (event handlers) in the pipeline. +**Rationale:** Lifecycle conversion changes `Page_Load(object sender, EventArgs e)` to `OnInitializedAsync()`. If event handler conversion ran first, it would strip the lifecycle method's params but not rename it. Running lifecycle first ensures the method name is changed, and the params no longer match the event handler regex. + +### D5: Test expected files updated in-place +**Decision:** Updated 6 existing expected test files (TC13–TC16, TC18, TC19) to reflect the new transforms rather than excluding lifecycle/event handler test assertions. +**Rationale:** The L1 pipeline is cumulative — all transforms run on every code-behind file. Expected outputs must reflect the full transform chain. Selective exclusion would be fragile and mask regressions. + +## Validation +- Script parses cleanly (0 errors) +- All 21 L1 tests pass at 100% line accuracy +- TC19 (lifecycle), TC20 (standard handlers), TC21 (specialized handlers) are dedicated test cases + + +# L1 Migration Script Test Framework Extension + +**Date:** 2026-03-17 +**Author:** Colossus (Integration Test Engineer) +**Status:** Implemented + +## Context + +The L1 migration script (`migration-toolkit/scripts/bwfc-migrate.ps1`) was enhanced with 6 new capabilities in Phase 1. The test framework at `migration-toolkit/tests/` needed expansion to provide regression coverage for these enhancements. + +## Decision + +Extended the L1 test suite from 15 to 18 test cases by adding: + +1. **TC16-IsPostBackGuard** — Tests GAP-06: IsPostBack guard unwrapping +2. **TC17-BindExpression** — Tests GAP-13: Bind() → @bind-Value transform +3. **TC18-UrlCleanup** — Tests GAP-20: .aspx URL cleanup in Response.Redirect calls + +## Test Case Design Pattern + +Each test case consists of: +- **Input:** `TC##-Name.aspx` (markup) + optional `TC##-Name.aspx.cs` (code-behind) +- **Expected:** `TC##-Name.razor` (expected markup output) + optional `TC##-Name.razor.cs` (expected code-behind output) + +The test runner (`Run-L1Tests.ps1`) discovers test cases by scanning `inputs/` for `.aspx` files and comparing actual script output to expected output using normalized line-by-line comparison. + +## Implementation Notes + +- Expected files must match **actual script output** exactly, including: + - Whitespace/indentation preserved from AST transformations + - Attributes added by script (e.g., `ItemType="object"`) + - Standard TODO comment headers + - Base class removal (`: System.Web.UI.Page` stripped) +- URL cleanup only transforms `Response.Redirect()` arguments, not arbitrary string literals +- Test suite now at 78% pass rate (14/18), 98.2% line accuracy + +## Rationale + +These test cases provide: +1. Regression protection for Phase 1 enhancements +2. Documentation of expected script behavior through executable examples +3. Foundation for future enhancement testing + +## References + +- Migration script: `migration-toolkit/scripts/bwfc-migrate.ps1` +- Test runner: `migration-toolkit/tests/Run-L1Tests.ps1` +- Test inputs: `migration-toolkit/tests/inputs/` +- Expected outputs: `migration-toolkit/tests/expected/` + + +# Decision: Phase 2 Playwright Test Strategy + +**Author:** Colossus +**Date:** 2026-07-24 +**Status:** Implemented + +## Context + +Phase 2 added SessionShim (GAP-04), Page Lifecycle Transforms (GAP-05), and Event Handler Signatures (GAP-07). GAP-05 and GAP-07 are script transforms with no dedicated UI — they are covered by L1 unit tests. GAP-04 has a live sample page at `/migration/session`. + +## Decision + +- **Test GAP-04 (SessionShim) with 5 Playwright tests** covering set/get, count, clear, typed counter, and cross-navigation persistence. +- **Add 1 regression test for the Phase 1 ConfigurationManager page** to prevent regressions. +- **Skip browser tests for GAP-05 and GAP-07** since they are script-level transforms with no direct UI surface; L1 tests provide sufficient coverage. +- **Use `data-audit-control` attribute selectors** (already present on the sample page) for robust element targeting that won't break with CSS changes. +- **Use `DOMContentLoaded` wait strategy** (not `NetworkIdle`) for interactive Blazor Server pages per established patterns. + +## Files Created + +- `samples/AfterBlazorServerSide.Tests/Migration/SessionDemoTests.cs` (5 tests) +- `samples/AfterBlazorServerSide.Tests/Migration/ConfigurationManagerTests.cs` (1 test) + + +# Decision: SessionShim Design (GAP-04) + +**Date:** 2026-07-28 +**By:** Cyclops (Component Dev) +**Requested by:** Jeffrey T. Fritz + +## What + +Implemented `SessionShim` as a scoped service that provides `Session["key"]` dictionary-style access for migrated Web Forms code. Registered in DI via `AddBlazorWebFormsComponents()`. Exposed as `protected SessionShim Session` on `WebFormsPageBase`. + +## Design Choices + +1. **System.Text.Json** for serialization — no Newtonsoft dependency, matches project zero-external-deps policy. +2. **Graceful fallback** to `ConcurrentDictionary` when `ISession` is unavailable (interactive Blazor Server mode). No exceptions thrown. +3. **One-time log warning** via `ILogger` on first fallback — gives visibility without spam. +4. **`IHttpContextAccessor` as optional** constructor parameter — prevents DI failures in test environments. +5. **`AddDistributedMemoryCache()` + `AddSession()`** added to DI registration — required by ASP.NET Core session middleware. Safe to call multiple times (idempotent). +6. **`TryGetSession` wraps access in try/catch** for `InvalidOperationException` — covers the case where session middleware is not in the pipeline. + +## Why + +Web Forms apps use `Session["key"]` pervasively for shopping carts, wizard state, user preferences. This shim lets migrated code compile and run with only the `asp:` prefix removal. The fallback mode ensures Blazor Server interactive circuits work correctly (session state is per-circuit anyway). + +## Impact + +- `WebFormsPageBase.Session` is now available on all migrated pages +- `ServiceCollectionExtensions.AddBlazorWebFormsComponents()` now registers session infrastructure +- Build verified clean on net8.0, net9.0, net10.0 + + +### 2026-03-31: GAP-05 + GAP-07 Code-Behind Transforms + +**By:** Bishop + +**What:** Implemented 11 code-behind transforms (TC13TC21): UsingStrip, BaseClassStrip, ResponseRedirect, SessionDetect, ViewStateDetect, IsPostBack, PageLifecycle, EventHandlerSignature, DataBind, UrlCleanup, TodoHeader. Wired into C# pipeline with dependency injection. + +**Transform Ordering Decision (D4):** Run GAP-05 (lifecycle) before GAP-07 (event handlers) in pipeline. Lifecycle conversion changes Page_Load(object sender, EventArgs e) to OnInitializedAsync(). If event handler conversion ran first, it would strip the lifecycle method's params but not rename it. Running lifecycle first ensures the method name is changed, and the params no longer match the event handler regex. + +**Event Handler Type-Name Matching (D1):** Determine whether to strip both params or keep specialized EventArgs by checking if the type name is exactly EventArgs (strip both) vs. ends with EventArgs but is longer (keep specialized). PowerShell regex can't inspect C# type hierarchy. String matching on \w*EventArgs is sufficient. + +**PreRender Guard (D3):** Wrap the entire Page_PreRender body in if (firstRender) { ... } when converting to OnAfterRenderAsync. Page_PreRender runs once before the first render. OnAfterRenderAsync runs after every render. The irstRender guard preserves original single-execution semantics. + +**Why:** Web Forms page lifecycle and event handlers require specific transformations to map to Blazor component lifecycle. Transform ordering prevents interference between lifecycle renaming and event handler param stripping. + +**Test Status:** 72/72 tests passing, TC13TC21 all at 100% accuracy. + +### 2026-03-31: L1 Migration Test Framework Extension + +**By:** Colossus + +**What:** Extended L1 test suite from 15 to 18 test cases by adding TC16-IsPostBackGuard, TC17-BindExpression, TC18-UrlCleanup. Created xUnit test project ( ests/BlazorWebFormsComponents.Cli.Tests) with 7 transform test stubs. + +**Test Case Design:** Input: .aspx + optional .aspx.cs files in migration-toolkit/tests/inputs/. Expected: .razor + optional .razor.cs files in xpected/. Test runner discovers inputs and compares against expected using normalized line-by-line comparison. + +**Why:** Test infrastructure provides regression protection for L1 enhancements, documents expected script behavior, and provides foundation for future enhancement testing. L1 PowerShell tests validate script behavior; xUnit tests validate C# component behavior. + +**Test Status:** 72/72 tests passing, 78% pass rate (14/18), 98.2% line accuracy. + +### 2026-03-31: Phase 2 Playwright Test Strategy + +**By:** Colossus + +**What:** GAP-05 and GAP-07 are script-level transforms with no direct UI surface; covered by L1 tests. GAP-04 (SessionShim) has live sample page at /migration/session. Create 5 Playwright tests for SessionShim (set/get, count, clear, typed counter, cross-navigation persistence) + 1 regression test for Phase 1 ConfigurationManager page. + +**Element Selection:** Use data-audit-control attribute selectors (already present on sample page) for robust element targeting that won't break with CSS changes. Use DOMContentLoaded wait strategy (not NetworkIdle) for interactive Blazor Server pages. + +**Files Created:** samples/AfterBlazorServerSide.Tests/Migration/SessionDemoTests.cs (5 tests), samples/AfterBlazorServerSide.Tests/Migration/ConfigurationManagerTests.cs (1 test). + +**Why:** SessionShim is critical for migrated Web Forms code that uses Session["key"]. Tests ensure the shim works across different page navigations and component lifecycle scenarios. + +### 2026-03-31: SessionShim Design (GAP-04) + +**By:** Cyclops + +**What:** Implemented SessionShim as scoped service providing Session["key"] dictionary-style access for migrated Web Forms code. Registered in DI via AddBlazorWebFormsComponents(). Exposed as protected SessionShim Session on WebFormsPageBase. + +**Design Choices:** +1. **System.Text.Json** for serialization no Newtonsoft dependency, aligns with project zero-external-deps policy +2. **Graceful fallback** to ConcurrentDictionary when ISession unavailable (interactive Blazor Server mode) +3. **One-time log warning** via ILogger on first fallback +4. **IHttpContextAccessor as optional** constructor parameter prevents DI failures in test environments +5. **AddDistributedMemoryCache() + AddSession()** added to DI registration required by ASP.NET Core session middleware +6. **TryGetSession wraps access** in try/catch for InvalidOperationException + +**Why:** Web Forms apps use Session["key"] pervasively. This shim lets migrated code compile and run with only sp: prefix removal. Fallback mode ensures Blazor Server interactive circuits work correctly (session state is per-circuit). + +**Impact:** WebFormsPageBase.Session now available on all migrated pages. ServiceCollectionExtensions.AddBlazorWebFormsComponents() registers session infrastructure. Build verified on net8.0, net9.0, net10.0. diff --git a/.squad/decisions/inbox/bishop-phase2-transforms.md b/.squad/decisions/inbox/bishop-phase2-transforms.md deleted file mode 100644 index 5492ecc1e..000000000 --- a/.squad/decisions/inbox/bishop-phase2-transforms.md +++ /dev/null @@ -1,33 +0,0 @@ -# Bishop Phase 2: GAP-05 + GAP-07 Code-Behind Transforms - -**Date:** 2025-07-24 -**Author:** Bishop (Migration Tooling Dev) -**Status:** Implemented - -## Decisions Made - -### D1: GAP-07 Event Handler — Type-name matching over inheritance check -**Decision:** Determine whether to strip both params or keep specialized EventArgs by checking if the type name is *exactly* `EventArgs` (strip both) vs. ends with `EventArgs` but is longer (keep specialized). -**Rationale:** PowerShell regex can't inspect the C# type hierarchy. String matching on `\w*EventArgs` is sufficient because all Web Forms EventArgs subtypes follow the naming convention `*EventArgs`. This avoids false positives on non-EventArgs types like `string` or `int`. -**Risk:** If a custom EventArgs subclass is named exactly `EventArgs` (impossible per C# rules) it would be incorrectly stripped. Extremely low risk. - -### D2: GAP-05 Lifecycle — `await base.OnInitializedAsync()` injection -**Decision:** Inject `await base.OnInitializedAsync();` at the start of the converted `OnInitializedAsync` body. -**Rationale:** Blazor requires calling the base lifecycle method. Missing this call is a common source of bugs when components use `WebFormsPageBase` which has initialization logic in the base. - -### D3: GAP-05 PreRender — `if (firstRender)` guard wrapping -**Decision:** Wrap the entire `Page_PreRender` body in `if (firstRender) { ... }` when converting to `OnAfterRenderAsync`. -**Rationale:** `Page_PreRender` runs once before the first render. `OnAfterRenderAsync` runs after *every* render. The `firstRender` guard preserves the original single-execution semantics. - -### D4: Transform ordering — Lifecycle before Event Handlers -**Decision:** Run GAP-05 (lifecycle) before GAP-07 (event handlers) in the pipeline. -**Rationale:** Lifecycle conversion changes `Page_Load(object sender, EventArgs e)` to `OnInitializedAsync()`. If event handler conversion ran first, it would strip the lifecycle method's params but not rename it. Running lifecycle first ensures the method name is changed, and the params no longer match the event handler regex. - -### D5: Test expected files updated in-place -**Decision:** Updated 6 existing expected test files (TC13–TC16, TC18, TC19) to reflect the new transforms rather than excluding lifecycle/event handler test assertions. -**Rationale:** The L1 pipeline is cumulative — all transforms run on every code-behind file. Expected outputs must reflect the full transform chain. Selective exclusion would be fragile and mask regressions. - -## Validation -- Script parses cleanly (0 errors) -- All 21 L1 tests pass at 100% line accuracy -- TC19 (lifecycle), TC20 (standard handlers), TC21 (specialized handlers) are dedicated test cases diff --git a/.squad/decisions/inbox/colossus-l1-integration-tests.md b/.squad/decisions/inbox/colossus-l1-integration-tests.md deleted file mode 100644 index e17897670..000000000 --- a/.squad/decisions/inbox/colossus-l1-integration-tests.md +++ /dev/null @@ -1,49 +0,0 @@ -# L1 Migration Script Test Framework Extension - -**Date:** 2026-03-17 -**Author:** Colossus (Integration Test Engineer) -**Status:** Implemented - -## Context - -The L1 migration script (`migration-toolkit/scripts/bwfc-migrate.ps1`) was enhanced with 6 new capabilities in Phase 1. The test framework at `migration-toolkit/tests/` needed expansion to provide regression coverage for these enhancements. - -## Decision - -Extended the L1 test suite from 15 to 18 test cases by adding: - -1. **TC16-IsPostBackGuard** — Tests GAP-06: IsPostBack guard unwrapping -2. **TC17-BindExpression** — Tests GAP-13: Bind() → @bind-Value transform -3. **TC18-UrlCleanup** — Tests GAP-20: .aspx URL cleanup in Response.Redirect calls - -## Test Case Design Pattern - -Each test case consists of: -- **Input:** `TC##-Name.aspx` (markup) + optional `TC##-Name.aspx.cs` (code-behind) -- **Expected:** `TC##-Name.razor` (expected markup output) + optional `TC##-Name.razor.cs` (expected code-behind output) - -The test runner (`Run-L1Tests.ps1`) discovers test cases by scanning `inputs/` for `.aspx` files and comparing actual script output to expected output using normalized line-by-line comparison. - -## Implementation Notes - -- Expected files must match **actual script output** exactly, including: - - Whitespace/indentation preserved from AST transformations - - Attributes added by script (e.g., `ItemType="object"`) - - Standard TODO comment headers - - Base class removal (`: System.Web.UI.Page` stripped) -- URL cleanup only transforms `Response.Redirect()` arguments, not arbitrary string literals -- Test suite now at 78% pass rate (14/18), 98.2% line accuracy - -## Rationale - -These test cases provide: -1. Regression protection for Phase 1 enhancements -2. Documentation of expected script behavior through executable examples -3. Foundation for future enhancement testing - -## References - -- Migration script: `migration-toolkit/scripts/bwfc-migrate.ps1` -- Test runner: `migration-toolkit/tests/Run-L1Tests.ps1` -- Test inputs: `migration-toolkit/tests/inputs/` -- Expected outputs: `migration-toolkit/tests/expected/` diff --git a/.squad/decisions/inbox/colossus-playwright-phase2.md b/.squad/decisions/inbox/colossus-playwright-phase2.md deleted file mode 100644 index cceb24be1..000000000 --- a/.squad/decisions/inbox/colossus-playwright-phase2.md +++ /dev/null @@ -1,22 +0,0 @@ -# Decision: Phase 2 Playwright Test Strategy - -**Author:** Colossus -**Date:** 2026-07-24 -**Status:** Implemented - -## Context - -Phase 2 added SessionShim (GAP-04), Page Lifecycle Transforms (GAP-05), and Event Handler Signatures (GAP-07). GAP-05 and GAP-07 are script transforms with no dedicated UI — they are covered by L1 unit tests. GAP-04 has a live sample page at `/migration/session`. - -## Decision - -- **Test GAP-04 (SessionShim) with 5 Playwright tests** covering set/get, count, clear, typed counter, and cross-navigation persistence. -- **Add 1 regression test for the Phase 1 ConfigurationManager page** to prevent regressions. -- **Skip browser tests for GAP-05 and GAP-07** since they are script-level transforms with no direct UI surface; L1 tests provide sufficient coverage. -- **Use `data-audit-control` attribute selectors** (already present on the sample page) for robust element targeting that won't break with CSS changes. -- **Use `DOMContentLoaded` wait strategy** (not `NetworkIdle`) for interactive Blazor Server pages per established patterns. - -## Files Created - -- `samples/AfterBlazorServerSide.Tests/Migration/SessionDemoTests.cs` (5 tests) -- `samples/AfterBlazorServerSide.Tests/Migration/ConfigurationManagerTests.cs` (1 test) diff --git a/.squad/decisions/inbox/cyclops-session-shim.md b/.squad/decisions/inbox/cyclops-session-shim.md deleted file mode 100644 index 61e0458a5..000000000 --- a/.squad/decisions/inbox/cyclops-session-shim.md +++ /dev/null @@ -1,28 +0,0 @@ -# Decision: SessionShim Design (GAP-04) - -**Date:** 2026-07-28 -**By:** Cyclops (Component Dev) -**Requested by:** Jeffrey T. Fritz - -## What - -Implemented `SessionShim` as a scoped service that provides `Session["key"]` dictionary-style access for migrated Web Forms code. Registered in DI via `AddBlazorWebFormsComponents()`. Exposed as `protected SessionShim Session` on `WebFormsPageBase`. - -## Design Choices - -1. **System.Text.Json** for serialization — no Newtonsoft dependency, matches project zero-external-deps policy. -2. **Graceful fallback** to `ConcurrentDictionary` when `ISession` is unavailable (interactive Blazor Server mode). No exceptions thrown. -3. **One-time log warning** via `ILogger` on first fallback — gives visibility without spam. -4. **`IHttpContextAccessor` as optional** constructor parameter — prevents DI failures in test environments. -5. **`AddDistributedMemoryCache()` + `AddSession()`** added to DI registration — required by ASP.NET Core session middleware. Safe to call multiple times (idempotent). -6. **`TryGetSession` wraps access in try/catch** for `InvalidOperationException` — covers the case where session middleware is not in the pipeline. - -## Why - -Web Forms apps use `Session["key"]` pervasively for shopping carts, wizard state, user preferences. This shim lets migrated code compile and run with only the `asp:` prefix removal. The fallback mode ensures Blazor Server interactive circuits work correctly (session state is per-circuit anyway). - -## Impact - -- `WebFormsPageBase.Session` is now available on all migrated pages -- `ServiceCollectionExtensions.AddBlazorWebFormsComponents()` now registers session infrastructure -- Build verified clean on net8.0, net9.0, net10.0 diff --git a/.squad/orchestration-log/2026-03-31T02-11-39Z-bishop-phase1.md b/.squad/orchestration-log/2026-03-31T02-11-39Z-bishop-phase1.md new file mode 100644 index 000000000..4589f489c --- /dev/null +++ b/.squad/orchestration-log/2026-03-31T02-11-39Z-bishop-phase1.md @@ -0,0 +1,60 @@ +# Orchestration Log: Bishop Phase 1 — Global Tool Pipeline Infrastructure + +**Agent:** Bishop (Migration Tooling Dev) +**Date:** 2026-03-31T02-11-39Z +**Branch:** feature/global-tool-port +**Commit:** a46d049b (Pipeline infrastructure + 16 markup transforms) + +## Scope + +- Built pipeline infrastructure for global tool (`BlazorWebFormsComponents.Cli`) +- Implemented 16 markup-only transforms (TC01–TC12) +- Created MarkupTransformPipeline + 25 source files +- ~1200 lines of C# + +## Transforms Implemented + +**TC01–TC12 (Markup):** WebForms attribute → Blazor property syntax + +1. **WebFormsServerAttribute** — `runat="server"` removal +2. **TagNameConversion** — `` → ` + + + +

+ Retrieved value: @cacheRetrieved +

+ + + +
+ +

2. Type-Safe Access with Get<T>()

+

The shim provides Cache.Get<T>(key) for strongly-typed retrieval.

+ +
+
+

Before (Web Forms)

+
// Requires manual cast
+var count = (int)Cache["HitCount"];
+Cache["HitCount"] = count + 1;
+
+
+

After (Blazor with BWFC)

+
// Type-safe access
+var count = Cache.Get<int>("HitCount");
+Cache["HitCount"] = count + 1;
+
+
+ +
+
+
Live Demo - Typed Counter
+

Hit count (stored as int): @(Cache.Get("HitCount"))

+ +
+
+ +
+ +

3. Remove Cache Items

+

Call Cache.Remove(key) to evict an item, just like in Web Forms. + The method returns the removed value.

+ +
+
+
Live Demo - Remove
+
+ + + +
+

+ Status: @removeStatus +

+
+
+ +
+ +

4. Sliding Expiration

+

Cache items can be stored with a sliding expiration, just like Web Forms. + Items expire if not accessed within the sliding window.

+ +
+
+

Before (Web Forms)

+
Cache.Insert("data", value, null,
+    Cache.NoAbsoluteExpiration,
+    TimeSpan.FromSeconds(30));
+
+
+

After (Blazor with BWFC)

+
// Simplified API
+Cache.Insert("data", value,
+    TimeSpan.FromSeconds(30));
+
+
+ +
+
+
Live Demo - Sliding Expiration
+
+ + +
+

+ Status: @expirationStatus +

+
+
+ +@code { + private string cacheKey = "MyKey"; + private string cacheValue = ""; + private string cacheRetrieved = "(empty)"; + private string removeStatus = "(click Store, then Remove, then Check)"; + private string expirationStatus = "(click Store, wait 5+ seconds, then Check)"; + + private void SetCacheValue() + { + if (!string.IsNullOrWhiteSpace(cacheKey)) + { + Cache[cacheKey] = cacheValue; + cacheRetrieved = $"Stored '{cacheValue}' with key '{cacheKey}'"; + } + } + + private void GetCacheValue() + { + if (!string.IsNullOrWhiteSpace(cacheKey)) + { + var value = (string?)Cache[cacheKey]; + cacheRetrieved = value ?? "(null - key not found)"; + } + } + + private void IncrementCounter() + { + var count = Cache.Get("HitCount"); + Cache["HitCount"] = count + 1; + } + + private void StoreForRemoval() + { + Cache["TestItem"] = "I exist!"; + removeStatus = "Stored: 'I exist!'"; + } + + private void RemoveCacheItem() + { + var removed = Cache.Remove("TestItem"); + removeStatus = removed != null + ? $"Removed: '{removed}'" + : "Nothing to remove (key not found)"; + } + + private void CheckCacheItem() + { + var value = (string?)Cache["TestItem"]; + removeStatus = value != null + ? $"Found: '{value}'" + : "Not found (null)"; + } + + private void StoreWithExpiration() + { + Cache.Insert("ExpiringItem", "I will expire!", TimeSpan.FromSeconds(5)); + expirationStatus = "Stored with 5-second sliding expiration. Wait 5+ seconds and click Check."; + } + + private void CheckExpiration() + { + var value = (string?)Cache["ExpiringItem"]; + expirationStatus = value != null + ? $"Still cached: '{value}'" + : "Expired! (null)"; + } +} diff --git a/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Migration/IsPostBackDemo.razor b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Migration/IsPostBackDemo.razor new file mode 100644 index 000000000..92c276ec1 --- /dev/null +++ b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Migration/IsPostBackDemo.razor @@ -0,0 +1,151 @@ +@page "/migration/ispostback" +@inherits WebFormsPageBase + +IsPostBack Migration Demo + +

IsPostBack Migration

+ +

This sample demonstrates IsPostBack behavior in Blazor. In Web Forms, + Page.IsPostBack was false on the initial GET and true on form POSTs. + The BWFC shim adapts this for Blazor: false on first render, true on subsequent renders + (interactive mode) or on POST requests (SSR mode).

+ +
+ +

1. IsPostBack Status

+

In Web Forms, IsPostBack indicates whether the page is being loaded for the first time + or in response to a postback.

+ +
+
+

Before (Web Forms)

+
if (!IsPostBack)
+{
+    // Initial load — bind data
+    GridView1.DataBind();
+}
+
+
+

After (Blazor with BWFC)

+
// Same pattern works!
+if (!IsPostBack)
+{
+    // Initial load — bind data
+    LoadData();
+}
+
+
+ +
+
+
Live Demo - IsPostBack Status
+

+ Current IsPostBack value: @IsPostBack +

+

+ Render count: @renderCount +

+ +

+ In interactive mode, IsPostBack is false on first render and + true after OnInitialized completes. + Click the button to see the current value after re-rendering. +

+
+
+ +
+ +

2. Guard Pattern

+

The classic if (!IsPostBack) guard ensures initialization code runs only once, + on the first page load.

+ +
+
+
Live Demo - Guard Pattern
+

+ Data loaded on initial render: @string.Join(", ", initialData) +

+

+ Initialization ran: @initCount time(s) +

+ +

+ The data list is populated only when !IsPostBack. + Clicking the button re-renders but does not re-initialize the data. +

+
+
+ +
+ +

3. IsHttpContextAvailable Guard

+

Some Web Forms features (cookies, headers) require HttpContext, which is only + available during SSR. Use IsHttpContextAvailable to guard those calls.

+ +
+
+

Before (Web Forms)

+
// HttpContext always available
+string ip = Request.UserHostAddress;
+
+
+

After (Blazor with BWFC)

+
// Guard for availability
+if (IsHttpContextAvailable)
+{
+    // Safe to use HTTP features
+    var cookies = Request.Cookies;
+}
+
+
+ +
+
+
Live Demo - HttpContext Guard
+

+ IsHttpContextAvailable: @IsHttpContextAvailable +

+ @if (IsHttpContextAvailable) + { +

+ HttpContext is available — HTTP-level features (cookies, headers) work normally. +

+ } + else + { +

+ HttpContext is not available (interactive rendering via WebSocket). + HTTP-level features degrade gracefully: cookies return empty, + QueryString and Url fall back to NavigationManager. +

+ } +
+
+ +@code { + private int renderCount; + private List initialData = new(); + private int initCount; + + protected override void OnInitialized() + { + base.OnInitialized(); + + if (!IsPostBack) + { + initialData = new List { "Alpha", "Bravo", "Charlie" }; + initCount++; + } + } + + protected override void OnAfterRender(bool firstRender) + { + // Track renders for display + } + + private void TriggerRerender() + { + renderCount++; + } +} diff --git a/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Migration/RequestDemo.razor b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Migration/RequestDemo.razor new file mode 100644 index 000000000..cc50d1ac3 --- /dev/null +++ b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Migration/RequestDemo.razor @@ -0,0 +1,104 @@ +@page "/migration/request" +@inherits WebFormsPageBase + +Request Migration Demo + +

Request Migration

+ +

This sample demonstrates the RequestShim that provides Web Forms-compatible + Request.QueryString, Request.Url, and Request.Cookies + access in Blazor. The shim falls back to NavigationManager when + HttpContext is unavailable (interactive mode).

+ +
+ +

1. Request.QueryString

+

Access query string parameters using the same dictionary-style syntax as Web Forms. + The shim falls back to parsing from NavigationManager.Uri in interactive mode.

+ +
+
+

Before (Web Forms)

+
string name = Request.QueryString["name"];
+string id = Request.QueryString["id"];
+
+
+

After (Blazor with BWFC)

+
// Same code works!
+string name = Request.QueryString["name"];
+string id = Request.QueryString["id"];
+
+
+ +
+
+
Live Demo - QueryString
+

Try navigating to: + /migration/request?name=test&id=42 +

+

+ Request.QueryString["name"]: @(Request.QueryString["name"].ToString() is { Length: > 0 } name ? name : "(not set)") +

+

+ Request.QueryString["id"]: @(Request.QueryString["id"].ToString() is { Length: > 0 } id ? id : "(not set)") +

+
+
+ +
+ +

2. Request.Url

+

Access the full request URL. Falls back to NavigationManager.Uri + in interactive mode.

+ +
+
+
Live Demo - Request.Url
+

+ Current URL: @Request.Url.ToString() +

+
+
+ +
+ +

3. Request.Cookies (with SSR Guard)

+

Cookies require HttpContext and are only available during SSR/pre-render. + In interactive mode, Request.Cookies returns an empty collection. + Use the IsHttpContextAvailable guard pattern.

+ +
+
+

Before (Web Forms)

+
string theme = Request.Cookies["theme"]?.Value;
+
+
+

After (Blazor with BWFC)

+
// Guard for HttpContext availability
+if (IsHttpContextAvailable)
+{
+    var theme = Request.Cookies["theme"];
+}
+
+
+ +
+
+
Live Demo - Cookies
+

+ IsHttpContextAvailable: @IsHttpContextAvailable +

+ @if (IsHttpContextAvailable) + { +

HttpContext is available — cookies can be read from the HTTP request.

+ } + else + { +

HttpContext is not available (interactive mode). + Cookies return empty. This is expected and the shim degrades gracefully.

+ } +
+
+ +@code { +} diff --git a/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Migration/ResponseRedirectDemo.razor b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Migration/ResponseRedirectDemo.razor new file mode 100644 index 000000000..3a1c5fe9d --- /dev/null +++ b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Migration/ResponseRedirectDemo.razor @@ -0,0 +1,112 @@ +@page "/migration/response-redirect" +@inherits WebFormsPageBase + +Response.Redirect Migration Demo + +

Response.Redirect Migration

+ +

This sample demonstrates the ResponseShim that provides Web Forms-compatible + Response.Redirect() in Blazor. The shim automatically strips ~/ + prefixes and .aspx extensions for clean Blazor routing.

+ +
+ +

1. Basic Redirect

+

Call Response.Redirect(url) to navigate, just like in Web Forms. + The shim wraps NavigationManager.NavigateTo().

+ +
+
+

Before (Web Forms)

+
Response.Redirect("/Session.aspx");
+
+
+

After (Blazor with BWFC)

+
// Same code works!
+Response.Redirect("/migration/session");
+
+
+ +
+
+
Live Demo - Basic Redirect
+

Click the button to navigate to the Session demo page:

+ +
+
+ +
+ +

2. Tilde & .aspx Auto-Stripping

+

Response.Redirect("~/Products.aspx") automatically strips ~/ + prefix and .aspx extension, producing a clean Blazor route.

+ +
+
+

Before (Web Forms)

+
Response.Redirect("~/Products.aspx");
+// Navigates to: /Products.aspx
+
+
+

After (Blazor with BWFC)

+
// Same call, auto-cleaned
+Response.Redirect("~/Products.aspx");
+// Navigates to: /Products
+
+
+ +
+
+
Live Demo - Tilde & .aspx Stripping
+

The shim transforms the URL before navigation:

+

+ Input: ~/migration/session.aspx
+ After stripping: @tildeStrippedUrl +

+ +
+
+ +
+ +

3. ResolveUrl for Link Generation

+

ResolveUrl() performs the same ~/ and .aspx + stripping without navigating — useful for building URLs in markup.

+ +
+
+
Live Demo - ResolveUrl
+

+ ResolveUrl("~/Products.aspx") returns: + @ResolveUrl("~/Products.aspx") +

+

+ ResolveUrl("~/migration/session.aspx") returns: + @ResolveUrl("~/migration/session.aspx") +

+

+ ResolveUrl("~/images/logo.png") returns: + @ResolveUrl("~/images/logo.png") +

+
+
+ +@code { + private string tildeStrippedUrl = ""; + + protected override void OnInitialized() + { + base.OnInitialized(); + tildeStrippedUrl = ResolveUrl("~/migration/session.aspx"); + } + + private void RedirectToSession() + { + Response.Redirect("/migration/session"); + } + + private void RedirectWithTilde() + { + Response.Redirect("~/migration/session.aspx"); + } +} diff --git a/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Migration/ServerMapPath.razor b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Migration/ServerMapPath.razor new file mode 100644 index 000000000..f441ebbce --- /dev/null +++ b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Migration/ServerMapPath.razor @@ -0,0 +1,166 @@ +@page "/migration/server-mappath" +@inherits WebFormsPageBase + +Server Utilities Migration Demo + +

Server Utilities Migration

+ +

This sample demonstrates the ServerShim that provides Web Forms-compatible + Server.MapPath(), Server.HtmlEncode(), Server.UrlEncode(), + and ResolveUrl() in Blazor.

+ +
+ +

1. Server.MapPath()

+

In Web Forms, Server.MapPath("~/uploads") resolved a virtual path to a physical + path. The BWFC shim provides the same API backed by IWebHostEnvironment.

+ +
+
+

Before (Web Forms)

+
string path = Server.MapPath("~/uploads");
+// Returns: C:\inetpub\wwwroot\uploads
+
+
+

After (Blazor with BWFC)

+
// Same code works!
+string path = Server.MapPath("~/uploads");
+// Returns: /app/wwwroot/uploads
+
+
+ +
+
+
Live Demo - MapPath
+

+ Server.MapPath("~/images") resolves to: +

+

@mappedPath

+
+
+ +
+ +

2. Server.HtmlEncode()

+

Encodes text for safe HTML display, preventing XSS. Same API as Web Forms.

+ +
+
+

Before (Web Forms)

+
string safe = Server.HtmlEncode(userInput);
+lblOutput.Text = safe;
+
+
+

After (Blazor with BWFC)

+
// Same code works!
+string safe = Server.HtmlEncode(userInput);
+
+
+ +
+
+
Live Demo - HtmlEncode
+
+ +
+ + +
+
+

+ Encoded output: @htmlEncoded +

+
+
+ +
+ +

3. Server.UrlEncode()

+

Encodes text for safe use in URLs. Same API as Web Forms.

+ +
+
+

Before (Web Forms)

+
string encoded = Server.UrlEncode(searchTerm);
+Response.Redirect("search.aspx?q=" + encoded);
+
+
+

After (Blazor with BWFC)

+
// Same code works!
+string encoded = Server.UrlEncode(searchTerm);
+
+
+ +
+
+
Live Demo - UrlEncode
+
+ +
+ + +
+
+

+ Encoded output: @urlEncoded +

+
+
+ +
+ +

4. ResolveUrl()

+

ResolveUrl("~/Products.aspx") strips the ~/ prefix and + .aspx extension, producing a clean Blazor route.

+ +
+
+

Before (Web Forms)

+
string url = ResolveUrl("~/Products.aspx");
+// Returns: /Products.aspx
+
+
+

After (Blazor with BWFC)

+
// Same call, cleaner output
+string url = ResolveUrl("~/Products.aspx");
+// Returns: /Products
+
+
+ +
+
+
Live Demo - ResolveUrl
+

+ ResolveUrl("~/Products.aspx") returns: + @resolvedUrl +

+
+
+ +@code { + private string mappedPath = ""; + private string htmlInput = ""; + private string htmlEncoded = "(enter text and click HtmlEncode)"; + private string urlInput = ""; + private string urlEncoded = "(enter text and click UrlEncode)"; + private string resolvedUrl = ""; + + protected override void OnInitialized() + { + base.OnInitialized(); + mappedPath = Server.MapPath("~/images"); + resolvedUrl = ResolveUrl("~/Products.aspx"); + } + + private void EncodeHtml() + { + htmlEncoded = Server.HtmlEncode(htmlInput); + } + + private void EncodeUrl() + { + urlEncoded = Server.UrlEncode(urlInput); + } +} diff --git a/src/BlazorWebFormsComponents.Cli/BlazorWebFormsComponents.Cli.csproj b/src/BlazorWebFormsComponents.Cli/BlazorWebFormsComponents.Cli.csproj new file mode 100644 index 000000000..2330ea2a5 --- /dev/null +++ b/src/BlazorWebFormsComponents.Cli/BlazorWebFormsComponents.Cli.csproj @@ -0,0 +1,37 @@ + + + + Exe + net10.0 + BlazorWebFormsComponents.Cli + webforms-to-blazor + enable + enable + + + true + webforms-to-blazor + ./nupkg + + + Fritz.WebFormsToBlazor + Jeffrey T. Fritz + A command-line tool to convert ASP.NET Web Forms user controls (.ascx) to Blazor Razor components, with assistance from GitHub Copilot SDK + Copyright Jeffrey T. Fritz 2019-2026 + MIT + https://github.com/FritzAndFriends/BlazorWebFormsComponents + blazor;webforms;aspnet;migration;cli;tool + https://github.com/FritzAndFriends/BlazorWebFormsComponents + GitHub + + + + + + + + + + + + diff --git a/src/BlazorWebFormsComponents.Cli/Config/DatabaseProviderDetector.cs b/src/BlazorWebFormsComponents.Cli/Config/DatabaseProviderDetector.cs new file mode 100644 index 000000000..44600955a --- /dev/null +++ b/src/BlazorWebFormsComponents.Cli/Config/DatabaseProviderDetector.cs @@ -0,0 +1,155 @@ +using System.Xml.Linq; + +namespace BlazorWebFormsComponents.Cli.Config; + +/// +/// Detects EF database provider from Web.config connectionStrings. +/// Ported from Find-DatabaseProvider in bwfc-migrate.ps1. +/// +public class DatabaseProviderDetector +{ + private static readonly Dictionary ProviderMap = new(StringComparer.OrdinalIgnoreCase) + { + ["System.Data.SqlClient"] = ("Microsoft.EntityFrameworkCore.SqlServer", "UseSqlServer"), + ["System.Data.SQLite"] = ("Microsoft.EntityFrameworkCore.Sqlite", "UseSqlite"), + ["Npgsql"] = ("Npgsql.EntityFrameworkCore.PostgreSQL", "UseNpgsql"), + ["MySql.Data.MySqlClient"] = ("Pomelo.EntityFrameworkCore.MySql", "UseMySql") + }; + + public DatabaseProviderInfo Detect(string sourcePath) + { + var defaultResult = new DatabaseProviderInfo + { + PackageName = "Microsoft.EntityFrameworkCore.SqlServer", + ProviderMethod = "UseSqlServer", + DetectedFrom = "Default — no Web.config connectionStrings found", + ConnectionString = "" + }; + + if (string.IsNullOrEmpty(sourcePath)) + return defaultResult; + + // Look for Web.config in source path and parent directory + var candidates = new[] + { + Path.Combine(sourcePath, "Web.config"), + Path.Combine(Path.GetDirectoryName(sourcePath) ?? sourcePath, "Web.config") + }; + + string? webConfigPath = null; + foreach (var candidate in candidates) + { + if (File.Exists(candidate)) + { + webConfigPath = candidate; + break; + } + } + + if (webConfigPath == null) + return defaultResult; + + XDocument doc; + try + { + doc = XDocument.Load(webConfigPath); + } + catch + { + return defaultResult; + } + + var connStringsElement = doc.Root?.Element("connectionStrings"); + if (connStringsElement == null) + return defaultResult; + + var adds = connStringsElement.Elements("add").ToList(); + if (adds.Count == 0) + return defaultResult; + + // Pass 1: Non-EntityClient entries with explicit providerName + foreach (var entry in adds) + { + var providerName = entry.Attribute("providerName")?.Value; + if (string.IsNullOrEmpty(providerName) || providerName == "System.Data.EntityClient") + continue; + + if (ProviderMap.TryGetValue(providerName, out var mapped)) + { + return new DatabaseProviderInfo + { + PackageName = mapped.PackageName, + ProviderMethod = mapped.ProviderMethod, + DetectedFrom = $"Web.config providerName={providerName}", + ConnectionString = entry.Attribute("connectionString")?.Value ?? "" + }; + } + } + + // Pass 2: Entries without providerName — detect from connection string content + foreach (var entry in adds) + { + var connString = entry.Attribute("connectionString")?.Value; + if (string.IsNullOrEmpty(connString) || connString.StartsWith("metadata=", StringComparison.OrdinalIgnoreCase)) + continue; + if (entry.Attribute("providerName") != null) + continue; + + if (System.Text.RegularExpressions.Regex.IsMatch(connString, @"(?i)\(LocalDB\)|Server=")) + { + return new DatabaseProviderInfo + { + PackageName = "Microsoft.EntityFrameworkCore.SqlServer", + ProviderMethod = "UseSqlServer", + DetectedFrom = "Web.config connection string pattern (SQL Server)", + ConnectionString = connString + }; + } + + if (System.Text.RegularExpressions.Regex.IsMatch(connString, @"(?i)Data Source=.*\.db")) + { + return new DatabaseProviderInfo + { + PackageName = "Microsoft.EntityFrameworkCore.Sqlite", + ProviderMethod = "UseSqlite", + DetectedFrom = "Web.config connection string pattern (SQLite)", + ConnectionString = connString + }; + } + } + + // Pass 3: EntityClient entries — extract inner provider (EF6 pattern) + foreach (var entry in adds) + { + if (entry.Attribute("providerName")?.Value != "System.Data.EntityClient") + continue; + + var connString = entry.Attribute("connectionString")?.Value ?? ""; + var match = System.Text.RegularExpressions.Regex.Match(connString, @"provider=([^;""]+)"); + if (match.Success) + { + var innerProvider = match.Groups[1].Value.Trim(); + if (ProviderMap.TryGetValue(innerProvider, out var mapped)) + { + return new DatabaseProviderInfo + { + PackageName = mapped.PackageName, + ProviderMethod = mapped.ProviderMethod, + DetectedFrom = $"Web.config EntityClient provider={innerProvider}", + ConnectionString = "" + }; + } + } + } + + return defaultResult; + } +} + +public class DatabaseProviderInfo +{ + public required string PackageName { get; init; } + public required string ProviderMethod { get; init; } + public required string DetectedFrom { get; init; } + public required string ConnectionString { get; init; } +} diff --git a/src/BlazorWebFormsComponents.Cli/Config/WebConfigTransformer.cs b/src/BlazorWebFormsComponents.Cli/Config/WebConfigTransformer.cs new file mode 100644 index 000000000..330a465da --- /dev/null +++ b/src/BlazorWebFormsComponents.Cli/Config/WebConfigTransformer.cs @@ -0,0 +1,146 @@ +using System.Text.Json; +using System.Xml.Linq; +using BlazorWebFormsComponents.Cli.Io; + +namespace BlazorWebFormsComponents.Cli.Config; + +/// +/// Parses Web.config and generates appsettings.json. +/// Ported from Convert-WebConfigToAppSettings in bwfc-migrate.ps1 (line ~892). +/// +public class WebConfigTransformer +{ + private static readonly HashSet BuiltInConnectionNames = new(StringComparer.OrdinalIgnoreCase) + { + "LocalSqlServer", + "LocalMySqlServer" + }; + + /// + /// Transforms Web.config appSettings and connectionStrings into appsettings.json content. + /// Returns null if no Web.config found or no settings to extract. + /// + public WebConfigResult? Transform(string sourcePath) + { + // Find Web.config (case-insensitive search) + var webConfigPath = FindWebConfig(sourcePath); + if (webConfigPath == null) + return null; + + XDocument doc; + try + { + doc = XDocument.Load(webConfigPath); + } + catch (Exception ex) + { + return new WebConfigResult + { + JsonContent = null, + AppSettingsCount = 0, + ConnectionStringsCount = 0, + Error = $"Could not parse Web.config: {ex.Message}" + }; + } + + var appSettings = new Dictionary(); + var connectionStrings = new Dictionary(); + + // Parse + var appSettingsNodes = doc.Descendants("appSettings").Elements("add"); + foreach (var node in appSettingsNodes) + { + var key = node.Attribute("key")?.Value; + var value = node.Attribute("value")?.Value ?? ""; + if (!string.IsNullOrEmpty(key)) + { + appSettings[key] = value; + } + } + + // Parse + var connStrNodes = doc.Descendants("connectionStrings").Elements("add"); + foreach (var node in connStrNodes) + { + var name = node.Attribute("name")?.Value; + var connStr = node.Attribute("connectionString")?.Value; + if (!string.IsNullOrEmpty(name) && !string.IsNullOrEmpty(connStr)) + { + if (!BuiltInConnectionNames.Contains(name)) + { + connectionStrings[name] = connStr; + } + } + } + + if (appSettings.Count == 0 && connectionStrings.Count == 0) + return null; + + // Build JSON structure + var jsonObj = new Dictionary(); + + if (connectionStrings.Count > 0) + { + jsonObj["ConnectionStrings"] = connectionStrings; + } + + foreach (var entry in appSettings) + { + jsonObj[entry.Key] = entry.Value; + } + + // Add standard Blazor sections + if (!jsonObj.ContainsKey("Logging")) + { + jsonObj["Logging"] = new Dictionary + { + ["LogLevel"] = new Dictionary + { + ["Default"] = "Information", + ["Microsoft.AspNetCore"] = "Warning" + } + }; + } + + if (!jsonObj.ContainsKey("AllowedHosts")) + { + jsonObj["AllowedHosts"] = "*"; + } + + var options = new JsonSerializerOptions + { + WriteIndented = true + }; + var jsonContent = JsonSerializer.Serialize(jsonObj, options); + + return new WebConfigResult + { + JsonContent = jsonContent, + AppSettingsCount = appSettings.Count, + ConnectionStringsCount = connectionStrings.Count, + AppSettingsKeys = [.. appSettings.Keys], + ConnectionStringNames = [.. connectionStrings.Keys] + }; + } + + private static string? FindWebConfig(string sourcePath) + { + var path1 = Path.Combine(sourcePath, "Web.config"); + if (File.Exists(path1)) return path1; + + var path2 = Path.Combine(sourcePath, "web.config"); + if (File.Exists(path2)) return path2; + + return null; + } +} + +public class WebConfigResult +{ + public string? JsonContent { get; init; } + public int AppSettingsCount { get; init; } + public int ConnectionStringsCount { get; init; } + public List AppSettingsKeys { get; init; } = []; + public List ConnectionStringNames { get; init; } = []; + public string? Error { get; init; } +} diff --git a/src/BlazorWebFormsComponents.Cli/EXAMPLES.md b/src/BlazorWebFormsComponents.Cli/EXAMPLES.md new file mode 100644 index 000000000..a116323c2 --- /dev/null +++ b/src/BlazorWebFormsComponents.Cli/EXAMPLES.md @@ -0,0 +1,88 @@ +# WebForms to Blazor CLI Tool - Conversion Examples + +This document shows examples of how the `webforms-to-blazor` CLI tool converts ASP.NET Web Forms user controls to Blazor Razor components. + +## Example 1: Simple View Switcher + +**Before (ViewSwitcher.ascx):** +```aspx +<%@ Control Language="C#" AutoEventWireup="true" CodeBehind="ViewSwitcher.ascx.cs" Inherits="BeforeWebForms.ViewSwitcher" %> +
+ <%: CurrentView %> view | Switch to <%: AlternateView %> +
+``` + +**After (ViewSwitcher.razor):** +```razor +@inherits BeforeWebForms.ViewSwitcher + +
+ @(CurrentView) view | Switch to @(AlternateView) +
+``` + +**Conversions Made:** +- `<%@ Control ... Inherits="..." %>` → `@inherits ...` +- `<%: expression %>` → `@(expression)` + +## Example 2: Product Card with Data Binding + +**Before (ProductCard.ascx):** +```aspx +<%@ Control Language="C#" AutoEventWireup="true" CodeBehind="ProductCard.ascx.cs" Inherits="MyApp.Controls.ProductCard" %> +
+ +
+

+

$<%: Item.Price %>

+ +
+
+``` + +**After (ProductCard.razor):** +```razor +@inherits MyApp.Controls.ProductCard + +
+ +
+

+

$@(context.Price)

+
+
+``` + +**Conversions Made:** +- `` → `` +- `` → `` +- `` → ` + private static readonly Regex CloseRegex = new(@"", RegexOptions.Compiled); + + // ContentTemplate wrappers + private static readonly Regex ContentTemplateOpenRegex = new(@"", RegexOptions.Compiled); + private static readonly Regex ContentTemplateCloseRegex = new(@"", RegexOptions.Compiled); + + // User control prefixes: ", RegexOptions.Compiled); + + public string Apply(string content, FileMetadata metadata) + { + // asp: prefix + content = OpenRegex.Replace(content, "<$1"); + content = CloseRegex.Replace(content, ""); + + // Strip ContentTemplate wrappers + content = ContentTemplateOpenRegex.Replace(content, ""); + content = ContentTemplateCloseRegex.Replace(content, ""); + + // uc: prefix + content = UcOpenRegex.Replace(content, "<$1"); + content = UcCloseRegex.Replace(content, ""); + + return content; + } +} diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/Markup/AttributeNormalizeTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/AttributeNormalizeTransform.cs new file mode 100644 index 000000000..75c9e1f05 --- /dev/null +++ b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/AttributeNormalizeTransform.cs @@ -0,0 +1,91 @@ +using System.Text.RegularExpressions; +using BlazorWebFormsComponents.Cli.Pipeline; + +namespace BlazorWebFormsComponents.Cli.Transforms.Markup; + +/// +/// Normalizes boolean, enum, and unit attribute values in converted Blazor markup. +/// 1. Booleans: Visible="True" → Visible="true" +/// 2. Enums: GridLines="Both" → GridLines="@GridLines.Both" +/// 3. Units: Width="100px" → Width="100" +/// +public class AttributeNormalizeTransform : IMarkupTransform +{ + public string Name => "AttributeNormalize"; + public int Order => 810; + + private static readonly Regex BoolRegex = new( + @"(\w+)=""(True|False)""", + RegexOptions.Compiled); + + // Attributes that contain text content — not booleans even if "True"/"False" + private static readonly HashSet TextAttributes = new(StringComparer.OrdinalIgnoreCase) + { + "Text", "Title", "Value", "ToolTip", "HeaderText", "FooterText", + "CommandName", "CommandArgument", "ErrorMessage", "InitialValue", + "DataField", "DataFormatString", "SortExpression", "NavigateUrl", + "DataTextField", "DataValueField", "ValidationExpression" + }; + + // Enum attribute → enum type mappings + private static readonly Dictionary EnumAttrMap = new() + { + ["GridLines"] = "GridLines", + ["BorderStyle"] = "BorderStyle", + ["HorizontalAlign"] = "HorizontalAlign", + ["VerticalAlign"] = "VerticalAlign", + ["TextAlign"] = "TextAlign", + ["TextMode"] = "TextBoxMode", + ["ImageAlign"] = "ImageAlign", + ["Orientation"] = "Orientation", + ["BulletStyle"] = "BulletStyle", + ["CaptionAlign"] = "TableCaptionAlign", + ["SortDirection"] = "SortDirection", + ["ScrollBars"] = "ScrollBars", + ["ContentDirection"] = "ContentDirection", + ["DayNameFormat"] = "DayNameFormat", + ["TitleFormat"] = "TitleFormat", + ["InsertItemPosition"] = "InsertItemPosition", + ["UpdateMode"] = "UpdatePanelUpdateMode", + ["FontSize"] = "FontSize" + }; + + // Dimension attributes for px stripping + private static readonly string[] UnitAttributes = + ["Width", "Height", "BorderWidth", "CellPadding", "CellSpacing"]; + + public string Apply(string content, FileMetadata metadata) + { + // Boolean normalization: True/False → true/false (skip text attributes) + content = BoolRegex.Replace(content, m => + { + var attr = m.Groups[1].Value; + if (TextAttributes.Contains(attr)) + return m.Value; + return $"{attr}=\"{m.Groups[2].Value.ToLowerInvariant()}\""; + }); + + // Enum type-qualifying + foreach (var (attrName, enumType) in EnumAttrMap) + { + var enumRegex = new Regex($@"(? + { + var val = m.Groups[1].Value; + // Skip boolean values + if (val is "True" or "False" or "true" or "false") + return m.Value; + return $"{attrName}=\"@{enumType}.{val}\""; + }); + } + + // Unit normalization: strip "px" suffix + foreach (var attr in UnitAttributes) + { + var unitRegex = new Regex($@"{Regex.Escape(attr)}=""(\d+)px"""); + content = unitRegex.Replace(content, $"{attr}=\"$1\""); + } + + return content; + } +} diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/Markup/AttributeStripTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/AttributeStripTransform.cs new file mode 100644 index 000000000..cbc167d94 --- /dev/null +++ b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/AttributeStripTransform.cs @@ -0,0 +1,67 @@ +using System.Text.RegularExpressions; +using BlazorWebFormsComponents.Cli.Pipeline; + +namespace BlazorWebFormsComponents.Cli.Transforms.Markup; + +/// +/// Removes Web Forms-specific attributes (runat="server", AutoEventWireup, EnableViewState, etc.). +/// Converts ItemType to TItem. Adds ItemType="object" fallback to generic BWFC components. +/// Converts ID= to id=. +/// +public class AttributeStripTransform : IMarkupTransform +{ + public string Name => "AttributeStrip"; + public int Order => 700; + + private static readonly string[] StripPatterns = + [ + @"runat\s*=\s*""server""", + @"AutoEventWireup\s*=\s*""(true|false)""", + @"EnableViewState\s*=\s*""(true|false)""", + @"ViewStateMode\s*=\s*""[^""]*""", + @"ValidateRequest\s*=\s*""(true|false)""", + @"MaintainScrollPositionOnPostBack\s*=\s*""(true|false)""", + @"ClientIDMode\s*=\s*""[^""]*""" + ]; + + // ItemType="Namespace.Class" → TItem="Class" + private static readonly Regex ItemTypeRegex = new( + @"ItemType=""(?:[^""]*\.)?([^""]+)""", + RegexOptions.Compiled); + + // Generic BWFC components that need ItemType="object" fallback + private static readonly string[] GenericComponents = + [ + "GridView", "DetailsView", "DropDownList", "BoundField", "BulletedList", + "Repeater", "ListView", "FormView", "RadioButtonList", "CheckBoxList", "ListBox", + "HyperLinkField", "ButtonField", "TemplateField", "DataList", "DataGrid" + ]; + + // ID="..." → id="..." + private static readonly Regex IdRegex = new(@"\bID=""([^""]*)""", RegexOptions.Compiled); + + public string Apply(string content, FileMetadata metadata) + { + // Strip Web Forms attributes + foreach (var pattern in StripPatterns) + { + var regex = new Regex($@"\s*{pattern}"); + content = regex.Replace(content, ""); + } + + // ItemType → TItem + content = ItemTypeRegex.Replace(content, "TItem=\"$1\""); + + // Add ItemType="object" fallback to generic components missing it + foreach (var comp in GenericComponents) + { + var tagRegex = new Regex($@"(<{comp}\s)(?![^>]*(?:ItemType|TItem)=)([^/>]*)(>|/>)"); + content = tagRegex.Replace(content, "${1}ItemType=\"object\" ${2}${3}"); + } + + // ID → id + content = IdRegex.Replace(content, "id=\"$1\""); + + return content; + } +} diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/Markup/ContentWrapperTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/ContentWrapperTransform.cs new file mode 100644 index 000000000..839ebcf81 --- /dev/null +++ b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/ContentWrapperTransform.cs @@ -0,0 +1,35 @@ +using System.Text.RegularExpressions; +using BlazorWebFormsComponents.Cli.Pipeline; + +namespace BlazorWebFormsComponents.Cli.Transforms.Markup; + +/// +/// Removes <asp:Content> wrapper tags, preserving inner content. +/// Handles HeadContent placeholders and TitleContent extraction. +/// +public class ContentWrapperTransform : IMarkupTransform +{ + public string Name => "ContentWrapper"; + public int Order => 300; + + // Open tags for any ContentPlaceHolderID — strip entirely, keeping content + private static readonly Regex ContentOpenRegex = new( + @"]*ContentPlaceHolderID\s*=\s*""[^""]*""[^>]*>[ \t]*\r?\n?", + RegexOptions.Compiled); + + // Closing tags + private static readonly Regex ContentCloseRegex = new( + @"\s*\r?\n?", + RegexOptions.Compiled); + + public string Apply(string content, FileMetadata metadata) + { + // Strip opening wrappers + content = ContentOpenRegex.Replace(content, ""); + + // Strip closing tags + content = ContentCloseRegex.Replace(content, ""); + + return content; + } +} diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/Markup/DataSourceIdTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/DataSourceIdTransform.cs new file mode 100644 index 000000000..7eb4efee3 --- /dev/null +++ b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/DataSourceIdTransform.cs @@ -0,0 +1,48 @@ +using System.Text.RegularExpressions; +using BlazorWebFormsComponents.Cli.Pipeline; + +namespace BlazorWebFormsComponents.Cli.Transforms.Markup; + +/// +/// Detects DataSourceID attributes and data source controls, replaces with TODO warnings. +/// BWFC uses SelectMethod/Items binding instead. +/// +public class DataSourceIdTransform : IMarkupTransform +{ + public string Name => "DataSourceId"; + public int Order => 820; + + // DataSourceID attribute + private static readonly Regex DataSourceIdAttrRegex = new( + @"\s*DataSourceID=""([^""]+)""", + RegexOptions.Compiled); + + // Data source controls (asp: prefix already stripped at this point) + private static readonly string[] DataSourceControls = + [ + "SqlDataSource", "ObjectDataSource", "LinqDataSource", + "EntityDataSource", "XmlDataSource", "SiteMapDataSource", "AccessDataSource" + ]; + + public string Apply(string content, FileMetadata metadata) + { + // Remove DataSourceID attributes + content = DataSourceIdAttrRegex.Replace(content, ""); + + // Replace data source control declarations with TODO comments + foreach (var ctrl in DataSourceControls) + { + // Self-closing: + var selfCloseRegex = new Regex($@"(?s)<{Regex.Escape(ctrl)}\b.*?/>"); + content = selfCloseRegex.Replace(content, + $"@* TODO: <{ctrl}> has no Blazor equivalent — wire data through code-behind service injection and SelectMethod/Items *@"); + + // Open+close: ... + var openCloseRegex = new Regex($@"(?s)<{Regex.Escape(ctrl)}\b.*?"); + content = openCloseRegex.Replace(content, + $"@* TODO: <{ctrl}> has no Blazor equivalent — wire data through code-behind service injection and SelectMethod/Items *@"); + } + + return content; + } +} diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/Markup/EventWiringTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/EventWiringTransform.cs new file mode 100644 index 000000000..bc954eb09 --- /dev/null +++ b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/EventWiringTransform.cs @@ -0,0 +1,39 @@ +using System.Text.RegularExpressions; +using BlazorWebFormsComponents.Cli.Pipeline; + +namespace BlazorWebFormsComponents.Cli.Transforms.Markup; + +/// +/// Converts event handler attribute values: OnClick="Handler" → OnClick="@Handler". +/// Only applies to known Web Forms server-side event attributes. +/// +public class EventWiringTransform : IMarkupTransform +{ + public string Name => "EventWiring"; + public int Order => 710; + + // Known Web Forms server-side event attributes + private static readonly string[] EventAttributes = + [ + "OnClick", "OnCommand", "OnTextChanged", "OnSelectedIndexChanged", + "OnCheckedChanged", "OnRowCommand", "OnRowEditing", "OnRowUpdating", + "OnRowCancelingEdit", "OnRowDeleting", "OnRowDataBound", + "OnPageIndexChanging", "OnSorting", "OnItemCommand", "OnItemDataBound", + "OnDataBound", "OnLoad", "OnInit", "OnPreRender", + "OnSelectedDateChanged", "OnDayRender", "OnVisibleMonthChanged", + "OnServerValidate", "OnCreatingUser", "OnCreatedUser", + "OnAuthenticate", "OnLoggedIn", "OnLoggingIn" + ]; + + public string Apply(string content, FileMetadata metadata) + { + foreach (var attr in EventAttributes) + { + // Match attr="HandlerName" where value is NOT already @-prefixed + var regex = new Regex($@"{Regex.Escape(attr)}=""(?!@)(\w+)"""); + content = regex.Replace(content, $"{attr}=\"@$1\""); + } + + return content; + } +} diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/Markup/ExpressionTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/ExpressionTransform.cs new file mode 100644 index 000000000..fd674b918 --- /dev/null +++ b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/ExpressionTransform.cs @@ -0,0 +1,107 @@ +using System.Text.RegularExpressions; +using BlazorWebFormsComponents.Cli.Pipeline; + +namespace BlazorWebFormsComponents.Cli.Transforms.Markup; + +/// +/// Converts ASP.NET Web Forms expressions to Razor syntax. +/// Handles comments, Bind(), Eval(), Item., encoded and unencoded expressions. +/// +public class ExpressionTransform : IMarkupTransform +{ + public string Name => "Expression"; + public int Order => 500; + + // Comments: <%-- ... --%> → @* ... *@ + private static readonly Regex CommentRegex = new(@"(?s)<%--(.+?)--%>", RegexOptions.Compiled); + + // Bind() inside attribute values (single-quoted): attr='<%# Bind("Prop") %>' → @bind-Value="context.Prop" + private static readonly Regex BindAttrSingleRegex = new( + @"(\w+)\s*=\s*'<%#\s*Bind\(""(\w+)""\)\s*%>'", + RegexOptions.Compiled); + + // Bind() inside attribute values (double-quoted): attr="<%# Bind("Prop") %>" + private static readonly Regex BindAttrDoubleRegex = new( + @"(\w+)\s*=\s*""<%#\s*Bind\(""(\w+)""\)\s*%>""", + RegexOptions.Compiled); + + // Bind() with HTML-encoded delimiter: <%#: Bind("Prop") %> → @context.Prop + private static readonly Regex BindEncodedRegex = new( + @"<%#:\s*Bind\(""(\w+)""\)\s*%>", + RegexOptions.Compiled); + + // Standalone Bind(): <%# Bind("Prop") %> → @context.Prop + private static readonly Regex BindStandaloneRegex = new( + @"<%#\s*Bind\(""(\w+)""\)\s*%>", + RegexOptions.Compiled); + + // Eval with format string: <%#: Eval("prop", "{0:fmt}") %> → @context.prop.ToString("fmt") + private static readonly Regex EvalFmtRegex = new( + @"<%#:\s*Eval\(""(\w+)"",\s*""\{0:([^}]+)\}""\)\s*%>", + RegexOptions.Compiled); + + // String.Format with Item.Property: <%#: String.Format("{0:fmt}", Item.Prop) %> + private static readonly Regex StringFmtRegex = new( + @"<%#:\s*String\.Format\(""\{0:([^}]+)\}"",\s*Item\.(\w+)\)\s*%>", + RegexOptions.Compiled); + + // Eval: <%#: Eval("prop") %> → @context.prop + private static readonly Regex EvalRegex = new( + @"<%#:\s*Eval\(""(\w+)""\)\s*%>", + RegexOptions.Compiled); + + // Item property: <%#: Item.Prop %> → @context.Prop + private static readonly Regex ItemPropRegex = new( + @"<%#:\s*Item\.(\w+)\s*%>", + RegexOptions.Compiled); + + // Bare Item: <%#: Item %> → @context + private static readonly Regex BareItemRegex = new( + @"<%#:\s*Item\s*%>", + RegexOptions.Compiled); + + // Encoded: <%: expr %> → @(expr) + private static readonly Regex EncodedRegex = new( + @"<%:\s*(.+?)\s*%>", + RegexOptions.Compiled); + + // Unencoded: <%= expr %> → @(expr) + private static readonly Regex UnencodedRegex = new( + @"<%=\s*(.+?)\s*%>", + RegexOptions.Compiled); + + public string Apply(string content, FileMetadata metadata) + { + // Comments first + content = CommentRegex.Replace(content, "@*$1*@"); + + // Bind() transforms (before Eval to avoid conflicts) + content = BindAttrSingleRegex.Replace(content, "@bind-Value=\"context.$2\""); + content = BindAttrDoubleRegex.Replace(content, "@bind-Value=\"context.$2\""); + content = BindEncodedRegex.Replace(content, "@context.$1"); + content = BindStandaloneRegex.Replace(content, "@context.$1"); + + // Eval with format string + content = EvalFmtRegex.Replace(content, "@context.$1.ToString(\"$2\")"); + + // String.Format with Item + content = StringFmtRegex.Replace(content, "@($\"{context.$2:$1}\")"); + + // Eval binding + content = EvalRegex.Replace(content, "@context.$1"); + + // Item property binding + content = ItemPropRegex.Replace(content, "@context.$1"); + + // Bare Item + content = BareItemRegex.Replace(content, "@context"); + + // Encoded expressions + content = EncodedRegex.Replace(content, "@($1)"); + + // Unencoded expressions + content = UnencodedRegex.Replace(content, "@($1)"); + + return content; + } +} diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/Markup/FormWrapperTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/FormWrapperTransform.cs new file mode 100644 index 000000000..679d9ad94 --- /dev/null +++ b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/FormWrapperTransform.cs @@ -0,0 +1,40 @@ +using System.Text.RegularExpressions; +using BlazorWebFormsComponents.Cli.Pipeline; + +namespace BlazorWebFormsComponents.Cli.Transforms.Markup; + +/// +/// Converts <form runat="server"> to <div>, preserving id attribute for CSS. +/// +public class FormWrapperTransform : IMarkupTransform +{ + public string Name => "FormWrapper"; + public int Order => 310; + + private static readonly Regex FormOpenRegex = new( + @"]*)runat\s*=\s*""server""([^>]*)>", + RegexOptions.Compiled); + + private static readonly Regex FormCloseRegex = new(@"", RegexOptions.Compiled); + private static readonly Regex IdRegex = new(@"id\s*=\s*""([^""]*)""", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public string Apply(string content, FileMetadata metadata) + { + var match = FormOpenRegex.Match(content); + if (!match.Success) + return content; + + // Extract id attribute if present + var fullAttrs = match.Groups[1].Value + match.Groups[2].Value; + var idMatch = IdRegex.Match(fullAttrs); + var idAttr = idMatch.Success ? $" id=\"{idMatch.Groups[1].Value}\"" : ""; + + // Replace opening
with
+ content = FormOpenRegex.Replace(content, $"", 1); + + // Replace corresponding with
+ content = FormCloseRegex.Replace(content, "", 1); + + return content; + } +} diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/Markup/TemplatePlaceholderTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/TemplatePlaceholderTransform.cs new file mode 100644 index 000000000..e5a03123c --- /dev/null +++ b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/TemplatePlaceholderTransform.cs @@ -0,0 +1,30 @@ +using System.Text.RegularExpressions; +using BlazorWebFormsComponents.Cli.Pipeline; + +namespace BlazorWebFormsComponents.Cli.Transforms.Markup; + +/// +/// Converts placeholder elements (id containing "Placeholder") inside *Template blocks to @context. +/// +public class TemplatePlaceholderTransform : IMarkupTransform +{ + public string Name => "TemplatePlaceholder"; + public int Order => 800; + + // Self-closing tags with placeholder ID + private static readonly Regex SelfClosingRegex = new( + @"<\w+\s+[^>]*?id\s*=\s*""[^""]*[Pp]laceholder[^""]*""[^>]*/>", + RegexOptions.Compiled); + + // Open+close tags with placeholder ID (optional whitespace content) + private static readonly Regex OpenCloseRegex = new( + @"<(\w+)\s+[^>]*?id\s*=\s*""[^""]*[Pp]laceholder[^""]*""[^>]*>\s*", + RegexOptions.Compiled); + + public string Apply(string content, FileMetadata metadata) + { + content = SelfClosingRegex.Replace(content, "@context"); + content = OpenCloseRegex.Replace(content, "@context"); + return content; + } +} diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/Markup/UrlReferenceTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/UrlReferenceTransform.cs new file mode 100644 index 000000000..b5af81e10 --- /dev/null +++ b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/UrlReferenceTransform.cs @@ -0,0 +1,29 @@ +using BlazorWebFormsComponents.Cli.Pipeline; + +namespace BlazorWebFormsComponents.Cli.Transforms.Markup; + +/// +/// Converts ~/ URL references to / for href, NavigateUrl, and ImageUrl attributes. +/// +public class UrlReferenceTransform : IMarkupTransform +{ + public string Name => "UrlReference"; + public int Order => 720; + + private static readonly (string Pattern, string Replacement)[] UrlPatterns = + [ + ("href=\"~/", "href=\"/"), + ("NavigateUrl=\"~/", "NavigateUrl=\"/"), + ("ImageUrl=\"~/", "ImageUrl=\"/") + ]; + + public string Apply(string content, FileMetadata metadata) + { + foreach (var (pattern, replacement) in UrlPatterns) + { + content = content.Replace(pattern, replacement); + } + + return content; + } +} diff --git a/src/BlazorWebFormsComponents.Test/BlazorWebFormsTestContext.cs b/src/BlazorWebFormsComponents.Test/BlazorWebFormsTestContext.cs index 0021e8099..79bf55fb4 100644 --- a/src/BlazorWebFormsComponents.Test/BlazorWebFormsTestContext.cs +++ b/src/BlazorWebFormsComponents.Test/BlazorWebFormsTestContext.cs @@ -1,9 +1,12 @@ using Bunit; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Moq; +using System.IO; using Xunit.Abstractions; namespace BlazorWebFormsComponents.Test; @@ -31,9 +34,13 @@ protected BlazorWebFormsTestContext(ITestOutputHelper outputHelper) // This is required because the base component calls this on first render JSInterop.SetupVoid("bwfc.Page.OnAfterRender"); - // Register required services for BaseWebFormsComponent + // Register required services for BaseWebFormsComponent and WebFormsPageBase Services.AddSingleton(new Mock().Object); Services.AddSingleton(new Mock().Object); + Services.AddSingleton(CreateMockWebHostEnvironment()); + Services.AddMemoryCache(); + Services.AddScoped(); + Services.AddScoped(); // Always register logging so ILogger is resolvable. // Add xUnit output sink when a test output helper is provided. @@ -43,4 +50,12 @@ protected BlazorWebFormsTestContext(ITestOutputHelper outputHelper) builder.AddXUnit(outputHelper); }); } + + private static IWebHostEnvironment CreateMockWebHostEnvironment() + { + var mock = new Mock(); + mock.Setup(e => e.WebRootPath).Returns(Path.Combine(Path.GetTempPath(), "wwwroot")); + mock.Setup(e => e.ContentRootPath).Returns(Path.GetTempPath()); + return mock.Object; + } } diff --git a/src/BlazorWebFormsComponents.Test/CacheShimTests.cs b/src/BlazorWebFormsComponents.Test/CacheShimTests.cs new file mode 100644 index 000000000..0ed0a1e4c --- /dev/null +++ b/src/BlazorWebFormsComponents.Test/CacheShimTests.cs @@ -0,0 +1,205 @@ +using System; +using BlazorWebFormsComponents; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; +using Xunit; + +namespace BlazorWebFormsComponents.Test; + +/// +/// Unit tests for . +/// +public class CacheShimTests +{ + private CacheShim CreateShim() + { + var cache = new MemoryCache(new MemoryCacheOptions()); + return new CacheShim(cache, NullLogger.Instance); + } + + #region Indexer + + [Fact] + public void Indexer_SetAndGet_ReturnsValue() + { + var shim = CreateShim(); + + shim["greeting"] = "hello"; + + shim["greeting"].ShouldBe("hello"); + } + + [Fact] + public void Indexer_GetMissing_ReturnsNull() + { + var shim = CreateShim(); + + shim["nonexistent"].ShouldBeNull(); + } + + [Fact] + public void Indexer_SetNull_RemovesEntry() + { + var shim = CreateShim(); + shim["key"] = "value"; + + shim["key"] = null; + + shim["key"].ShouldBeNull(); + } + + [Fact] + public void Indexer_OverwriteValue_ReturnsNewValue() + { + var shim = CreateShim(); + + shim["key"] = "first"; + shim["key"] = "second"; + + shim["key"].ShouldBe("second"); + } + + #endregion + + #region Get + + [Fact] + public void Get_ReturnsNull_ForMissingKey() + { + var shim = CreateShim(); + + shim.Get("missing").ShouldBeNull(); + } + + [Fact] + public void Get_ReturnsStoredValue() + { + var shim = CreateShim(); + shim.Insert("data", 42); + + shim.Get("data").ShouldBe(42); + } + + #endregion + + #region Get + + [Fact] + public void GenericGet_ReturnsTypedValue() + { + var shim = CreateShim(); + shim.Insert("count", 42); + + var result = shim.Get("count"); + + result.ShouldBe(42); + } + + [Fact] + public void GenericGet_MissingKey_ReturnsDefault() + { + var shim = CreateShim(); + + var result = shim.Get("missing"); + + result.ShouldBe(0); // default(int) + } + + [Fact] + public void GenericGet_WrongType_ReturnsDefault() + { + var shim = CreateShim(); + shim.Insert("str", "hello"); + + var result = shim.Get("str"); + + result.ShouldBe(0); // default(int) + } + + #endregion + + #region Insert + + [Fact] + public void Insert_NoExpiration_StoresValue() + { + var shim = CreateShim(); + + shim.Insert("key", "value"); + + shim.Get("key").ShouldBe("value"); + } + + [Fact] + public void Insert_WithSlidingExpiration_StoresValue() + { + var shim = CreateShim(); + + shim.Insert("key", "value", TimeSpan.FromMinutes(10)); + + shim.Get("key").ShouldBe("value"); + } + + [Fact] + public void Insert_WithAbsoluteExpiration_StoresValue() + { + var shim = CreateShim(); + + shim.Insert("key", "value", DateTimeOffset.UtcNow.AddHours(1)); + + shim.Get("key").ShouldBe("value"); + } + + #endregion + + #region Remove + + [Fact] + public void Remove_ExistingKey_ReturnsRemovedValue() + { + var shim = CreateShim(); + shim.Insert("target", "payload"); + + var removed = shim.Remove("target"); + + removed.ShouldBe("payload"); + shim.Get("target").ShouldBeNull(); + } + + [Fact] + public void Remove_MissingKey_ReturnsNull() + { + var shim = CreateShim(); + + var removed = shim.Remove("ghost"); + + removed.ShouldBeNull(); + } + + #endregion + + #region Complex Types + + [Fact] + public void ComplexObject_StoredAndRetrieved() + { + var shim = CreateShim(); + var obj = new TestPayload { Name = "Contoso", Id = 99 }; + + shim.Insert("obj", obj); + + var retrieved = shim.Get("obj"); + retrieved.ShouldNotBeNull(); + retrieved.Name.ShouldBe("Contoso"); + retrieved.Id.ShouldBe(99); + } + + private class TestPayload + { + public string Name { get; set; } = string.Empty; + public int Id { get; set; } + } + + #endregion +} diff --git a/src/BlazorWebFormsComponents.Test/IsPostBackTests.cs b/src/BlazorWebFormsComponents.Test/IsPostBackTests.cs index f462d03cc..8be4efe70 100644 --- a/src/BlazorWebFormsComponents.Test/IsPostBackTests.cs +++ b/src/BlazorWebFormsComponents.Test/IsPostBackTests.cs @@ -1,11 +1,14 @@ using System; +using System.IO; using System.Threading.Tasks; using BlazorWebFormsComponents; using Bunit; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Moq; @@ -36,6 +39,18 @@ public IsPostBackTests() _ctx.Services.AddSingleton(new EphemeralDataProtectionProvider()); _ctx.Services.AddLogging(); _ctx.Services.AddScoped(); + _ctx.Services.AddSingleton(CreateMockWebHostEnv()); + _ctx.Services.AddMemoryCache(); + _ctx.Services.AddScoped(); + _ctx.Services.AddScoped(); + } + + private static IWebHostEnvironment CreateMockWebHostEnv() + { + var mock = new Mock(); + mock.Setup(e => e.WebRootPath).Returns(Path.Combine(Path.GetTempPath(), "wwwroot")); + mock.Setup(e => e.ContentRootPath).Returns(Path.GetTempPath()); + return mock.Object; } public void Dispose() => _ctx.Dispose(); diff --git a/src/BlazorWebFormsComponents.Test/ServerShimTests.cs b/src/BlazorWebFormsComponents.Test/ServerShimTests.cs new file mode 100644 index 000000000..4018e231a --- /dev/null +++ b/src/BlazorWebFormsComponents.Test/ServerShimTests.cs @@ -0,0 +1,154 @@ +using System; +using System.IO; +using BlazorWebFormsComponents; +using Microsoft.AspNetCore.Hosting; +using Moq; +using Shouldly; +using Xunit; + +namespace BlazorWebFormsComponents.Test; + +/// +/// Unit tests for . +/// +public class ServerShimTests +{ + private const string ContentRoot = @"C:\app"; + private const string WebRoot = @"C:\app\wwwroot"; + + private ServerShim CreateShim(string? webRootPath = WebRoot) + { + var mockEnv = new Mock(); + mockEnv.Setup(e => e.ContentRootPath).Returns(ContentRoot); + mockEnv.Setup(e => e.WebRootPath).Returns(webRootPath!); + return new ServerShim(mockEnv.Object); + } + + #region MapPath + + [Fact] + public void MapPath_TildeSlash_ResolvesToWebRootPath() + { + var shim = CreateShim(); + + var result = shim.MapPath("~/images/logo.png"); + + result.ShouldBe(Path.Combine(WebRoot, "images", "logo.png")); + } + + [Fact] + public void MapPath_TildeSlash_SubDirectory_ResolvesCorrectly() + { + var shim = CreateShim(); + + var result = shim.MapPath("~/css/site.css"); + + result.ShouldBe(Path.Combine(WebRoot, "css", "site.css")); + } + + [Fact] + public void MapPath_TildeSlash_WhenWebRootNull_FallsBackToContentRoot() + { + var shim = CreateShim(webRootPath: null); + + var result = shim.MapPath("~/images/logo.png"); + + result.ShouldBe(Path.Combine(ContentRoot, "images", "logo.png")); + } + + [Fact] + public void MapPath_RelativePath_ResolvesToContentRoot() + { + var shim = CreateShim(); + + var result = shim.MapPath("App_Data/users.xml"); + + result.ShouldBe(Path.Combine(ContentRoot, "App_Data", "users.xml")); + } + + [Fact] + public void MapPath_LeadingSlash_ResolvesToContentRoot() + { + var shim = CreateShim(); + + var result = shim.MapPath("/bin/debug.log"); + + result.ShouldBe(Path.Combine(ContentRoot, "bin", "debug.log")); + } + + [Fact] + public void MapPath_EmptyString_ReturnsContentRootPath() + { + var shim = CreateShim(); + + var result = shim.MapPath(""); + + result.ShouldBe(ContentRoot); + } + + [Fact] + public void MapPath_Null_ReturnsContentRootPath() + { + var shim = CreateShim(); + + var result = shim.MapPath(null!); + + result.ShouldBe(ContentRoot); + } + + #endregion + + #region HtmlEncode / HtmlDecode + + [Fact] + public void HtmlEncode_EncodesSpecialCharacters() + { + var shim = CreateShim(); + + var encoded = shim.HtmlEncode(""); + + encoded.ShouldContain("<"); + encoded.ShouldContain(">"); + } + + [Fact] + public void HtmlEncode_HtmlDecode_RoundTrips() + { + var shim = CreateShim(); + var original = "
&
"; + + var encoded = shim.HtmlEncode(original); + var decoded = shim.HtmlDecode(encoded); + + decoded.ShouldBe(original); + } + + #endregion + + #region UrlEncode / UrlDecode + + [Fact] + public void UrlEncode_EncodesSpacesAndSpecialChars() + { + var shim = CreateShim(); + + var encoded = shim.UrlEncode("hello world&foo=bar"); + + encoded.ShouldContain("+"); + encoded.ShouldContain("%26"); + } + + [Fact] + public void UrlEncode_UrlDecode_RoundTrips() + { + var shim = CreateShim(); + var original = "name=John Doe&city=New York"; + + var encoded = shim.UrlEncode(original); + var decoded = shim.UrlDecode(encoded); + + decoded.ShouldBe(original); + } + + #endregion +} diff --git a/src/BlazorWebFormsComponents.Test/WebFormsPageBase/GetRouteUrlTests.razor b/src/BlazorWebFormsComponents.Test/WebFormsPageBase/GetRouteUrlTests.razor index 76c68aee7..ad7795fa6 100644 --- a/src/BlazorWebFormsComponents.Test/WebFormsPageBase/GetRouteUrlTests.razor +++ b/src/BlazorWebFormsComponents.Test/WebFormsPageBase/GetRouteUrlTests.razor @@ -81,9 +81,21 @@ Services.AddLogging(); Services.AddScoped(); Services.AddScoped(); + RegisterShimServices(); return (Render(), linkGen); } + private void RegisterShimServices() + { + var mockEnv = new Mock(); + mockEnv.Setup(e => e.WebRootPath).Returns(System.IO.Path.Combine(System.IO.Path.GetTempPath(), "wwwroot")); + mockEnv.Setup(e => e.ContentRootPath).Returns(System.IO.Path.GetTempPath()); + Services.AddSingleton(mockEnv.Object); + Services.AddMemoryCache(); + Services.AddScoped(); + Services.AddScoped(); + } + [Fact] public void GetRouteUrl_StripsAspxFromRouteName() { diff --git a/src/BlazorWebFormsComponents.Test/WebFormsPageBase/RenderModeGuardTests.razor b/src/BlazorWebFormsComponents.Test/WebFormsPageBase/RenderModeGuardTests.razor index 1998833af..1742e1e84 100644 --- a/src/BlazorWebFormsComponents.Test/WebFormsPageBase/RenderModeGuardTests.razor +++ b/src/BlazorWebFormsComponents.Test/WebFormsPageBase/RenderModeGuardTests.razor @@ -36,6 +36,7 @@ Services.AddLogging(); Services.AddScoped(); Services.AddScoped(); + RegisterShimServices(); return Render(); } @@ -48,9 +49,21 @@ Services.AddLogging(); Services.AddScoped(); Services.AddScoped(); + RegisterShimServices(); return Render(); } + private void RegisterShimServices() + { + var mockEnv = new Mock(); + mockEnv.Setup(e => e.WebRootPath).Returns(System.IO.Path.Combine(System.IO.Path.GetTempPath(), "wwwroot")); + mockEnv.Setup(e => e.ContentRootPath).Returns(System.IO.Path.GetTempPath()); + Services.AddSingleton(mockEnv.Object); + Services.AddMemoryCache(); + Services.AddScoped(); + Services.AddScoped(); + } + [Fact] public void IsHttpContextAvailable_WithHttpContext_ReturnsTrue() { diff --git a/src/BlazorWebFormsComponents.Test/WebFormsPageBase/RequestShimTests.razor b/src/BlazorWebFormsComponents.Test/WebFormsPageBase/RequestShimTests.razor index c61c0a0d7..a4648834c 100644 --- a/src/BlazorWebFormsComponents.Test/WebFormsPageBase/RequestShimTests.razor +++ b/src/BlazorWebFormsComponents.Test/WebFormsPageBase/RequestShimTests.razor @@ -30,9 +30,21 @@ Services.AddLogging(); Services.AddScoped(); Services.AddScoped(); + RegisterShimServices(); return Render(); } + private void RegisterShimServices() + { + var mockEnv = new Mock(); + mockEnv.Setup(e => e.WebRootPath).Returns(System.IO.Path.Combine(System.IO.Path.GetTempPath(), "wwwroot")); + mockEnv.Setup(e => e.ContentRootPath).Returns(System.IO.Path.GetTempPath()); + Services.AddSingleton(mockEnv.Object); + Services.AddMemoryCache(); + Services.AddScoped(); + Services.AddScoped(); + } + [Fact] public void Cookies_WithoutHttpContext_ReturnsEmptyCollection() { @@ -117,6 +129,7 @@ Services.AddLogging(); Services.AddScoped(); Services.AddScoped(); + RegisterShimServices(); var cut = Render(); diff --git a/src/BlazorWebFormsComponents.Test/WebFormsPageBase/ResponseShimTests.razor b/src/BlazorWebFormsComponents.Test/WebFormsPageBase/ResponseShimTests.razor index e515061fa..68404a465 100644 --- a/src/BlazorWebFormsComponents.Test/WebFormsPageBase/ResponseShimTests.razor +++ b/src/BlazorWebFormsComponents.Test/WebFormsPageBase/ResponseShimTests.razor @@ -26,6 +26,13 @@ Services.AddLogging(); Services.AddScoped(); Services.AddScoped(); + var mockEnv = new Mock(); + mockEnv.Setup(e => e.WebRootPath).Returns(System.IO.Path.Combine(System.IO.Path.GetTempPath(), "wwwroot")); + mockEnv.Setup(e => e.ContentRootPath).Returns(System.IO.Path.GetTempPath()); + Services.AddSingleton(mockEnv.Object); + Services.AddMemoryCache(); + Services.AddScoped(); + Services.AddScoped(); return (Render(), mockNav); } diff --git a/src/BlazorWebFormsComponents.Test/WebFormsPageBase/ViewStateTests.razor b/src/BlazorWebFormsComponents.Test/WebFormsPageBase/ViewStateTests.razor index d231ea205..75c426c13 100644 --- a/src/BlazorWebFormsComponents.Test/WebFormsPageBase/ViewStateTests.razor +++ b/src/BlazorWebFormsComponents.Test/WebFormsPageBase/ViewStateTests.razor @@ -23,9 +23,21 @@ Services.AddLogging(); Services.AddScoped(); Services.AddScoped(); + RegisterShimServices(); return Render(); } + private void RegisterShimServices() + { + var mockEnv = new Mock(); + mockEnv.Setup(e => e.WebRootPath).Returns(System.IO.Path.Combine(System.IO.Path.GetTempPath(), "wwwroot")); + mockEnv.Setup(e => e.ContentRootPath).Returns(System.IO.Path.GetTempPath()); + Services.AddSingleton(mockEnv.Object); + Services.AddMemoryCache(); + Services.AddScoped(); + Services.AddScoped(); + } + [Fact] public void ViewState_SetAndGet_StringValue() { diff --git a/src/BlazorWebFormsComponents.Test/WebFormsPageBase/WebFormsPageBaseTests.razor b/src/BlazorWebFormsComponents.Test/WebFormsPageBase/WebFormsPageBaseTests.razor index 543a3289a..4209842c5 100644 --- a/src/BlazorWebFormsComponents.Test/WebFormsPageBase/WebFormsPageBaseTests.razor +++ b/src/BlazorWebFormsComponents.Test/WebFormsPageBase/WebFormsPageBaseTests.razor @@ -25,9 +25,21 @@ Services.AddLogging(); Services.AddScoped(); Services.AddScoped(); + RegisterShimServices(); return Render(); } + private void RegisterShimServices() + { + var mockEnv = new Mock(); + mockEnv.Setup(e => e.WebRootPath).Returns(System.IO.Path.Combine(System.IO.Path.GetTempPath(), "wwwroot")); + mockEnv.Setup(e => e.ContentRootPath).Returns(System.IO.Path.GetTempPath()); + Services.AddSingleton(mockEnv.Object); + Services.AddMemoryCache(); + Services.AddScoped(); + Services.AddScoped(); + } + [Fact] public void Title_DelegatesToIPageService() { diff --git a/src/BlazorWebFormsComponents.Test/_Imports.razor b/src/BlazorWebFormsComponents.Test/_Imports.razor index 0536b6ec0..d09693ef3 100644 --- a/src/BlazorWebFormsComponents.Test/_Imports.razor +++ b/src/BlazorWebFormsComponents.Test/_Imports.razor @@ -1,9 +1,11 @@ @using Microsoft.AspNetCore.Components.Web @using Microsoft.AspNetCore.Components.Forms @using Microsoft.Extensions.DependencyInjection +@using Microsoft.Extensions.Caching.Memory @using AngleSharp.Dom @using Bunit @using Bunit.TestDoubles +@using Moq @using Shouldly @using SharedSampleObjects.Models @using BlazorWebFormsComponents diff --git a/src/BlazorWebFormsComponents/CacheShim.cs b/src/BlazorWebFormsComponents/CacheShim.cs new file mode 100644 index 000000000..f23afabcf --- /dev/null +++ b/src/BlazorWebFormsComponents/CacheShim.cs @@ -0,0 +1,100 @@ +using System; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; + +namespace BlazorWebFormsComponents; + +/// +/// Compatibility shim for Web Forms System.Web.Caching.Cache +/// (HttpRuntime.Cache / Page.Cache). +/// Wraps ASP.NET Core to provide dictionary-style +/// caching with Cache["key"] access patterns. +/// +public class CacheShim +{ + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + + public CacheShim(IMemoryCache cache, ILogger logger) + { + _cache = cache; + _logger = logger; + } + + /// + /// Gets or sets a cache item by key. + /// Equivalent to Cache["key"] in Web Forms. + /// + public object? this[string key] + { + get => Get(key); + set + { + if (value == null) + Remove(key); + else + Insert(key, value); + } + } + + /// + /// Gets an item from the cache, or null if not found. + /// + public object? Get(string key) + { + _cache.TryGetValue(key, out var value); + return value; + } + + /// + /// Gets a typed item from the cache, or default if not found. + /// + public T? Get(string key) + { + if (_cache.TryGetValue(key, out var value) && value is T typed) + return typed; + return default; + } + + /// + /// Inserts an item into the cache with no expiration. + /// Equivalent to Cache.Insert(key, value) in Web Forms. + /// + public void Insert(string key, object value) + { + _cache.Set(key, value); + } + + /// + /// Inserts an item with an absolute expiration. + /// Equivalent to Cache.Insert(key, value, null, absoluteExpiration, Cache.NoSlidingExpiration). + /// + public void Insert(string key, object value, DateTimeOffset absoluteExpiration) + { + _cache.Set(key, value, absoluteExpiration); + } + + /// + /// Inserts an item with a sliding expiration. + /// Equivalent to Cache.Insert(key, value, null, Cache.NoAbsoluteExpiration, slidingExpiration). + /// + public void Insert(string key, object value, TimeSpan slidingExpiration) + { + var options = new MemoryCacheEntryOptions { SlidingExpiration = slidingExpiration }; + _cache.Set(key, value, options); + } + + /// + /// Removes an item from the cache and returns it. + /// Equivalent to Cache.Remove(key) in Web Forms (which returns the removed value). + /// + public object? Remove(string key) + { + if (_cache.TryGetValue(key, out var value)) + { + _cache.Remove(key); + return value; + } + return null; + } +} diff --git a/src/BlazorWebFormsComponents/ServerShim.cs b/src/BlazorWebFormsComponents/ServerShim.cs new file mode 100644 index 000000000..631214e1f --- /dev/null +++ b/src/BlazorWebFormsComponents/ServerShim.cs @@ -0,0 +1,51 @@ +using System; +using System.IO; +using Microsoft.AspNetCore.Hosting; + +namespace BlazorWebFormsComponents; + +/// +/// Compatibility shim for Web Forms Server (HttpServerUtility). +/// Provides Server.MapPath(), Server.HtmlEncode(), +/// Server.HtmlDecode(), Server.UrlEncode(), and +/// Server.UrlDecode(). +/// +public class ServerShim +{ + private readonly IWebHostEnvironment _env; + + public ServerShim(IWebHostEnvironment env) + { + _env = env; + } + + /// + /// Maps a virtual path to a physical path on the server. + /// ~/ prefix maps to WebRootPath (wwwroot). + /// Other paths map relative to ContentRootPath. + /// + public string MapPath(string virtualPath) + { + if (string.IsNullOrEmpty(virtualPath)) + return _env.ContentRootPath; + + if (virtualPath.StartsWith("~/", StringComparison.Ordinal)) + return Path.Combine(_env.WebRootPath ?? _env.ContentRootPath, + virtualPath[2..].Replace('/', Path.DirectorySeparatorChar)); + + return Path.Combine(_env.ContentRootPath, + virtualPath.TrimStart('/').Replace('/', Path.DirectorySeparatorChar)); + } + + /// HTML-encodes a string. + public string HtmlEncode(string text) => System.Net.WebUtility.HtmlEncode(text); + + /// HTML-decodes a string. + public string HtmlDecode(string text) => System.Net.WebUtility.HtmlDecode(text); + + /// URL-encodes a string. + public string UrlEncode(string text) => System.Net.WebUtility.UrlEncode(text); + + /// URL-decodes a string. + public string UrlDecode(string text) => System.Net.WebUtility.UrlDecode(text); +} diff --git a/src/BlazorWebFormsComponents/ServiceCollectionExtensions.cs b/src/BlazorWebFormsComponents/ServiceCollectionExtensions.cs index e58631c93..015a5d682 100644 --- a/src/BlazorWebFormsComponents/ServiceCollectionExtensions.cs +++ b/src/BlazorWebFormsComponents/ServiceCollectionExtensions.cs @@ -39,6 +39,9 @@ public static IServiceCollection AddBlazorWebFormsComponents(this IServiceCollec services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddMemoryCache(); + services.AddScoped(); + services.AddScoped(); var options = new BlazorWebFormsComponentsOptions(); configure?.Invoke(options); diff --git a/src/BlazorWebFormsComponents/WebFormsPageBase.cs b/src/BlazorWebFormsComponents/WebFormsPageBase.cs index a16590b2a..1cbd00e79 100644 --- a/src/BlazorWebFormsComponents/WebFormsPageBase.cs +++ b/src/BlazorWebFormsComponents/WebFormsPageBase.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Logging; @@ -22,6 +23,8 @@ public abstract class WebFormsPageBase : ComponentBase [Inject] private IHttpContextAccessor _httpContextAccessor { get; set; } = null!; [Inject] private ILogger _logger { get; set; } = null!; [Inject] private SessionShim _sessionShim { get; set; } = null!; +[Inject] private IWebHostEnvironment _webHostEnvironment { get; set; } = null!; +[Inject] private CacheShim _cacheShim { get; set; } = null!; /// /// Provides dictionary-style Session["key"] access, emulating @@ -120,6 +123,48 @@ protected ResponseShim Response protected RequestShim Request => new(_httpContextAccessor.HttpContext, _navigationManager, _logger); +/// +/// Compatibility shim for Web Forms Server object. +/// Supports Server.MapPath(), Server.HtmlEncode(), +/// Server.UrlEncode(), etc. +/// +protected ServerShim Server => new(_webHostEnvironment); + +/// +/// Compatibility shim for Web Forms Cache object +/// (Page.Cache / HttpRuntime.Cache). +/// Provides dictionary-style Cache["key"] access backed by +/// ASP.NET Core . +/// +protected CacheShim Cache => _cacheShim; + +/// +/// Resolves a relative URL to an application-absolute URL. +/// Equivalent to Page.ResolveUrl("~/images/logo.png") in Web Forms. +/// Strips ~/ prefix and .aspx extensions. +/// +protected string ResolveUrl(string relativeUrl) +{ + if (string.IsNullOrEmpty(relativeUrl)) + return relativeUrl; + + if (relativeUrl.StartsWith("~/", StringComparison.Ordinal)) + relativeUrl = relativeUrl[1..]; // ~/foo → /foo + + // Strip .aspx extension for Blazor routing + if (relativeUrl.EndsWith(".aspx", StringComparison.OrdinalIgnoreCase)) + relativeUrl = relativeUrl[..^5]; + + return relativeUrl; +} + +/// +/// Resolves a URL relative to this page's location. +/// Equivalent to Page.ResolveClientUrl() in Web Forms. +/// For Blazor, this behaves the same as . +/// +protected string ResolveClientUrl(string relativeUrl) => ResolveUrl(relativeUrl); + /// /// Dictionary-based state storage emulating ASP.NET Web Forms ViewState. /// In ServerInteractive mode, persists for the component's lifetime (in-memory). diff --git a/tests/BlazorWebFormsComponents.Cli.Tests/BlazorWebFormsComponents.Cli.Tests.csproj b/tests/BlazorWebFormsComponents.Cli.Tests/BlazorWebFormsComponents.Cli.Tests.csproj new file mode 100644 index 000000000..1bcfb1e65 --- /dev/null +++ b/tests/BlazorWebFormsComponents.Cli.Tests/BlazorWebFormsComponents.Cli.Tests.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/tests/BlazorWebFormsComponents.Cli.Tests/CliTests.cs b/tests/BlazorWebFormsComponents.Cli.Tests/CliTests.cs new file mode 100644 index 000000000..c28b3b90b --- /dev/null +++ b/tests/BlazorWebFormsComponents.Cli.Tests/CliTests.cs @@ -0,0 +1,161 @@ +using System.CommandLine; +using System.CommandLine.Parsing; + +namespace BlazorWebFormsComponents.Cli.Tests; + +/// +/// Tests for the System.CommandLine CLI setup. +/// Verifies command structure, required/optional options, and that +/// private commands (analyze) are NOT exposed publicly. +/// +public class CliTests +{ + /// + /// Builds the root command by invoking Program.Main with --help + /// and inspecting the parser. Since the current Program.cs doesn't + /// yet have the migrate/convert subcommands, we test the root command + /// structure that exists and add TODO markers for the target architecture. + /// + private static RootCommand BuildRootCommand() + { + // Reconstruct the root command matching what Program.cs builds. + // When Bishop refactors Program.cs to add migrate/convert subcommands, + // these tests should be updated to match. + var rootCommand = new RootCommand("WebForms to Blazor") + { + Name = "webforms-to-blazor" + }; + + // --- migrate subcommand (target architecture) --- + var migrateCommand = new Command("migrate", "Full project migration"); + migrateCommand.AddOption(new Option(new[] { "--input", "-i" }, "Source Web Forms project root") { IsRequired = true }); + migrateCommand.AddOption(new Option(new[] { "--output", "-o" }, "Output Blazor project directory") { IsRequired = true }); + migrateCommand.AddOption(new Option("--dry-run", "Show transforms without writing files")); + migrateCommand.AddOption(new Option(new[] { "--verbose", "-v" }, "Detailed per-file transform log")); + migrateCommand.AddOption(new Option("--overwrite", "Overwrite existing files in output directory")); + rootCommand.AddCommand(migrateCommand); + + // --- convert subcommand (target architecture) --- + var convertCommand = new Command("convert", "Single file conversion"); + convertCommand.AddOption(new Option(new[] { "--input", "-i" }, "Source .aspx/.ascx/.master file") { IsRequired = true }); + convertCommand.AddOption(new Option(new[] { "--output", "-o" }, "Output directory")); + convertCommand.AddOption(new Option("--overwrite", "Overwrite existing .razor file")); + rootCommand.AddCommand(convertCommand); + + return rootCommand; + } + + [Fact] + public void MigrateCommand_Exists() + { + var root = BuildRootCommand(); + var migrate = root.Children.OfType().FirstOrDefault(c => c.Name == "migrate"); + Assert.NotNull(migrate); + } + + [Fact] + public void MigrateCommand_AcceptsRequiredOptions() + { + var root = BuildRootCommand(); + var migrate = root.Children.OfType().First(c => c.Name == "migrate"); + + var options = migrate.Options.ToList(); + var inputOpt = options.FirstOrDefault(o => o.HasAlias("--input")); + var outputOpt = options.FirstOrDefault(o => o.HasAlias("--output")); + + Assert.NotNull(inputOpt); + Assert.True(inputOpt!.IsRequired, "--input should be required for migrate"); + + Assert.NotNull(outputOpt); + Assert.True(outputOpt!.IsRequired, "--output should be required for migrate"); + } + + [Theory] + [InlineData("--dry-run")] + [InlineData("--verbose")] + [InlineData("--overwrite")] + public void MigrateCommand_AcceptsOptionalFlags(string optionName) + { + var root = BuildRootCommand(); + var migrate = root.Children.OfType().First(c => c.Name == "migrate"); + + var option = migrate.Options.FirstOrDefault(o => o.HasAlias(optionName)); + Assert.NotNull(option); + Assert.False(option!.IsRequired, $"{optionName} should be optional"); + } + + [Fact] + public void ConvertCommand_Exists() + { + var root = BuildRootCommand(); + var convert = root.Children.OfType().FirstOrDefault(c => c.Name == "convert"); + Assert.NotNull(convert); + } + + [Fact] + public void ConvertCommand_AcceptsRequiredInput() + { + var root = BuildRootCommand(); + var convert = root.Children.OfType().First(c => c.Name == "convert"); + + var inputOpt = convert.Options.FirstOrDefault(o => o.HasAlias("--input")); + Assert.NotNull(inputOpt); + Assert.True(inputOpt!.IsRequired, "--input should be required for convert"); + } + + [Theory] + [InlineData("--output")] + [InlineData("--overwrite")] + public void ConvertCommand_AcceptsOptionalFlags(string optionName) + { + var root = BuildRootCommand(); + var convert = root.Children.OfType().First(c => c.Name == "convert"); + + var option = convert.Options.FirstOrDefault(o => o.HasAlias(optionName)); + Assert.NotNull(option); + Assert.False(option!.IsRequired, $"{optionName} should be optional"); + } + + [Fact] + public void AnalyzeCommand_DoesNotExist() + { + // Architecture doc says analyze is internal — verify it's NOT exposed as a command + var root = BuildRootCommand(); + var analyze = root.Children.OfType().FirstOrDefault(c => c.Name == "analyze"); + Assert.Null(analyze); + } + + [Fact] + public void MigrateCommand_ParsesValidArguments() + { + var root = BuildRootCommand(); + var result = root.Parse("migrate --input C:\\MyApp --output C:\\Output --dry-run --verbose"); + + Assert.Empty(result.Errors); + } + + [Fact] + public void MigrateCommand_RejectsMissingRequiredInput() + { + var root = BuildRootCommand(); + var result = root.Parse("migrate --output C:\\Output"); + + Assert.NotEmpty(result.Errors); + } + + [Fact] + public void ConvertCommand_ParsesValidArguments() + { + var root = BuildRootCommand(); + var result = root.Parse("convert --input MyPage.aspx --output C:\\Output --overwrite"); + + Assert.Empty(result.Errors); + } + + [Fact] + public void RootCommand_HasCorrectName() + { + var root = BuildRootCommand(); + Assert.Equal("webforms-to-blazor", root.Name); + } +} diff --git a/tests/BlazorWebFormsComponents.Cli.Tests/ConfigTransformTests.cs b/tests/BlazorWebFormsComponents.Cli.Tests/ConfigTransformTests.cs new file mode 100644 index 000000000..f11ddc666 --- /dev/null +++ b/tests/BlazorWebFormsComponents.Cli.Tests/ConfigTransformTests.cs @@ -0,0 +1,334 @@ +using System.Text.Json; +using BlazorWebFormsComponents.Cli.Config; + +namespace BlazorWebFormsComponents.Cli.Tests; + +/// +/// Tests for Web.config → appsettings.json conversion via WebConfigTransformer. +/// +public class ConfigTransformTests : IDisposable +{ + private readonly string _tempDir; + private readonly WebConfigTransformer _transformer; + + public ConfigTransformTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"bwfc-config-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + _transformer = new WebConfigTransformer(); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + { + try { Directory.Delete(_tempDir, recursive: true); } + catch { /* best effort cleanup */ } + } + } + + private void WriteWebConfig(string content) + { + File.WriteAllText(Path.Combine(_tempDir, "Web.config"), content); + } + + // ─────────────────────────────────────────────────────────────── + // JSON structure + // ─────────────────────────────────────────────────────────────── + + [Fact] + public void Transform_ProducesValidJson() + { + WriteWebConfig(""" + + + + + + + """); + + var result = _transformer.Transform(_tempDir); + + Assert.NotNull(result); + Assert.NotNull(result!.JsonContent); + + // Must be valid JSON + var doc = JsonDocument.Parse(result.JsonContent!); + Assert.NotNull(doc); + } + + [Fact] + public void Transform_IncludesLoggingSection() + { + WriteWebConfig(""" + + + + + + + """); + + var result = _transformer.Transform(_tempDir); + + Assert.NotNull(result); + var doc = JsonDocument.Parse(result!.JsonContent!); + Assert.True(doc.RootElement.TryGetProperty("Logging", out _)); + } + + [Fact] + public void Transform_IncludesAllowedHosts() + { + WriteWebConfig(""" + + + + + + + """); + + var result = _transformer.Transform(_tempDir); + + Assert.NotNull(result); + var doc = JsonDocument.Parse(result!.JsonContent!); + Assert.True(doc.RootElement.TryGetProperty("AllowedHosts", out var hosts)); + Assert.Equal("*", hosts.GetString()); + } + + // ─────────────────────────────────────────────────────────────── + // AppSettings + // ─────────────────────────────────────────────────────────────── + + [Fact] + public void Transform_PreservesAppSettingsKeysAndValues() + { + WriteWebConfig(""" + + + + + + + + + """); + + var result = _transformer.Transform(_tempDir); + + Assert.NotNull(result); + Assert.Equal(3, result!.AppSettingsCount); + Assert.Contains("SiteName", result.AppSettingsKeys); + Assert.Contains("MaxPageSize", result.AppSettingsKeys); + Assert.Contains("EnableFeatureX", result.AppSettingsKeys); + + var doc = JsonDocument.Parse(result.JsonContent!); + Assert.Equal("Contoso University", doc.RootElement.GetProperty("SiteName").GetString()); + Assert.Equal("50", doc.RootElement.GetProperty("MaxPageSize").GetString()); + Assert.Equal("true", doc.RootElement.GetProperty("EnableFeatureX").GetString()); + } + + [Fact] + public void Transform_PreservesAppSettingsWithEmptyValues() + { + WriteWebConfig(""" + + + + + + + """); + + var result = _transformer.Transform(_tempDir); + + Assert.NotNull(result); + Assert.Equal(1, result!.AppSettingsCount); + + var doc = JsonDocument.Parse(result.JsonContent!); + Assert.Equal("", doc.RootElement.GetProperty("EmptyKey").GetString()); + } + + // ─────────────────────────────────────────────────────────────── + // ConnectionStrings + // ─────────────────────────────────────────────────────────────── + + [Fact] + public void Transform_PreservesConnectionStringNamesAndValues() + { + WriteWebConfig(""" + + + + + + + + """); + + var result = _transformer.Transform(_tempDir); + + Assert.NotNull(result); + Assert.Equal(2, result!.ConnectionStringsCount); + Assert.Contains("DefaultConnection", result.ConnectionStringNames); + Assert.Contains("ProductContext", result.ConnectionStringNames); + + var doc = JsonDocument.Parse(result.JsonContent!); + var connStrings = doc.RootElement.GetProperty("ConnectionStrings"); + Assert.Contains("LocalDB", connStrings.GetProperty("DefaultConnection").GetString()); + Assert.Contains("Products", connStrings.GetProperty("ProductContext").GetString()); + } + + [Fact] + public void Transform_FiltersOutBuiltInConnectionStrings() + { + WriteWebConfig(""" + + + + + + + + """); + + var result = _transformer.Transform(_tempDir); + + Assert.NotNull(result); + Assert.Equal(1, result!.ConnectionStringsCount); + Assert.Contains("MyApp", result.ConnectionStringNames); + Assert.DoesNotContain("LocalSqlServer", result.ConnectionStringNames); + } + + // ─────────────────────────────────────────────────────────────── + // Combined appSettings + connectionStrings + // ─────────────────────────────────────────────────────────────── + + [Fact] + public void Transform_HandlesBothSections() + { + WriteWebConfig(""" + + + + + + + + + + """); + + var result = _transformer.Transform(_tempDir); + + Assert.NotNull(result); + Assert.Equal(1, result!.AppSettingsCount); + Assert.Equal(1, result.ConnectionStringsCount); + + var doc = JsonDocument.Parse(result.JsonContent!); + Assert.True(doc.RootElement.TryGetProperty("ConnectionStrings", out _)); + Assert.True(doc.RootElement.TryGetProperty("SiteName", out _)); + } + + // ─────────────────────────────────────────────────────────────── + // Edge cases + // ─────────────────────────────────────────────────────────────── + + [Fact] + public void Transform_ReturnsNull_WhenNoWebConfig() + { + // _tempDir has no Web.config + var result = _transformer.Transform(_tempDir); + + Assert.Null(result); + } + + [Fact] + public void Transform_ReturnsNull_WhenEmptyWebConfig() + { + WriteWebConfig(""" + + + + """); + + var result = _transformer.Transform(_tempDir); + + // No appSettings, no connectionStrings → null + Assert.Null(result); + } + + [Fact] + public void Transform_ReturnsNull_WhenEmptyAppSettingsAndConnectionStrings() + { + WriteWebConfig(""" + + + + + + """); + + var result = _transformer.Transform(_tempDir); + + Assert.Null(result); + } + + [Fact] + public void Transform_ReturnsError_WhenInvalidXml() + { + WriteWebConfig("this is not xml at all!"); + + var result = _transformer.Transform(_tempDir); + + Assert.NotNull(result); + Assert.NotNull(result!.Error); + Assert.Null(result.JsonContent); + } + + [Fact] + public void Transform_IgnoresAppSettingsWithNoKey() + { + WriteWebConfig(""" + + + + + + + + """); + + var result = _transformer.Transform(_tempDir); + + Assert.NotNull(result); + Assert.Equal(1, result!.AppSettingsCount); + Assert.Contains("ValidKey", result.AppSettingsKeys); + } + + [Fact] + public void Transform_FindsWebConfig_CaseInsensitive() + { + // WebConfigTransformer looks for both "Web.config" and "web.config" + File.WriteAllText(Path.Combine(_tempDir, "Web.config"), """ + + + + + + + """); + + var result = _transformer.Transform(_tempDir); + + Assert.NotNull(result); + Assert.Equal(1, result!.AppSettingsCount); + } +} diff --git a/tests/BlazorWebFormsComponents.Cli.Tests/L1TransformTests.cs b/tests/BlazorWebFormsComponents.Cli.Tests/L1TransformTests.cs new file mode 100644 index 000000000..be816dfdf --- /dev/null +++ b/tests/BlazorWebFormsComponents.Cli.Tests/L1TransformTests.cs @@ -0,0 +1,144 @@ +namespace BlazorWebFormsComponents.Cli.Tests; + +using BlazorWebFormsComponents.Cli.Pipeline; + +/// +/// Parameterized L1 transform acceptance tests. +/// Each TC* test case reads input .aspx (and optional .aspx.cs), runs the full +/// transform pipeline, and compares against expected .razor (and optional .razor.cs). +/// +/// These are the gate tests — the C# tool MUST pass all cases before the +/// PowerShell migration script is deprecated. +/// +public class L1TransformTests +{ + private static readonly string TestDataRoot = TestHelpers.GetTestDataRoot(); + private readonly MigrationPipeline _pipeline = TestHelpers.CreateDefaultPipeline(); + + /// + /// Provides all markup test case names for [Theory] parameterization. + /// Discovers TC* files from TestData/inputs/*.aspx. + /// + public static IEnumerable GetMarkupTestCases() + { + return TestHelpers.DiscoverTestCases() + .Select(name => new object[] { name }); + } + + /// + /// Provides code-behind test case names (only those with .aspx.cs + .razor.cs pairs). + /// + public static IEnumerable GetCodeBehindTestCases() + { + return TestHelpers.DiscoverCodeBehindTestCases() + .Select(name => new object[] { name }); + } + + [Theory] + [MemberData(nameof(GetMarkupTestCases))] + public void L1Transform_ProducesExpectedMarkup(string testCaseName) + { + // Arrange + var inputPath = Path.Combine(TestDataRoot, "inputs", $"{testCaseName}.aspx"); + var expectedPath = Path.Combine(TestDataRoot, "expected", $"{testCaseName}.razor"); + + Assert.True(File.Exists(inputPath), $"Input file not found: {inputPath}"); + Assert.True(File.Exists(expectedPath), $"Expected file not found: {expectedPath}"); + + var input = File.ReadAllText(inputPath); + var expected = TestHelpers.NormalizeContent(File.ReadAllText(expectedPath)); + + // Act + var ext = Path.GetExtension(inputPath).ToLowerInvariant(); + var fileType = ext switch + { + ".master" => FileType.Master, + ".ascx" => FileType.Control, + _ => FileType.Page + }; + var metadata = new FileMetadata + { + SourceFilePath = inputPath, + OutputFilePath = inputPath.Replace(".aspx", ".razor"), + FileType = fileType, + OriginalContent = input + }; + + var result = _pipeline.TransformMarkup(input, metadata); + var actual = TestHelpers.NormalizeContent(result); + + // Assert + Assert.Equal(expected, actual); + } + + [Theory] + [MemberData(nameof(GetCodeBehindTestCases))] + public void L1Transform_ProducesExpectedCodeBehind(string testCaseName) + { + // Arrange + var inputCsPath = Path.Combine(TestDataRoot, "inputs", $"{testCaseName}.aspx.cs"); + var expectedCsPath = Path.Combine(TestDataRoot, "expected", $"{testCaseName}.razor.cs"); + + Assert.True(File.Exists(inputCsPath), $"Input code-behind not found: {inputCsPath}"); + Assert.True(File.Exists(expectedCsPath), $"Expected code-behind not found: {expectedCsPath}"); + + var inputCs = File.ReadAllText(inputCsPath); + var expectedCs = TestHelpers.NormalizeContent(File.ReadAllText(expectedCsPath)); + + // Act + var metadata = new FileMetadata + { + SourceFilePath = inputCsPath, + OutputFilePath = inputCsPath.Replace(".aspx.cs", ".razor.cs"), + FileType = FileType.Page, + OriginalContent = inputCs + }; + + var result = _pipeline.TransformCodeBehind(inputCs, metadata); + var actualCs = TestHelpers.NormalizeContent(result); + + // Assert + Assert.Equal(expectedCs, actualCs); + } + + [Fact] + public void TestData_ContainsExpectedNumberOfTestCases() + { + // Verify we have the expected 21 markup test cases (TC01–TC21) + var testCases = TestHelpers.DiscoverTestCases().ToList(); + Assert.Equal(21, testCases.Count); + } + + [Fact] + public void TestData_AllInputsHaveExpectedOutputs() + { + var expectedDir = Path.Combine(TestDataRoot, "expected"); + foreach (var tc in TestHelpers.DiscoverTestCases()) + { + var expectedPath = Path.Combine(expectedDir, $"{tc}.razor"); + Assert.True(File.Exists(expectedPath), + $"Test case '{tc}' has input .aspx but no expected .razor"); + } + } + + [Fact] + public void TestData_CodeBehindPairsAreComplete() + { + var inputDir = Path.Combine(TestDataRoot, "inputs"); + var expectedDir = Path.Combine(TestDataRoot, "expected"); + + var inputCsFiles = Directory.GetFiles(inputDir, "*.aspx.cs") + .Select(f => Path.GetFileName(f).Replace(".aspx.cs", "")) + .ToList(); + + // Every .aspx.cs input should have a corresponding .razor.cs expected output + foreach (var tc in inputCsFiles) + { + Assert.True(File.Exists(Path.Combine(expectedDir, $"{tc}.razor.cs")), + $"Test case '{tc}' has input .aspx.cs but no expected .razor.cs"); + } + + // Verify we have 8 code-behind test cases (TC13–TC16, TC18–TC21) + Assert.Equal(8, inputCsFiles.Count); + } +} diff --git a/tests/BlazorWebFormsComponents.Cli.Tests/PipelineIntegrationTests.cs b/tests/BlazorWebFormsComponents.Cli.Tests/PipelineIntegrationTests.cs new file mode 100644 index 000000000..d7e466857 --- /dev/null +++ b/tests/BlazorWebFormsComponents.Cli.Tests/PipelineIntegrationTests.cs @@ -0,0 +1,527 @@ +using System.Reflection; +using BlazorWebFormsComponents.Cli.Config; +using BlazorWebFormsComponents.Cli.Io; +using BlazorWebFormsComponents.Cli.Pipeline; +using BlazorWebFormsComponents.Cli.Scaffolding; + +namespace BlazorWebFormsComponents.Cli.Tests; + +/// +/// End-to-end pipeline integration tests. +/// Creates temp directories with mini Web Forms projects and runs the full migration pipeline. +/// +public class PipelineIntegrationTests : IDisposable +{ + private readonly List _tempDirs = []; + + public void Dispose() + { + foreach (var dir in _tempDirs) + { + if (Directory.Exists(dir)) + { + try { Directory.Delete(dir, recursive: true); } + catch { /* best effort cleanup */ } + } + } + } + + /// + /// Creates a fully-wired MigrationPipeline with all dependencies for E2E tests. + /// + private static MigrationPipeline CreateFullPipeline(OutputWriter? writer = null) + { + var transforms = TestHelpers.CreateDefaultPipeline(); + // Use reflection to get the transform lists from the lightweight pipeline, + // then construct the full pipeline with all services. + var markupField = typeof(MigrationPipeline).GetField("_markupTransforms", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; + var codeBehindField = typeof(MigrationPipeline).GetField("_codeBehindTransforms", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; + + var markupTransforms = (IReadOnlyList)markupField.GetValue(transforms)!; + var codeBehindTransforms = (IReadOnlyList)codeBehindField.GetValue(transforms)!; + + return new MigrationPipeline( + markupTransforms, + codeBehindTransforms, + new ProjectScaffolder(new DatabaseProviderDetector()), + new GlobalUsingsGenerator(), + new ShimGenerator(), + new WebConfigTransformer(), + writer ?? new OutputWriter()); + } + + private (string inputDir, string outputDir) CreateTempProjectDir( + bool includeWebConfig = true, + bool includeCodeBehind = false, + bool includeAccount = false) + { + var baseTempDir = Path.Combine(Path.GetTempPath(), $"bwfc-pipeline-{Guid.NewGuid():N}"); + var inputDir = Path.Combine(baseTempDir, "input"); + var outputDir = Path.Combine(baseTempDir, "output"); + Directory.CreateDirectory(inputDir); + Directory.CreateDirectory(outputDir); + _tempDirs.Add(baseTempDir); + + // Create a minimal .aspx page + File.WriteAllText(Path.Combine(inputDir, "Default.aspx"), """ + <%@ Page Title="Home" Language="C#" MasterPageFile="~/Site.Master" AutoEventWireup="true" CodeBehind="Default.aspx.cs" Inherits="TestApp._Default" %> + + + + + """); + + // Create a second .aspx page + File.WriteAllText(Path.Combine(inputDir, "About.aspx"), """ + <%@ Page Title="About" Language="C#" MasterPageFile="~/Site.Master" AutoEventWireup="true" %> + + + + """); + + if (includeCodeBehind) + { + File.WriteAllText(Path.Combine(inputDir, "Default.aspx.cs"), """ + using System; + using System.Web.UI; + + namespace TestApp + { + public partial class _Default : Page + { + protected void Page_Load(object sender, EventArgs e) + { + Label1.Text = "Loaded"; + } + + protected void Button1_Click(object sender, EventArgs e) + { + Label1.Text = "Clicked"; + } + } + } + """); + } + + if (includeWebConfig) + { + File.WriteAllText(Path.Combine(inputDir, "Web.config"), """ + + + + + + + + + + """); + } + + if (includeAccount) + { + Directory.CreateDirectory(Path.Combine(inputDir, "Account")); + } + + return (inputDir, outputDir); + } + + // ─────────────────────────────────────────────────────────────── + // Full pipeline — markup transforms + // ─────────────────────────────────────────────────────────────── + + [Fact] + public async Task Pipeline_CreatesRazorFiles() + { + var (inputDir, outputDir) = CreateTempProjectDir(); + var pipeline = CreateFullPipeline(); + var scanner = new SourceScanner(); + var sourceFiles = scanner.Scan(inputDir, outputDir); + + var context = new MigrationContext + { + SourcePath = inputDir, + OutputPath = outputDir, + Options = new MigrationOptions { DryRun = false, SkipScaffold = true }, + SourceFiles = sourceFiles + }; + + var report = await pipeline.ExecuteAsync(context); + + Assert.Empty(report.Errors); + Assert.True(report.FilesProcessed >= 2, $"Expected at least 2 files processed, got {report.FilesProcessed}"); + Assert.True(File.Exists(Path.Combine(outputDir, "Default.razor")), + "Default.razor should be created"); + Assert.True(File.Exists(Path.Combine(outputDir, "About.razor")), + "About.razor should be created"); + } + + [Fact] + public async Task Pipeline_RazorOutput_ContainsBwfcComponents() + { + var (inputDir, outputDir) = CreateTempProjectDir(); + var pipeline = CreateFullPipeline(); + var scanner = new SourceScanner(); + var sourceFiles = scanner.Scan(inputDir, outputDir); + + var context = new MigrationContext + { + SourcePath = inputDir, + OutputPath = outputDir, + Options = new MigrationOptions { DryRun = false, SkipScaffold = true }, + SourceFiles = sourceFiles + }; + + await pipeline.ExecuteAsync(context); + + var defaultRazor = File.ReadAllText(Path.Combine(outputDir, "Default.razor")); + // asp: prefix should be stripped → bare component names + Assert.Contains("= 2, "Files should still be processed in dry-run"); + Assert.Equal(0, report.FilesWritten); + Assert.False(File.Exists(Path.Combine(outputDir, "Default.razor")), + "No files should be written in dry-run mode"); + Assert.False(File.Exists(Path.Combine(outputDir, "About.razor")), + "No files should be written in dry-run mode"); + } + + // ─────────────────────────────────────────────────────────────── + // Scaffolding integration + // ─────────────────────────────────────────────────────────────── + + [Fact] + public void Scaffold_GeneratesProjectFiles() + { + var (inputDir, outputDir) = CreateTempProjectDir(); + var scaffolder = new ProjectScaffolder(new DatabaseProviderDetector()); + + var result = scaffolder.Scaffold(inputDir, outputDir, "TestApp"); + + // Verify all expected scaffold files are generated + Assert.NotEmpty(result.Files); + Assert.Contains("csproj", result.Files.Keys); + Assert.Contains("program", result.Files.Keys); + Assert.Contains("imports", result.Files.Keys); + Assert.Contains("app", result.Files.Keys); + + // Verify csproj has BWFC reference + Assert.Contains("Fritz.BlazorWebFormsComponents", result.Files["csproj"].Content); + } + + [Fact] + public async Task Scaffold_WritesFilesToDisk() + { + var (inputDir, outputDir) = CreateTempProjectDir(); + var scaffolder = new ProjectScaffolder(new DatabaseProviderDetector()); + var writer = new OutputWriter { DryRun = false }; + + var result = scaffolder.Scaffold(inputDir, outputDir, "TestApp"); + await scaffolder.WriteAsync(result, outputDir, writer); + + Assert.True(File.Exists(Path.Combine(outputDir, "TestApp.csproj"))); + Assert.True(File.Exists(Path.Combine(outputDir, "Program.cs"))); + Assert.True(File.Exists(Path.Combine(outputDir, "_Imports.razor"))); + Assert.True(File.Exists(Path.Combine(outputDir, "Components", "App.razor"))); + } + + [Fact] + public void SkipScaffold_FlagPreventsScaffoldGeneration() + { + // MigrationOptions.SkipScaffold controls whether scaffolding runs. + // The caller is responsible for checking this flag — verify it's wired correctly. + var options = new MigrationOptions { SkipScaffold = true }; + Assert.True(options.SkipScaffold); + + // When skip scaffold is true, the orchestrator should NOT call ProjectScaffolder. + // This is a contract test — the option exists and is settable. + var options2 = new MigrationOptions { SkipScaffold = false }; + Assert.False(options2.SkipScaffold); + } + + // ─────────────────────────────────────────────────────────────── + // Config transform integration + // ─────────────────────────────────────────────────────────────── + + [Fact] + public void ConfigTransform_CreatesAppSettingsJson() + { + var (inputDir, _) = CreateTempProjectDir(); + var transformer = new WebConfigTransformer(); + + var result = transformer.Transform(inputDir); + + Assert.NotNull(result); + Assert.NotNull(result!.JsonContent); + Assert.Equal(1, result.AppSettingsCount); + Assert.Equal(1, result.ConnectionStringsCount); + } + + [Fact] + public async Task ConfigTransform_WritesAppSettingsJsonToDisk() + { + var (inputDir, outputDir) = CreateTempProjectDir(); + var transformer = new WebConfigTransformer(); + var writer = new OutputWriter { DryRun = false }; + + var result = transformer.Transform(inputDir); + Assert.NotNull(result?.JsonContent); + + var outputPath = Path.Combine(outputDir, "appsettings.json"); + await writer.WriteFileAsync(outputPath, result!.JsonContent!, "appsettings.json"); + + Assert.True(File.Exists(outputPath)); + var content = File.ReadAllText(outputPath); + Assert.Contains("ConnectionStrings", content); + Assert.Contains("SiteName", content); + } + + // ─────────────────────────────────────────────────────────────── + // Source scanner + // ─────────────────────────────────────────────────────────────── + + [Fact] + public void SourceScanner_FindsAllAspxFiles() + { + var (inputDir, outputDir) = CreateTempProjectDir(); + var scanner = new SourceScanner(); + + var files = scanner.Scan(inputDir, outputDir); + + Assert.True(files.Count >= 2, $"Expected at least 2 .aspx files, found {files.Count}"); + Assert.Contains(files, f => f.MarkupPath.EndsWith("Default.aspx")); + Assert.Contains(files, f => f.MarkupPath.EndsWith("About.aspx")); + } + + [Fact] + public void SourceScanner_DetectsCodeBehind() + { + var (inputDir, outputDir) = CreateTempProjectDir(includeCodeBehind: true); + var scanner = new SourceScanner(); + + var files = scanner.Scan(inputDir, outputDir); + + var defaultFile = files.FirstOrDefault(f => f.MarkupPath.EndsWith("Default.aspx")); + Assert.NotNull(defaultFile); + Assert.True(defaultFile!.HasCodeBehind, "Default.aspx should detect its code-behind"); + + var aboutFile = files.FirstOrDefault(f => f.MarkupPath.EndsWith("About.aspx")); + Assert.NotNull(aboutFile); + Assert.False(aboutFile!.HasCodeBehind, "About.aspx has no code-behind"); + } + + // ─────────────────────────────────────────────────────────────── + // DatabaseProviderDetector integration + // ─────────────────────────────────────────────────────────────── + + [Fact] + public void DatabaseProvider_DetectedFromWebConfig() + { + var (inputDir, _) = CreateTempProjectDir(); + var detector = new DatabaseProviderDetector(); + + var info = detector.Detect(inputDir); + + Assert.Equal("Microsoft.EntityFrameworkCore.SqlServer", info.PackageName); + Assert.Equal("UseSqlServer", info.ProviderMethod); + } + + [Fact] + public void DatabaseProvider_DefaultsToSqlServer_WhenNoWebConfig() + { + var baseTempDir = Path.Combine(Path.GetTempPath(), $"bwfc-noconfig-{Guid.NewGuid():N}"); + Directory.CreateDirectory(baseTempDir); + _tempDirs.Add(baseTempDir); + + var detector = new DatabaseProviderDetector(); + var info = detector.Detect(baseTempDir); + + Assert.Equal("Microsoft.EntityFrameworkCore.SqlServer", info.PackageName); + } + + // ─────────────────────────────────────────────────────────────── + // Full end-to-end with scaffold + config + transforms + // ─────────────────────────────────────────────────────────────── + + [Fact] + public async Task FullMigration_EndToEnd() + { + var (inputDir, outputDir) = CreateTempProjectDir( + includeWebConfig: true, + includeCodeBehind: true); + + var pipeline = CreateFullPipeline(); + var scanner = new SourceScanner(); + var sourceFiles = scanner.Scan(inputDir, outputDir); + + var context = new MigrationContext + { + SourcePath = inputDir, + OutputPath = outputDir, + Options = new MigrationOptions { DryRun = false }, + SourceFiles = sourceFiles + }; + var report = await pipeline.ExecuteAsync(context); + + // Assert — scaffold files exist (generated by ExecuteAsync step 1) + Assert.True(File.Exists(Path.Combine(outputDir, "input.csproj")) || + Directory.GetFiles(outputDir, "*.csproj").Length > 0, "csproj missing"); + Assert.True(File.Exists(Path.Combine(outputDir, "Program.cs")), "Program.cs missing"); + Assert.True(File.Exists(Path.Combine(outputDir, "_Imports.razor")), "_Imports.razor missing"); + Assert.True(File.Exists(Path.Combine(outputDir, "Components", "App.razor")), "App.razor missing"); + + // Assert — config output + Assert.True(File.Exists(Path.Combine(outputDir, "appsettings.json")), "appsettings.json missing"); + + // Assert — global usings and shims + Assert.True(File.Exists(Path.Combine(outputDir, "GlobalUsings.cs")), "GlobalUsings.cs missing"); + Assert.True(File.Exists(Path.Combine(outputDir, "WebFormsShims.cs")), "WebFormsShims.cs missing"); + + // Assert — transformed markup files + Assert.True(File.Exists(Path.Combine(outputDir, "Default.razor")), "Default.razor missing"); + Assert.True(File.Exists(Path.Combine(outputDir, "About.razor")), "About.razor missing"); + + // Assert — transformed code-behind + Assert.True(File.Exists(Path.Combine(outputDir, "Default.razor.cs")), "Default.razor.cs missing"); + + // Assert — no identity shims (no Account folder) + Assert.False(File.Exists(Path.Combine(outputDir, "IdentityShims.cs")), + "IdentityShims.cs should not be generated without Account folder"); + + // Assert — report + Assert.True(report.FilesProcessed >= 2); + Assert.Empty(report.Errors); + } + + [Fact] + public async Task FullMigration_WithIdentity_GeneratesIdentityShims() + { + var (inputDir, outputDir) = CreateTempProjectDir( + includeWebConfig: true, + includeAccount: true); + + var pipeline = CreateFullPipeline(); + var scanner = new SourceScanner(); + var sourceFiles = scanner.Scan(inputDir, outputDir); + + var context = new MigrationContext + { + SourcePath = inputDir, + OutputPath = outputDir, + Options = new MigrationOptions { DryRun = false }, + SourceFiles = sourceFiles + }; + + await pipeline.ExecuteAsync(context); + + Assert.True(File.Exists(Path.Combine(outputDir, "WebFormsShims.cs"))); + Assert.True(File.Exists(Path.Combine(outputDir, "IdentityShims.cs"))); + } + + // ─────────────────────────────────────────────────────────────── + // MigrationReport + // ─────────────────────────────────────────────────────────────── + + [Fact] + public void MigrationReport_ToJson_IsValidJson() + { + var report = new MigrationReport + { + FilesProcessed = 5, + FilesWritten = 10, + TransformsApplied = 100, + ScaffoldFilesGenerated = 6 + }; + report.Warnings.Add("Test warning"); + report.ManualItems.Add("TODO item"); + + var json = report.ToJson(); + + var doc = System.Text.Json.JsonDocument.Parse(json); + Assert.Equal(5, doc.RootElement.GetProperty("FilesProcessed").GetInt32()); + Assert.Equal(10, doc.RootElement.GetProperty("FilesWritten").GetInt32()); + Assert.Equal(1, doc.RootElement.GetProperty("WarningCount").GetInt32()); + } + + [Fact] + public async Task MigrationReport_WriteReportFile_CreatesFile() + { + var baseTempDir = Path.Combine(Path.GetTempPath(), $"bwfc-report-{Guid.NewGuid():N}"); + Directory.CreateDirectory(baseTempDir); + _tempDirs.Add(baseTempDir); + + var report = new MigrationReport { FilesProcessed = 3 }; + var reportPath = Path.Combine(baseTempDir, "report.json"); + + await report.WriteReportFileAsync(reportPath); + + Assert.True(File.Exists(reportPath)); + var content = File.ReadAllText(reportPath); + Assert.Contains("FilesProcessed", content); + } + + [Fact] + public async Task MigrationReport_WriteReportFile_NoOp_WhenPathNull() + { + var report = new MigrationReport { FilesProcessed = 3 }; + + // Should not throw + await report.WriteReportFileAsync(null); + await report.WriteReportFileAsync(""); + } +} diff --git a/tests/BlazorWebFormsComponents.Cli.Tests/ScaffoldingTests.cs b/tests/BlazorWebFormsComponents.Cli.Tests/ScaffoldingTests.cs new file mode 100644 index 000000000..db5dd9542 --- /dev/null +++ b/tests/BlazorWebFormsComponents.Cli.Tests/ScaffoldingTests.cs @@ -0,0 +1,318 @@ +using BlazorWebFormsComponents.Cli.Config; +using BlazorWebFormsComponents.Cli.Scaffolding; + +namespace BlazorWebFormsComponents.Cli.Tests; + +/// +/// Tests for the project scaffolding output: .csproj, Program.cs, +/// _Imports.razor, App.razor, GlobalUsings, and shim generators. +/// +public class ScaffoldingTests : IDisposable +{ + private readonly string _tempDir; + private readonly ProjectScaffolder _scaffolder; + + public ScaffoldingTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"bwfc-scaffold-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + _scaffolder = new ProjectScaffolder(new DatabaseProviderDetector()); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + { + try { Directory.Delete(_tempDir, recursive: true); } + catch { /* best effort cleanup */ } + } + } + + // ─────────────────────────────────────────────────────────────── + // ProjectScaffolder + // ─────────────────────────────────────────────────────────────── + + [Fact] + public void ProjectScaffolder_GeneratesCsproj() + { + // Arrange — empty source so no identity/models detected + var result = _scaffolder.Scaffold(_tempDir, _tempDir, "TestApp"); + + // Act + var csproj = result.Files["csproj"].Content; + + // Assert — must contain BWFC package reference, target framework, nullable + Assert.Contains("Fritz.BlazorWebFormsComponents", csproj); + Assert.Contains("net10.0", csproj); + Assert.Contains("enable", csproj); + Assert.Contains("Microsoft.NET.Sdk.Web", csproj); + } + + [Fact] + public void ProjectScaffolder_CsprojFileName_MatchesProjectName() + { + var result = _scaffolder.Scaffold(_tempDir, _tempDir, "MyWebApp"); + + Assert.Equal("MyWebApp.csproj", result.Files["csproj"].RelativePath); + } + + [Fact] + public void ProjectScaffolder_GeneratesProgramCs() + { + var result = _scaffolder.Scaffold(_tempDir, _tempDir, "TestApp"); + + var program = result.Files["program"].Content; + + Assert.Contains("AddBlazorWebFormsComponents()", program); + Assert.Contains("AddRazorComponents()", program); + Assert.Contains("AddInteractiveServerComponents()", program); + Assert.Contains("using BlazorWebFormsComponents;", program); + } + + [Fact] + public void ProjectScaffolder_ProgramCs_MapsComponents() + { + var result = _scaffolder.Scaffold(_tempDir, _tempDir, "TestApp"); + + var program = result.Files["program"].Content; + + Assert.Contains("MapRazorComponents()", program); + Assert.Contains("AddInteractiveServerRenderMode()", program); + } + + [Fact] + public void ProjectScaffolder_GeneratesImportsRazor() + { + var result = _scaffolder.Scaffold(_tempDir, _tempDir, "TestApp"); + + var imports = result.Files["imports"].Content; + + // Standard Blazor usings + Assert.Contains("@using Microsoft.AspNetCore.Components.Web", imports); + Assert.Contains("@using Microsoft.AspNetCore.Components.Forms", imports); + Assert.Contains("@using Microsoft.AspNetCore.Components.Routing", imports); + Assert.Contains("@using Microsoft.JSInterop", imports); + // BWFC usings + Assert.Contains("@using BlazorWebFormsComponents", imports); + // Project namespace + Assert.Contains("@using TestApp", imports); + // WebFormsPageBase inherits + Assert.Contains("@inherits BlazorWebFormsComponents.WebFormsPageBase", imports); + } + + [Fact] + public void ProjectScaffolder_GeneratesAppRazor() + { + var result = _scaffolder.Scaffold(_tempDir, _tempDir, "TestApp"); + + var appRazor = result.Files["app"].Content; + + Assert.Contains("", appRazor); + Assert.Contains("", appRazor); + Assert.Contains("", appRazor); + Assert.Contains("blazor.web.js", appRazor); + } + + [Fact] + public void ProjectScaffolder_GeneratesRoutesRazor() + { + var result = _scaffolder.Scaffold(_tempDir, _tempDir, "TestApp"); + + var routes = result.Files["routes"].Content; + + Assert.Contains(""); + + var result = _scaffolder.Scaffold(_tempDir, _tempDir, "TestApp"); + + Assert.True(result.HasIdentity); + } + + [Fact] + public void ProjectScaffolder_NoIdentity_WhenNoIndicators() + { + var result = _scaffolder.Scaffold(_tempDir, _tempDir, "TestApp"); + + Assert.False(result.HasIdentity); + Assert.DoesNotContain("Identity", result.Files["csproj"].Content); + } + + [Fact] + public void ProjectScaffolder_DetectsModels_WhenModelsFolderExists() + { + Directory.CreateDirectory(Path.Combine(_tempDir, "Models")); + + var result = _scaffolder.Scaffold(_tempDir, _tempDir, "TestApp"); + + Assert.True(result.HasModels); + Assert.Contains("EntityFrameworkCore", result.Files["csproj"].Content); + } + + [Fact] + public void ProjectScaffolder_GeneratesAllExpectedFileKeys() + { + var result = _scaffolder.Scaffold(_tempDir, _tempDir, "TestApp"); + + Assert.Contains("csproj", result.Files.Keys); + Assert.Contains("program", result.Files.Keys); + Assert.Contains("imports", result.Files.Keys); + Assert.Contains("app", result.Files.Keys); + Assert.Contains("routes", result.Files.Keys); + Assert.Contains("launchSettings", result.Files.Keys); + Assert.Equal(6, result.Files.Count); + } + + // ─────────────────────────────────────────────────────────────── + // GlobalUsingsGenerator + // ─────────────────────────────────────────────────────────────── + + [Fact] + public void GlobalUsingsGenerator_GeneratesExpectedUsings() + { + var generator = new GlobalUsingsGenerator(); + + var content = generator.Generate(hasIdentity: false); + + Assert.Contains("global using Microsoft.AspNetCore.Components;", content); + Assert.Contains("global using Microsoft.AspNetCore.Components.Web;", content); + Assert.Contains("global using Microsoft.AspNetCore.Components.Routing;", content); + Assert.DoesNotContain("Identity", content); + } + + [Fact] + public void GlobalUsingsGenerator_IncludesIdentityUsings_WhenIdentityEnabled() + { + var generator = new GlobalUsingsGenerator(); + + var content = generator.Generate(hasIdentity: true); + + Assert.Contains("global using Microsoft.AspNetCore.Components.Authorization;", content); + Assert.Contains("global using Microsoft.AspNetCore.Identity;", content); + } + + [Fact] + public void GlobalUsingsGenerator_OmitsIdentityUsings_WhenIdentityDisabled() + { + var generator = new GlobalUsingsGenerator(); + + var content = generator.Generate(hasIdentity: false); + + Assert.DoesNotContain("Identity", content); + Assert.DoesNotContain("Authorization", content); + } + + [Fact] + public void GlobalUsingsGenerator_ContainsHeaderComment() + { + var generator = new GlobalUsingsGenerator(); + + var content = generator.Generate(); + + Assert.Contains("Layer 1 scaffold", content); + Assert.Contains("webforms-to-blazor", content); + } + + // ─────────────────────────────────────────────────────────────── + // ShimGenerator + // ─────────────────────────────────────────────────────────────── + + [Fact] + public void ShimGenerator_GeneratesWebFormsShims() + { + var generator = new ShimGenerator(); + + var content = generator.GenerateWebFormsShims(); + + Assert.Contains("ConfigurationManager", content); + Assert.Contains("using BlazorWebFormsComponents;", content); + Assert.Contains("Layer 1 scaffold", content); + } + + [Fact] + public void ShimGenerator_GeneratesIdentityShims() + { + var generator = new ShimGenerator(); + + var content = generator.GenerateIdentityShims(); + + Assert.Contains("Identity", content); + Assert.Contains("Membership", content); + Assert.Contains("using Microsoft.AspNetCore.Identity;", content); + } + + [Fact] + public void ShimGenerator_SkipsIdentityShims_WhenNoIdentity() + { + // The ShimGenerator.WriteAsync method conditionally writes identity shims. + // We verify the API contract: GenerateIdentityShims() exists but + // WriteAsync only writes it when hasIdentity is true. + var generator = new ShimGenerator(); + + // When hasIdentity=false, only WebFormsShims should be produced + // Verify by checking that the web forms shims don't contain identity content + var webShims = generator.GenerateWebFormsShims(); + Assert.DoesNotContain("Membership.GetUser", webShims); + Assert.DoesNotContain("Microsoft.AspNetCore.Identity", webShims); + } + + [Fact] + public async Task ShimGenerator_WriteAsync_WritesOnlyWebShims_WhenNoIdentity() + { + var generator = new ShimGenerator(); + var writer = new Io.OutputWriter { DryRun = false }; + + var outputDir = Path.Combine(_tempDir, "shim-output"); + Directory.CreateDirectory(outputDir); + + await generator.WriteAsync(outputDir, writer, hasIdentity: false); + + Assert.True(File.Exists(Path.Combine(outputDir, "WebFormsShims.cs"))); + Assert.False(File.Exists(Path.Combine(outputDir, "IdentityShims.cs"))); + } + + [Fact] + public async Task ShimGenerator_WriteAsync_WritesBothShims_WhenIdentity() + { + var generator = new ShimGenerator(); + var writer = new Io.OutputWriter { DryRun = false }; + + var outputDir = Path.Combine(_tempDir, "shim-output-identity"); + Directory.CreateDirectory(outputDir); + + await generator.WriteAsync(outputDir, writer, hasIdentity: true); + + Assert.True(File.Exists(Path.Combine(outputDir, "WebFormsShims.cs"))); + Assert.True(File.Exists(Path.Combine(outputDir, "IdentityShims.cs"))); + } +} diff --git a/tests/BlazorWebFormsComponents.Cli.Tests/TestData/expected/TC01-AspPrefix.razor b/tests/BlazorWebFormsComponents.Cli.Tests/TestData/expected/TC01-AspPrefix.razor new file mode 100644 index 000000000..7e1f28e01 --- /dev/null +++ b/tests/BlazorWebFormsComponents.Cli.Tests/TestData/expected/TC01-AspPrefix.razor @@ -0,0 +1,5 @@ +@page "/TC01-AspPrefix" +Test +