Changes to the Main GuyStore App
GuyStore.csproj - added package
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.4" />
Required so integration and E2E tests can substitute the InMemory provider for SQL Server at runtime.
Program.cs - three changes
1. Environment-aware DB registration
if (builder.Environment.IsEnvironment("Testing"))
{
var testDbName = "GuyStoreTestDb_" + Guid.NewGuid(); // unique per run
builder.Services.AddDbContext<ApplicationDbContext>(opt =>
opt.UseInMemoryDatabase(testDbName));
}
else
{
builder.Services.AddDbContext<ApplicationDbContext>(opt =>
opt.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
}
The Guid is captured outside the lambda.
If it were inside, EF Core would generate a new DB name per DbContext
instance and products seeded in one context would be invisible to another.
2. Environment-aware migration vs EnsureCreated
if (app.Environment.IsEnvironment("Testing"))
await db.Database.EnsureCreatedAsync(); // InMemory has no migrations
else
await db.Database.MigrateAsync();
3. Partial Program class for WebApplicationFactory
// bottom of Program.cs
public partial class Program { }
WebApplicationFactory<Program> needs a
public or internal reference to
Program. The minimal-API entry-point is a compiler-generated
class with limited accessibility; the partial declaration promotes it.
Bugs Found and Fixed During the Build
All bugs below were discovered while getting the tests to pass - not pre-existing issues logged elsewhere.
BUG 1 - xUnit Fact/Theory attributes not found (all three projects)
Symptom: Every test file reported "The type or namespace name 'Fact' could not be found."
Root cause: No using Xunit; in any test file.
Fix: Added GlobalUsings.cs with global using Xunit; to each project - one file eliminates the import from every test class.
BUG 2 - EF Core dual-provider conflict ("services for 'SqlServer' and 'InMemory' both registered")
Symptom: Integration tests threw InvalidOperationException on first DB access.
Root cause: The WebApplicationFactory attempted to
remove only the SQL Server DbContextOptions descriptor and add InMemory,
but EF Core 9 still saw both providers registered.
Fix: Made Program.cs fully environment-aware (see above).
The factory only needs builder.UseEnvironment("Testing") - no patching of
ConfigureServices required.
BUG 3 - InMemory DB differs per request (seeded products not visible in shop)
Symptom: Integration tests for the shop index returned an empty product list even though seeding succeeded.
Root cause: Guid.NewGuid() was called inside
the AddDbContext lambda, so every new DbContext
instance received a different database name - seeded data was in DB "X" while the controller
queried DB "Y".
Fix: Captured the name once outside the lambda:
var testDbName = "GuyStoreTestDb_" + Guid.NewGuid(); … opt.UseInMemoryDatabase(testDbName);
BUG 4 - Shared HttpClient auth state between integration tests
Symptom: Test ordering mattered - if test A logged in, test B (which expected to be unauthenticated) saw an auth cookie.
Root cause: All tests in a class shared one _client
field; cookies accumulated across tests.
Fix: Each test calls FreshClient() which creates a
new HttpClient from the factory per test, with an isolated cookie jar.
BUG 5 - E2E server port conflict ("address already in use")
Symptom: E2E tests failed intermittently when re-run shortly after a previous run - the previous Kestrel instance hadn't fully released port 5099.
Root cause: PlaywrightFixture had a hardcoded port.
Fix: Changed to http://127.0.0.1:0 (OS picks a free port)
and read the actual port back via IServerAddressesFeature.
BUG 6 - Playwright pages showed blank/error content (all E2E tests timing out)
Symptom: Every E2E test timed out with "waiting for Locator(input[name='Email'])". The server started, but no page rendered the expected HTML.
Root cause: PlaywrightFixture creates a new
WebApplication inside the test process. Without
ApplicationName = "GuyStore" in WebApplicationOptions,
ASP.NET Core's MVC view engine used the test assembly name
(GuyStore.Tests.E2E) to locate precompiled Razor views.
It couldn't find them in GuyStore.dll, so every route rendered an error page.
Fix: Added ApplicationName = "GuyStore" to
WebApplicationOptions. This tells the view engine to look for compiled
views in GuyStore.dll, not the test DLL.
BUG 7 - Submit button found but "not visible" (login/checkout E2E tests)
Symptom: After the ApplicationName fix, tests progressed but timed out trying
to click button[type='submit'].
Playwright reported the element "not visible".
Root cause: The layout's navbar contains Bootstrap dropdown menus for the
theme switcher (and logout). These menus include button[type='submit']
elements that appear before the login/checkout buttons in DOM order.
Bootstrap dropdowns are hidden by default (display: none), so
page.ClickAsync("button[type='submit']") found the hidden dropdown
button first and tried (and failed) to click it.
Fix: Changed all login/checkout button selectors to
button.btn-primary[type='submit']. The main action buttons use the
Bootstrap btn-primary class; the dropdown theme/logout buttons use
dropdown-item, so this selector is unambiguous.
BUG 8 - "Add to Cart" assertion fails for unauthenticated users (E2E shop detail)
Symptom: Shop_ProductDetail_ShowsProductInfo asserted
Assert.Contains("Add to Cart", html) but the page showed
"Login to Order" instead.
Root cause: The Shop Detail view conditionally renders
Add to Cart only when the user is authenticated.
Unauthenticated visitors see Login to Order.
The test navigated as an unauthenticated user.
Fix:
Assert.True(html.Contains("Add to Cart") || html.Contains("Login to Order"))
BUG 9 - Theme switch tests: hidden dropdown elements and GET vs POST mismatch
Symptom: SwitchToDark timed out trying to click
a hidden <input type="hidden" name="theme" value="dark">.
SwitchToCorporate failed with cookie null because
page.GotoAsync("/Theme/Switch?theme=corporate") issued a GET,
but the controller action is [HttpPost] only (405 returned, no cookie set).
Root cause: Both theme switch tests tried to interact with elements inside
Bootstrap dropdowns (hidden by default). The locator
input[name='theme'][value='dark'] matched the hidden field, and
CountAsync() > 0 returned true - so the test tried to click
the hidden input rather than falling through to the else branch.
Fix: Both tests now submit the theme form directly via JavaScript - bypassing
the hidden dropdown - and use RunAndWaitForNavigationAsync to
correctly wait for the POST → redirect → page-load sequence before reading cookies.
await page.RunAndWaitForNavigationAsync(async () =>
await page.EvaluateAsync(
"document.querySelector(\"input[name='theme'][value='dark']\").closest('form').submit()"),
new PageRunAndWaitForNavigationOptions { WaitUntil = WaitUntilState.NetworkIdle });
BUG 10 - SwitchToDark: race condition between form.submit() and WaitForLoadStateAsync
Symptom: After switching to the JS form-submit approach, SwitchToCorporate
passed but SwitchToDark failed with Assert.NotNull() - cookie is null
even in isolation (no prior tests interfering).
Root cause: form.submit() triggers a navigation
asynchronously. Playwright's EvaluateAsync returns as soon as the
JavaScript expression returns (i.e. immediately after calling submit(),
not after the navigation completes). If the subsequent
WaitForLoadStateAsync(NetworkIdle) fires before the navigation has
started, it sees the page as already idle and returns immediately - before the POST response
and its Set-Cookie header have been processed.
Why corporate didn't hit this race: Corporate test runs first in a fresh context; the timing happened to be forgiving. Dark runs second (or in isolation) where the race consistently fires.
Fix: RunAndWaitForNavigationAsync starts listening for
a navigation event before the trigger fires, so it reliably waits for the full
POST → 302 → GET cycle to complete before the test reads cookies.
BUG 11 - Logout button: hidden inside Bootstrap dropdown
Symptom: Logout_AuthenticatedUser_RedirectsToHome timed
out - Playwright found the logout button in the user dropdown but it was invisible
(display: none on the closed dropdown menu).
Root cause: The Sign Out button is inside a
.dropdown-menu that is only visible when the user clicks the
dropdown toggle.
Fix: Bypass visibility by submitting the logout form via JavaScript.
The form already contains the rendered __RequestVerificationToken
hidden field, so the anti-forgery check passes.
await page.EvaluateAsync("document.querySelector(\"form[action*='Logout']\").submit()");
BUG 13 - Voice Camera: InvalidStateError when restarting SpeechRecognition after photo capture
File: wwwroot/js/voice-camera.js — recognition.onend handler and startVoice() function.
Symptom: After taking a photo via voice command, the browser console reported "InvalidStateError: recognition has already started" on the keep-alive restart attempt.
Root cause: When a photo was captured, capturePhoto() called stopVoice(),
which set isListening = false and called recognition.stop().
However, iOS Safari (and some desktop browsers) also fires onend independently
when recognition auto-stops after detecting a result. If onend fired while
isListening was still true (race condition between the
auto-stop and the manual stop from the capture), the handler called recognition.start()
on the same object while its internal state machine was still in the "ending" phase — not yet idle —
throwing InvalidStateError.
Even without the race, calling start() immediately inside onend
is inherently unsafe because the browser has not fully torn down the session at the point
onend fires.
Fix — two-part change:
1. recognition.onend: Replaced the immediate
recognition.start() call with a setTimeout(startVoice, 150).
The 150 ms gap lets the browser fully close the previous session before a new one is opened.
An early-return guard (if (!isListening) return) ensures no restart
happens after a photo capture has already set isListening = false.
// Before (broken)
recognition.onend = () => {
if (isListening) recognition.start(); // keep alive
};
// After (fixed)
recognition.onend = () => {
if (!isListening) return;
setTimeout(startVoice, 150); // fresh instance avoids InvalidStateError
};
2. startVoice(): Before creating a new
SpeechRecognition instance, any existing instance has its
onend handler nulled out first, then stopped.
Nulling onend is the critical step — without it, calling
recognition.stop() inside startVoice()
would fire onend → startVoice →
stop() again, creating an infinite restart loop.
// Added at the top of startVoice(), before new SpeechRecognition()
if (recognition) { recognition.onend = null; recognition.stop(); recognition = null; }
Together these changes guarantee that recognition.start() is always
called on a brand-new, never-started instance with no lingering state from a previous session.
BUG 12 - Razor tag helper: C# variable used as bare HTML attribute in <option>
File: Views/VoiceFeature/Translate.cshtml — both the source-language and target-language <select> loops.
Symptom: Build error: "The tag helper 'option' must not have C# in the element's attribute declaration area."
Root cause: A C# variable was placed directly in the attribute list of an <option> element —
<option value="@l.Code" @selected> — which is not legal in a Razor view that has
tag helpers active. The Razor parser treats option as a tag helper and
rejects bare C# expressions inside the opening tag.
Fix: Use the selected attribute with a null-conditional expression.
Razor omits the attribute entirely when the value is null, so no selected=""
is rendered for non-matching options:
<!-- Before (broken) -->
@{
var selected = l.Code == "zh-TW" ? "selected" : "";
}
<option value="@l.Code" @selected>@l.NativeName — @l.Name</option>
<!-- After (fixed) -->
<option value="@l.Code" selected="@(l.Code == "zh-TW" ? "selected" : null)">@l.NativeName — @l.Name</option>
BUG 14 — VS 2022 build warnings: QuestPDF version pin & Playwright obsolete API (2026-05-27)
Warning 1 — NU1603: QuestPDF version mismatch (all 4 projects)
Symptom: Every project in the solution emitted:
warning NU1603: GuyStore depends on QuestPDF (>= 2024.10.5) but QuestPDF 2024.10.5 was not found.
QuestPDF 2024.12.0 was resolved instead.
Root cause: GuyStore.csproj pinned
QuestPDF to an exact minimum of 2024.10.5,
but that version is no longer available on NuGet; the lowest available release in the
2024.x family is now 2024.12.0.
NuGet resolved to the nearest available version but logged the mismatch as a warning.
All three test projects inherited the same warning transitively.
Fix: Updated the version reference in GuyStore.csproj
to match exactly what NuGet resolved:
<!-- Before -->
<PackageReference Include="QuestPDF" Version="2024.10.5" />
<!-- After -->
<PackageReference Include="QuestPDF" Version="2024.12.0" />
Warning 2 — CS0612: RunAndWaitForNavigationAsync is obsolete (20 call sites, 6 E2E test files)
Symptom: 20 CS0612 warnings across the E2E project:
warning CS0612: 'IPage.RunAndWaitForNavigationAsync(Func<Task>, PageRunAndWaitForNavigationOptions?)' is obsolete
Affected files:
ReviewTests.cs (3),
DiscountTests.cs (5),
AddressTests.cs (3),
Stage2Tests.cs (2),
ThemeTests.cs (2),
InvoiceTests.cs (3),
Stage3Tests.cs (2).
Root cause: IPage.RunAndWaitForNavigationAsync was
deprecated in Microsoft.Playwright 1.26+. The method still compiles and runs, but Playwright
marks it [Obsolete] in favour of a simpler sequential pattern.
Fix: Replaced every call with the equivalent two-step sequence — trigger the
action first, then wait for the page to reach NetworkIdle:
// Before (obsolete) — click + navigation bundled
await page.RunAndWaitForNavigationAsync(
() => page.ClickAsync("button[type='submit']"),
new PageRunAndWaitForNavigationOptions { WaitUntil = WaitUntilState.NetworkIdle });
// After — equivalent, no obsolete API
await page.ClickAsync("button[type='submit']");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
For the two EvaluateAsync (JS form-submit) variants in
ThemeTests.cs and Stage2Tests.cs:
// Before
await page.RunAndWaitForNavigationAsync(async () =>
await page.EvaluateAsync("document.querySelector(...).closest('form').submit()"),
new PageRunAndWaitForNavigationOptions { WaitUntil = WaitUntilState.NetworkIdle });
// After
await page.EvaluateAsync("document.querySelector(...).closest('form').submit()");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
Result after both fixes:
Build succeeded.
0 Warning(s)
0 Error(s)
Voice Feature
Two browser-based voice tools added to the main navigation under a 🎤 Voice dropdown.
Both use the Web Speech API for microphone input and require no backend infrastructure beyond the
existing ASP.NET Core app.
New Files
| Path | Type | Purpose |
Controllers/VoiceFeatureController.cs | Controller | Camera GET, Translate GET + POST, Languages GET |
Services/ITranslationService.cs | Interface | Contract for TranslateAsync and GetSupportedLanguages |
Services/TranslationService.cs | Service | MyMemory API integration (simulation mode or live) |
Views/VoiceFeature/Camera.cshtml | View | Live camera feed + voice-triggered capture UI |
Views/VoiceFeature/Translate.cshtml | View | Language-pair selector + voice input + output box |
wwwroot/js/voice-camera.js | JS | MediaDevices, SpeechRecognition, canvas capture, download |
wwwroot/js/voice-translate.js | JS | SpeechRecognition, POST to API, history, SpeechSynthesis |
Modified Files
| Path | Change |
Views/Shared/_Layout.cshtml | Added Voice dropdown menu item in navbar |
Program.cs | Registered ITranslationService / TranslationService as scoped |
1 — Voice Camera (/VoiceFeature/Camera)
Opens the device camera (rear by default, togglable to front) with a live video preview.
Speak a trigger word and the page captures a still, shows it as a preview, and offers
one-click download — no server round-trip needed.
Voice Triggers
| Language | Recognised phrases |
| English | photo, picture, snap, take photo, take picture, capture |
| Traditional Chinese | 拍照, 拍一張, 照相, 截圖, 拍攝 |
| Simplified Chinese | 拍照, 拍一张, 照相, 截图, 拍摄 |
Key JS logic (voice-camera.js)
// SpeechRecognition fires interimResults continuously
recognition.onresult = e => {
const transcript = Array.from(e.results)
.map(r => r[0].transcript.toLowerCase()).join(' ');
if (TRIGGERS[currentLang].some(t => transcript.includes(t)))
capturePhoto(); // draws video frame to canvas → PNG data URL
};
Capture writes the video frame to an off-screen <canvas>,
converts it to a PNG data URL, and assigns it to both an <img>
preview and a download anchor. Camera toggle switches facingMode
between environment and user.
2 — Voice Translate (/VoiceFeature/Translate)
Speaks into the source-language box, hits Translate, and reads the result aloud.
Supports 12 languages with a one-click swap button.
Supported Languages (12)
| Code | Language | Code | Language |
| en | English | fr | French |
| zh-TW | Traditional Chinese | de | German |
| zh-CN | Simplified Chinese | es | Spanish |
| ja | Japanese | it | Italian |
| ko | Korean | pt | Portuguese |
| vi | Vietnamese | th | Thai |
Controller API endpoint
// POST /VoiceFeature/Translate
[HttpPost]
public async Task<IActionResult> Translate([FromBody] TranslateRequest req)
{
var result = await _translation.TranslateAsync(req.Text, req.From, req.To);
return Ok(new { translation = result });
}
Translation service backends
| Mode | Setting | Notes |
| Simulation (default) | Translation:SimulationMode = true | Returns mock text — zero dependencies |
| MyMemory API | SimulationMode = false (no key needed) | Free, 2 000 chars/day per IP |
| Azure Translator | Translation:AzureKey + AzureRegion | Full throughput, requires Azure subscription |
Frontend features (voice-translate.js)
- Mic button →
SpeechRecognition set to source-language BCP-47 tag; transcript fills the input textarea
- Translate button (or Ctrl+Enter) → POST to
/VoiceFeature/Translate
- Read Aloud →
SpeechSynthesisUtterance on the translated output
- Swap button → exchanges both language selectors and moves the output back to input
- History — last 5 translations shown inline; HTML-escaped before insertion
- Same-language guard — inline error if source and target are identical
Navigation menu addition (_Layout.cshtml)
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle
@(ViewContext.RouteData.Values["controller"]?.ToString() == "VoiceFeature" ? "active" : "")"
href="#" role="button" data-bs-toggle="dropdown">🎤 Voice</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" asp-controller="VoiceFeature" asp-action="Camera">📷 Voice Camera</a></li>
<li><a class="dropdown-item" asp-controller="VoiceFeature" asp-action="Translate">🌐 Voice Translate</a></li>
</ul>
</li>
Service registration (Program.cs)
builder.Services.AddScoped<ITranslationService, TranslationService>();
Browser requirements
| Feature | Required APIs | Supported browsers |
| Voice Camera | MediaDevices, SpeechRecognition, Canvas | Chrome, Edge, Safari |
| Voice Translate | SpeechRecognition, SpeechSynthesis | Chrome, Edge (Firefox partial) |
Both features require HTTPS or localhost for microphone access.