Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,28 @@ on:
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 15
timeout-minutes: 30
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/setup-dotnet@v5
with:
dotnet-version: '10.0.x'
- name: Restore
run: dotnet restore
- uses: actions/setup-python@v6
with:
python-version: '3.13'
- name: Restore and audit dependencies
run: dotnet restore -p:NuGetAudit=true -p:NuGetAuditMode=all -warnaserror:NU1901,NU1902,NU1903,NU1904
- name: Build
run: dotnet build --no-restore
run: dotnet build SonosControl.sln --no-restore --warnaserror
- name: Test
run: dotnet test --no-build --verbosity normal --blame-hang-timeout 5m
run: dotnet test SonosControl.sln --no-build --verbosity normal --blame-hang-timeout 5m
- name: Verify formatting
run: dotnet format SonosControl.sln --verify-no-changes --no-restore
- name: Install UI smoke dependencies
run: |
python -m pip install --requirement requirements-ui.txt
python -m playwright install --with-deps chromium
- name: Run isolated responsive UI smoke
run: python verify_mobile_smoke.py
2 changes: 2 additions & 0 deletions .github/workflows/docs-quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ jobs:
markdown:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- uses: actions/checkout@v6

Expand Down
2 changes: 1 addition & 1 deletion .markdownlint-cli2.jsonc
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
// Keep document globs aligned with .github/workflows/docs-quality.yml.
// Keep document globs aligned with .github/workflows/docs-quality.yml and AGENTS.md.
"config": {
"default": true,
"MD013": false,
Expand Down
15 changes: 14 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
|---|---|
| `SonosControl.Web/**`, `SonosControl.DAL/**`, `SonosControl.Tests/**`, `*.cs`, `*.csproj`, `SonosControl.sln` | Run `dotnet test SonosControl.sln --verbosity minimal` |
| `SonosControl.Web/Pages/**`, `SonosControl.Web/Shared/**`, `SonosControl.Web/wwwroot/css/**`, `*.razor`, `*.razor.css` | Run `python3 verify_mobile_smoke.py` on macOS/Linux or `.\run-mobile-smoke.ps1` on Windows |
| `*.cs`, `*.csproj`, `*.razor`, `*.razor.cs`, `SonosControl.sln` | Run `dotnet format SonosControl.sln --verify-no-changes --no-restore` and the NuGet audit command below |
| `README.md`, `docs/**`, `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md`, `.markdownlint-cli2.jsonc`, `scripts/check_markdown_links.py`, `AGENTS.md` | Run markdown lint and markdown link validation commands from the Required Verification Commands section |
| `docs/assets/readme/**`, `capture_readme_screenshots.py`, `run-readme-screenshots.ps1`, and any UI paths from above | Recommend `.\run-readme-screenshots.ps1` and review image diffs |

Expand All @@ -32,7 +33,13 @@ Run only the commands required by the routing matrix for the files you changed.
dotnet test SonosControl.sln --verbosity minimal
```

```powershell
dotnet restore -p:NuGetAudit=true -p:NuGetAuditMode=all -warnaserror:NU1901,NU1902,NU1903,NU1904
dotnet format SonosControl.sln --verify-no-changes --no-restore
```

```bash
python3 -m pip install --requirement requirements-ui.txt
python3 verify_mobile_smoke.py
```

Expand All @@ -42,7 +49,7 @@ On Windows, the PowerShell wrapper is also supported:
.\run-mobile-smoke.ps1
```

On macOS, the smoke runner auto-detects Google Chrome at `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`. If needed, set `PLAYWRIGHT_CHROME_PATH` explicitly. The runner uses a writable `artifacts/mobile_smoke_dataprotection_keys` directory for local Data Protection keys unless `DataProtection__KeysDirectory` is already set.
On macOS, the smoke runner auto-detects Google Chrome at `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`. If needed, set `PLAYWRIGHT_CHROME_PATH` explicitly. Auto-start uses isolated temporary settings, SQLite, and Data Protection paths under `artifacts`, disables background services, and never reads configured Sonos addresses.

```powershell
markdownlint-cli2 README.md "docs/**/*.md" CONTRIBUTING.md CODE_OF_CONDUCT.md SECURITY.md AGENTS.md
Expand All @@ -67,6 +74,12 @@ python scripts/check_markdown_links.py README.md docs CONTRIBUTING.md CODE_OF_CO
.\run-readme-screenshots.ps1
```

On macOS/Linux, use the installed UI-smoke environment directly:

```bash
artifacts/ui-smoke-venv/bin/python capture_readme_screenshots.py
```

## Safety Rules

- Never commit secrets, tokens, or real credentials.
Expand Down
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

SonosControl is a deployer-friendly Blazor control centre organised around everyday playback, a unified source library, scene-based automation, listening insights, and role-aware administration.

![SonosControl dashboard hero](docs/assets/readme/images/desktop-home.png)
![SonosControl dashboard hero in dark theme](docs/assets/readme/images/desktop-dark-home.png)

## Quick Start

Expand Down Expand Up @@ -60,17 +60,29 @@ Then open `http://localhost:5107`.

## Screenshot Gallery

### Desktop
### Desktop · Light

| Home | Library | Automation | Insights | Administration |
|---|---|---|---|---|
| ![Desktop home](docs/assets/readme/images/desktop-home.png) | ![Desktop library](docs/assets/readme/images/desktop-library.png) | ![Desktop automation](docs/assets/readme/images/desktop-automation.png) | ![Desktop insights](docs/assets/readme/images/desktop-insights.png) | ![Desktop administration](docs/assets/readme/images/desktop-administration.png) |
| ![Desktop light home](docs/assets/readme/images/desktop-light-home.png) | ![Desktop light library](docs/assets/readme/images/desktop-light-library.png) | ![Desktop light automation](docs/assets/readme/images/desktop-light-automation.png) | ![Desktop light insights](docs/assets/readme/images/desktop-light-insights.png) | ![Desktop light administration](docs/assets/readme/images/desktop-light-administration.png) |

### Mobile
### Desktop · Dark

| Home | Library | Automation | Insights | Administration |
|---|---|---|---|---|
| ![Mobile home](docs/assets/readme/images/mobile-home.png) | ![Mobile library](docs/assets/readme/images/mobile-library.png) | ![Mobile automation](docs/assets/readme/images/mobile-automation.png) | ![Mobile insights](docs/assets/readme/images/mobile-insights.png) | ![Mobile administration](docs/assets/readme/images/mobile-administration.png) |
| ![Desktop dark home](docs/assets/readme/images/desktop-dark-home.png) | ![Desktop dark library](docs/assets/readme/images/desktop-dark-library.png) | ![Desktop dark automation](docs/assets/readme/images/desktop-dark-automation.png) | ![Desktop dark insights](docs/assets/readme/images/desktop-dark-insights.png) | ![Desktop dark administration](docs/assets/readme/images/desktop-dark-administration.png) |

### Mobile · Light

| Home | Library | Automation | Insights | Administration |
|---|---|---|---|---|
| ![Mobile light home](docs/assets/readme/images/mobile-light-home.png) | ![Mobile light library](docs/assets/readme/images/mobile-light-library.png) | ![Mobile light automation](docs/assets/readme/images/mobile-light-automation.png) | ![Mobile light insights](docs/assets/readme/images/mobile-light-insights.png) | ![Mobile light administration](docs/assets/readme/images/mobile-light-administration.png) |

### Mobile · Dark

| Home | Library | Automation | Insights | Administration |
|---|---|---|---|---|
| ![Mobile dark home](docs/assets/readme/images/mobile-dark-home.png) | ![Mobile dark library](docs/assets/readme/images/mobile-dark-library.png) | ![Mobile dark automation](docs/assets/readme/images/mobile-dark-automation.png) | ![Mobile dark insights](docs/assets/readme/images/mobile-dark-insights.png) | ![Mobile dark administration](docs/assets/readme/images/mobile-dark-administration.png) |

## Feature Highlights

Expand Down
2 changes: 1 addition & 1 deletion SonosControl.DAL/Models/SpotifyObject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
public class SpotifyObject
{
public string Name { get; set; } = string.Empty;
public string Url { get; set; } = string.Empty;
public string Url { get; set; } = string.Empty;
}
2 changes: 1 addition & 1 deletion SonosControl.DAL/Repos/HolidayRepo.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
using System;
using System.Net.Http;
using System.Threading.Tasks;
using SonosControl.DAL.Interfaces;
using SonosControl.DAL.Interfaces;

namespace SonosControl.DAL.Repos
{
Expand Down
2 changes: 1 addition & 1 deletion SonosControl.DAL/SonosControl.DAL.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<ItemGroup>
<PackageReference Include="ByteDev.Sonos" Version="1.2.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.10" />
</ItemGroup>

</Project>
116 changes: 116 additions & 0 deletions SonosControl.Tests/AccessibilityPrimitivesTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using SonosControl.DAL.Interfaces;
using SonosControl.Web.Models;
using SonosControl.Web.Shared;
using Xunit;

namespace SonosControl.Tests;

public class AccessibilityPrimitivesTests
{
[Fact]
public void WorkspaceTabs_ExposeTabSemanticsAndRovingTabIndex()
{
using var ctx = new TestContext();
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
ctx.Services.AddSingleton(Mock.Of<IUnitOfWork>());
var selected = string.Empty;
IReadOnlyList<WorkspaceTabItem> items =
[
new("now", "Now playing"),
new("rooms", "Rooms"),
new("library", "Library")
];

var cut = ctx.RenderComponent<WorkspaceTabs>(parameters => parameters
.Add(component => component.Items, items)
.Add(component => component.ActiveKey, "now")
.Add(component => component.IdPrefix, "quality")
.Add(component => component.OnSelect, EventCallback.Factory.Create<string>(
this,
value => selected = value)));

Assert.Equal("tablist", cut.Find("nav").GetAttribute("role"));
Assert.Equal(3, cut.FindAll("[role='tab']").Count);
var nowTab = cut.Find("#quality-tab-now");
var roomsTab = cut.Find("#quality-tab-rooms");
Assert.Equal("true", nowTab.GetAttribute("aria-selected"));
Assert.Equal("0", nowTab.GetAttribute("tabindex"));
Assert.Equal("-1", roomsTab.GetAttribute("tabindex"));
Assert.Equal("quality-panel-now", nowTab.GetAttribute("aria-controls"));

nowTab.KeyDown(new KeyboardEventArgs { Key = "ArrowRight" });

Assert.Equal("rooms", selected);
Assert.Contains(
ctx.JSInterop.Invocations,
invocation => invocation.Identifier == "window.sonosUi.focusById");
}

[Fact]
public void AccessibleDialog_HasNameFocusTrapAndEscapeClose()
{
using var ctx = new TestContext();
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
ctx.Services.AddSingleton(Mock.Of<IUnitOfWork>());
var closed = false;

var cut = ctx.RenderComponent<AccessibleDialog>(parameters => parameters
.Add(component => component.Visible, true)
.Add(component => component.Title, "Edit source")
.Add(component => component.CloseLabel, "Close source editor")
.Add(component => component.OnClose, EventCallback.Factory.Create(
this,
() => closed = true)));

var dialog = cut.Find("[role='dialog']");
Assert.Equal("true", dialog.GetAttribute("aria-modal"));
var titleId = dialog.GetAttribute("aria-labelledby");
Assert.Equal("Edit source", cut.Find($"#{titleId}").TextContent);
Assert.Equal(
"Close source editor",
cut.Find("button.app-dialog__close").GetAttribute("aria-label"));
Assert.Contains(
ctx.JSInterop.Invocations,
invocation => invocation.Identifier == "window.sonosUi.activateFocusTrap");

dialog.KeyDown(new KeyboardEventArgs { Key = "Escape" });

Assert.True(closed);
Assert.Contains(
ctx.JSInterop.Invocations,
invocation => invocation.Identifier == "window.sonosUi.releaseFocusTrap");
}

[Fact]
public void EmptyState_DefaultsToH2AndHidesDecorativeIcon()
{
using var ctx = new TestContext();
ctx.Services.AddSingleton(Mock.Of<IUnitOfWork>());

var cut = ctx.RenderComponent<EmptyState>(parameters => parameters
.Add(component => component.Title, "Nothing queued")
.Add(component => component.IconName, "queue"));

Assert.Equal("Nothing queued", cut.Find("h2").TextContent);
Assert.Equal("true", cut.Find(".empty-state__icon").GetAttribute("aria-hidden"));
Assert.Null(cut.Find(".empty-state__icon svg").GetAttribute("aria-label"));
}

[Theory]
[InlineData(null, ThemePreferenceMode.System)]
[InlineData("", ThemePreferenceMode.System)]
[InlineData("light", ThemePreferenceMode.Light)]
[InlineData("dark", ThemePreferenceMode.Dark)]
[InlineData("unexpected", ThemePreferenceMode.System)]
public void ThemePreference_PreservesLegacyValuesAndFallsBackToSystem(
string? storedValue,
ThemePreferenceMode expected)
{
Assert.Equal(expected, ThemePreferenceModeExtensions.FromIdentifier(storedValue));
}
}
4 changes: 2 additions & 2 deletions SonosControl.Tests/AdministrationPageNavigationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public void AdministrationPage_UpdatesContentImmediately_WhenTabRouteChanges()

Assert.Equal("http://localhost/administration/users", navigation.Uri);
Assert.Contains("is-active", usersTab.ClassList);
Assert.Equal("page", usersTab.GetAttribute("aria-current"));
Assert.Equal("true", usersTab.GetAttribute("aria-selected"));
Assert.Single(cut.FindComponents<Stub<UserManagement>>());
Assert.Empty(cut.FindComponents<Stub<DevicesPage>>());
});
Expand All @@ -57,7 +57,7 @@ public void AdministrationPage_FollowsExternalLocationChanges()
.Single(button => button.TextContent.Trim() == "Backups");

Assert.Contains("is-active", backupsTab.ClassList);
Assert.Equal("page", backupsTab.GetAttribute("aria-current"));
Assert.Equal("true", backupsTab.GetAttribute("aria-selected"));
Assert.Single(cut.FindComponents<Stub<SettingsBackupsPage>>());
Assert.Empty(cut.FindComponents<Stub<DevicesPage>>());
});
Expand Down
29 changes: 29 additions & 0 deletions SonosControl.Tests/AuthControllerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Mvc;
using Moq;
using SonosControl.DAL.Interfaces;
using SonosControl.DAL.Models;
using SonosControl.Web.Controllers;
using Xunit;

namespace SonosControl.Tests;

public class AuthControllerTests
{
[Fact]
public async Task Login_LockedOutErrorShowsActionableMessage()
{
var settingsRepo = new Mock<ISettingsRepo>();
settingsRepo.Setup(repository => repository.GetSettings())
.ReturnsAsync(new SonosSettings { AllowUserRegistration = true });
var unitOfWork = new Mock<IUnitOfWork>();
unitOfWork.SetupGet(value => value.ISettingsRepo).Returns(settingsRepo.Object);
var controller = new AuthController(null!, unitOfWork.Object);

var result = await controller.Login(error: "lockedout");

Assert.IsType<ViewResult>(result);
var message = Assert.IsType<string>(controller.ViewBag.Error);
Assert.Contains("temporarily locked", message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("five minutes", message, StringComparison.OrdinalIgnoreCase);
}
}
2 changes: 1 addition & 1 deletion SonosControl.Tests/AutomationSchedulerServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ private sealed class SchedulerFixture(
public void Dispose()
{
provider.Dispose();
db.Dispose();
Db.Dispose();
}
}
}
4 changes: 2 additions & 2 deletions SonosControl.Tests/DiscordNotificationServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,8 @@ public async Task SendNotificationAsync_ShouldSendPostRequest_WhenUrlIsConfigure
Times.Once(),
ItExpr.Is<HttpRequestMessage>(req =>
req.Method == HttpMethod.Post &&
req.RequestUri.ToString() == webhookUrl &&
req.Content.ReadAsStringAsync().Result.Contains("Test Message") &&
req.RequestUri!.ToString() == webhookUrl &&
req.Content!.ReadAsStringAsync().Result.Contains("Test Message") &&
req.Content.ReadAsStringAsync().Result.Contains("User1")
),
ItExpr.IsAny<CancellationToken>()
Expand Down
3 changes: 2 additions & 1 deletion SonosControl.Tests/GlobalPlayerBarTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ public void GlobalPlayerBar_ResolvesSavedStationNameFromCurrentUri()
public void GlobalPlayerBar_PlayerSheet_UsesActiveSpeakerAndNumericVolume()
{
using var ctx = new TestContext();
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
ConfigureServices(ctx, activeSpeakerIp: "10.0.0.2");

var cut = ctx.RenderComponent<GlobalPlayerBar>();
Expand All @@ -105,7 +106,7 @@ public void GlobalPlayerBar_PlayerSheet_UsesActiveSpeakerAndNumericVolume()
{
Assert.Single(cut.FindAll(".player-sheet[role='dialog']"));
Assert.Equal("10.0.0.2", cut.Find("#player-sheet-room").GetAttribute("value"));
Assert.Contains("Kitchen", cut.Find(".player-sheet__header").TextContent);
Assert.Contains("Kitchen", cut.Find(".player-sheet .app-dialog__header").TextContent);
Assert.Equal("25", cut.Find("#player-sheet-volume-number").GetAttribute("value"));
Assert.Equal("80", cut.Find("#player-sheet-volume-slider").GetAttribute("max"));
});
Expand Down
2 changes: 1 addition & 1 deletion SonosControl.Tests/IndexPageAccessibilityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ public void GlobalPlayer_UsesDecorativeAlbumArtWithAdjacentTrackText()
[Fact]
public void GlobalPlayer_FallbackArt_IsHiddenFromScreenReaders()
{
using var ctx = new TestContext();
using var ctx = new TestContext();

// Setup Mocks (No album art)
var auth = ctx.AddTestAuthorization();
Expand Down
Loading