diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 93a4c59..464aa50 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -50,6 +50,14 @@ jobs:
/nologo
/v:minimal
+ - name: Run unit tests
+ shell: pwsh
+ run: >
+ dotnet test
+ src/extension/WcpBrowserTabs/WcpBrowserTabs.Tests/WcpBrowserTabs.Tests.csproj
+ --configuration Debug
+ /p:Platform=x64
+
- name: Build PR Store bundle
shell: pwsh
run: |
diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml
new file mode 100644
index 0000000..0f0823c
--- /dev/null
+++ b/.github/workflows/ui-tests.yml
@@ -0,0 +1,70 @@
+name: ui-tests
+
+on:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ui-tests-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ browser-ui-tests:
+ runs-on: windows-2022
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup .NET 9 SDK
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 9.0.x
+
+ - name: Verify Windows SDK platform
+ shell: pwsh
+ run: |
+ $platformXml = "C:\Program Files (x86)\Windows Kits\10\Platforms\UAP\10.0.22621.0\Platform.xml"
+ if (-not (Test-Path $platformXml)) {
+ Write-Host "Installed UAP SDK platforms:"
+ Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\Platforms\UAP" -ErrorAction SilentlyContinue |
+ Select-Object -ExpandProperty FullName
+ throw "Required Windows SDK UAP platform file not found: $platformXml"
+ }
+
+ - name: Run unit tests with coverage
+ shell: pwsh
+ run: >
+ dotnet test
+ src/extension/WcpBrowserTabs/WcpBrowserTabs.Tests/WcpBrowserTabs.Tests.csproj
+ --configuration Debug
+ --settings src/extension/WcpBrowserTabs/WcpBrowserTabs.Tests/coverage.runsettings
+ --collect "XPlat Code Coverage"
+ --logger "trx;LogFileName=unit-tests.trx"
+ --results-directory artifacts/unit-tests
+ /p:Platform=x64
+
+ - name: Run browser UI tests
+ shell: pwsh
+ run: >
+ dotnet test
+ src/extension/WcpBrowserTabs/WcpBrowserTabs.UiTests/WcpBrowserTabs.UiTests.csproj
+ --configuration Debug
+ --filter Category=Ui
+ --settings src/extension/WcpBrowserTabs/WcpBrowserTabs.UiTests/coverage.runsettings
+ --collect "XPlat Code Coverage"
+ --logger "trx;LogFileName=ui-tests.trx"
+ --results-directory artifacts/ui-tests
+ /p:Platform=x64
+
+ - name: Upload UI test results
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: ui-test-results
+ path: |
+ artifacts/unit-tests
+ artifacts/ui-tests
+ if-no-files-found: ignore
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 0000000..2a9a865
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,6 @@
+{
+ "recommendations": [
+ "ms-dotnettools.csdevkit",
+ "github.vscode-github-actions"
+ ]
+}
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
index 81e4357..fd874bb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -4,7 +4,7 @@
This repository contains a Windows Command Palette extension named **Browser Tabs**.
-- It discovers open browser tabs from supported browsers (currently Chrome and Edge).
+- It discovers open browser tabs from supported browsers (currently Chrome, Edge, and Firefox).
- It shows those tabs in Command Palette so users can search and switch quickly.
- It is implemented as a COM-based Windows app extension.
diff --git a/PRIVACY.md b/PRIVACY.md
index 391e1ac..d76d737 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -2,7 +2,7 @@
Browser Tabs is a Windows Command Palette extension that helps you find and switch to open browser tabs.
-Browser Tabs runs locally on your Windows device. It reads the titles of open Chrome and Microsoft Edge tabs through Windows UI Automation so those tabs can be shown in Command Palette search results.
+Browser Tabs runs locally on your Windows device. It reads the titles of open Chrome, Microsoft Edge, and Firefox tabs through Windows UI Automation so those tabs can be shown in Command Palette search results.
Browser Tabs does not send browser tab titles, browsing activity, account information, or other personal data to a remote service. Browser Tabs does not require an account and does not operate a backend service.
diff --git a/PUBLISHING.md b/PUBLISHING.md
index 0289ec4..c821b3d 100644
--- a/PUBLISHING.md
+++ b/PUBLISHING.md
@@ -160,13 +160,13 @@ Use this draft listing copy.
Short description:
```text
-Switch to open Chrome and Edge tabs directly from Windows Command Palette.
+Switch to open Chrome, Edge, and Firefox tabs directly from Windows Command Palette.
```
Description:
```text
-Browser Tabs integrates with Windows Command Palette to make open browser tabs searchable from one launcher. It discovers open Chrome and Microsoft Edge tabs on your Windows desktop, shows matching tabs in Command Palette, and switches directly to the selected tab.
+Browser Tabs integrates with Windows Command Palette to make open browser tabs searchable from one launcher. It discovers open Chrome, Microsoft Edge, and Firefox tabs on your Windows desktop, shows matching tabs in Command Palette, and switches directly to the selected tab.
This extension requires Microsoft PowerToys Command Palette to be installed and enabled. Browser tab discovery runs locally on your device using Windows UI Automation. Browser Tabs does not send your browser tab data to a remote service.
```
@@ -182,6 +182,7 @@ Features:
```text
Search open Chrome tabs
Search open Microsoft Edge tabs
+Search open Firefox tabs
Switch directly to a selected tab
Highlights currently active tabs
Runs locally as a Command Palette extension
@@ -190,7 +191,7 @@ Runs locally as a Command Palette extension
Keywords:
```text
-command palette, powertoys, browser tabs, chrome, edge, tab switcher, productivity
+command palette, powertoys, browser tabs, chrome, edge, firefox, tab switcher, productivity
```
Screenshots:
@@ -239,7 +240,7 @@ Recommended screenshot set:
Suggested captions:
```text
-Search open Chrome and Edge tabs from Windows Command Palette.
+Search open Chrome, Edge, and Firefox tabs from Windows Command Palette.
Filter browser tabs by title, browser, or window text.
Switch directly to the selected browser tab.
Runs locally as a PowerToys Command Palette extension.
@@ -256,13 +257,13 @@ Certification note:
```text
Browser Tabs is a Windows Command Palette extension, not a standalone app. Its app-list entry is disabled intentionally because users launch it through Microsoft PowerToys Command Palette. If the package executable is launched directly, it shows an informational dialog explaining that Browser Tabs must be opened from Command Palette.
-To test it, install and enable Microsoft PowerToys Command Palette, open Chrome or Microsoft Edge with several tabs, launch Command Palette, open Browser Tabs, search for a tab title, and select a result. The extension should bring the browser window forward and activate the selected tab. No account, network service, or test credentials are required.
+To test it, install and enable Microsoft PowerToys Command Palette, open Chrome, Microsoft Edge, or Firefox with several tabs, launch Command Palette, open Browser Tabs, search for a tab title, and select a result. The extension should bring the browser window forward and activate the selected tab. No account, network service, or test credentials are required.
```
Restricted capability explanation for `runFullTrust`:
```text
-Browser Tabs declares runFullTrust because Command Palette extensions use an out-of-process COM server and packaged COM registration. The extension runs as a desktop Command Palette extension, enumerates local Chrome and Microsoft Edge window/tab UI Automation elements, and activates the selected local browser tab. It does not use runFullTrust to elevate privileges, install services, modify system settings, or access user files.
+Browser Tabs declares runFullTrust because Command Palette extensions use an out-of-process COM server and packaged COM registration. The extension runs as a desktop Command Palette extension, enumerates local Chrome, Microsoft Edge, and Firefox window/tab UI Automation elements, and activates the selected local browser tab. It does not use runFullTrust to elevate privileges, install services, modify system settings, or access user files.
```
Useful Microsoft docs:
diff --git a/README.md b/README.md
index c4d7542..2673333 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# wcp-browsertabs
-Browser Tabs is a Windows Command Palette extension for searching and switching between open Chrome and Microsoft Edge tabs.
+Browser Tabs is a Windows Command Palette extension for searching and switching between open Chrome, Microsoft Edge, and Firefox tabs.
Install it from the Microsoft Store:
@@ -10,12 +10,12 @@ https://apps.microsoft.com/detail/9N7BMMLTGFWX
- Windows 10 version 2004 or later
- Microsoft PowerToys with Command Palette enabled
-- Chrome or Microsoft Edge
+- Chrome, Microsoft Edge, or Firefox
## Usage
1. Install `wcp-browsertabs` from the Microsoft Store.
-2. Open Chrome or Microsoft Edge with a few tabs.
+2. Open Chrome, Microsoft Edge, or Firefox with a few tabs.
3. Open PowerToys Command Palette.
4. Open **Browser Tabs**.
5. Search by tab title, browser, or window title.
@@ -43,6 +43,35 @@ Build:
dotnet build src\extension\WcpBrowserTabs\WcpBrowserTabs.sln
```
+Run unit tests:
+
+```powershell
+dotnet test src\extension\WcpBrowserTabs\WcpBrowserTabs.Tests\WcpBrowserTabs.Tests.csproj /p:Platform=x64
+```
+
+Run unit tests with coverage:
+
+```powershell
+dotnet test src\extension\WcpBrowserTabs\WcpBrowserTabs.Tests\WcpBrowserTabs.Tests.csproj --settings src\extension\WcpBrowserTabs\WcpBrowserTabs.Tests\coverage.runsettings --collect "XPlat Code Coverage" --results-directory artifacts\unit-tests /p:Platform=x64
+```
+
+Run browser UI integration tests:
+
+```powershell
+dotnet test src\extension\WcpBrowserTabs\WcpBrowserTabs.UiTests\WcpBrowserTabs.UiTests.csproj --filter Category=Ui /p:Platform=x64
+```
+
+Run browser UI integration tests with coverage:
+
+```powershell
+dotnet test src\extension\WcpBrowserTabs\WcpBrowserTabs.UiTests\WcpBrowserTabs.UiTests.csproj --filter Category=Ui --settings src\extension\WcpBrowserTabs\WcpBrowserTabs.UiTests\coverage.runsettings --collect "XPlat Code Coverage" --results-directory artifacts\ui-tests /p:Platform=x64
+```
+
+The UI tests launch Chrome, Microsoft Edge, and Firefox with temporary profiles
+and local test pages. Tests for browsers that are not installed are skipped.
+Coverage output is written as Cobertura XML under `artifacts\unit-tests\` or
+`artifacts\ui-tests\`.
+
Create a Store bundle using the current package version:
```powershell
diff --git a/scripts/build-and-install-local.ps1 b/scripts/build-and-install-local.ps1
new file mode 100644
index 0000000..e7fa22f
--- /dev/null
+++ b/scripts/build-and-install-local.ps1
@@ -0,0 +1,21 @@
+param(
+ [string]$WorkspaceFolder = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path,
+ [switch]$UseManifestVersion
+)
+
+$ErrorActionPreference = "Stop"
+
+$workspace = (Resolve-Path $WorkspaceFolder).Path
+
+$packageScript = Join-Path $PSScriptRoot "package-extension-x64.ps1"
+$trustScript = Join-Path $PSScriptRoot "trust-latest-dev-certificate.ps1"
+$installScript = Join-Path $PSScriptRoot "install-latest-package.ps1"
+
+Write-Host "Building local x64 package..."
+& $packageScript -WorkspaceFolder $workspace -UseManifestVersion:$UseManifestVersion
+
+Write-Host "Trusting latest development certificate..."
+& $trustScript -WorkspaceFolder $workspace
+
+Write-Host "Installing latest local package..."
+& $installScript -WorkspaceFolder $workspace
diff --git a/src/Poc/Program.cs b/src/Poc/Program.cs
index e765f92..cb94def 100644
--- a/src/Poc/Program.cs
+++ b/src/Poc/Program.cs
@@ -160,6 +160,12 @@ private static bool TryGetBrowserName(int processId, out string browser)
return true;
}
+ if (processName.Equals("firefox", StringComparison.OrdinalIgnoreCase))
+ {
+ browser = "Firefox";
+ return true;
+ }
+
return false;
}
catch (ArgumentException)
diff --git a/src/extension/WcpBrowserTabs/Directory.Packages.props b/src/extension/WcpBrowserTabs/Directory.Packages.props
index c4a3840..c22c138 100644
--- a/src/extension/WcpBrowserTabs/Directory.Packages.props
+++ b/src/extension/WcpBrowserTabs/Directory.Packages.props
@@ -4,8 +4,10 @@
+
+
@@ -14,5 +16,8 @@
+
+
+
diff --git a/src/extension/WcpBrowserTabs/WcpBrowserTabs.Tests/BrowserTabDiscoveryServiceTests.cs b/src/extension/WcpBrowserTabs/WcpBrowserTabs.Tests/BrowserTabDiscoveryServiceTests.cs
new file mode 100644
index 0000000..5327a4a
--- /dev/null
+++ b/src/extension/WcpBrowserTabs/WcpBrowserTabs.Tests/BrowserTabDiscoveryServiceTests.cs
@@ -0,0 +1,168 @@
+using Xunit;
+
+namespace WcpBrowserTabs.Tests;
+
+public sealed class BrowserTabDiscoveryServiceTests
+{
+ [Theory]
+ [InlineData("chrome", "Chrome")]
+ [InlineData("CHROME", "Chrome")]
+ [InlineData("msedge", "Edge")]
+ [InlineData("firefox", "Firefox")]
+ public void TryGetBrowserNameRecognizesSupportedBrowsers(string processName, string expectedBrowser)
+ {
+ var result = BrowserTabDiscoveryService.TryGetBrowserName(processName, out var browserName);
+
+ Assert.True(result);
+ Assert.Equal(expectedBrowser, browserName);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("notepad")]
+ public void TryGetBrowserNameRejectsUnsupportedBrowsers(string? processName)
+ {
+ var result = BrowserTabDiscoveryService.TryGetBrowserName(processName, out var browserName);
+
+ Assert.False(result);
+ Assert.Empty(browserName);
+ }
+
+ [Fact]
+ public void CreateTabsFiltersInvalidWindowsAndTabs()
+ {
+ var windows = new[]
+ {
+ Window(1, 100, " Chrome Window ", Tab(" Alpha ", true), Tab("", false), Tab(" ", false), Tab(null, false)),
+ Window(2, nint.Zero, "Edge Window", Tab("Ignored because handle is zero", true)),
+ Window(3, 300, "Unsupported Window", Tab("Ignored because process is unsupported", true)),
+ Window(4, 400, "Missing Process Window", Tab("Ignored because process name is missing", true)),
+ };
+
+ var tabs = BrowserTabDiscoveryService.CreateTabs(windows, ProcessNameFor).ToArray();
+
+ var tab = Assert.Single(tabs);
+ Assert.Equal("Chrome", tab.Browser);
+ Assert.Equal(1, tab.ProcessId);
+ Assert.Equal(100, tab.WindowHandle);
+ Assert.Equal("Chrome Window", tab.WindowTitle);
+ Assert.Equal("Alpha", tab.TabTitle);
+ Assert.True(tab.IsActiveTab);
+ }
+
+ [Fact]
+ public void CreateTabsDeduplicatesWithinSameBrowserWindowAndTitle()
+ {
+ var windows = new[]
+ {
+ Window(1, 100, "Chrome Window", Tab("Duplicate", true), Tab("Duplicate", false)),
+ Window(1, 101, "Second Chrome Window", Tab("Duplicate", false)),
+ Window(2, 200, "Edge Window", Tab("Duplicate", false)),
+ };
+
+ var tabs = BrowserTabDiscoveryService.CreateTabs(windows, ProcessNameFor).ToArray();
+
+ Assert.Equal(3, tabs.Length);
+ Assert.Contains(tabs, tab => tab.Browser == "Chrome" && tab.WindowHandle == 100 && tab.TabTitle == "Duplicate" && tab.IsActiveTab);
+ Assert.Contains(tabs, tab => tab.Browser == "Chrome" && tab.WindowHandle == 101 && tab.TabTitle == "Duplicate");
+ Assert.Contains(tabs, tab => tab.Browser == "Edge" && tab.WindowHandle == 200 && tab.TabTitle == "Duplicate");
+ }
+
+ [Fact]
+ public void CreateTabsSortsActiveTabsBeforeBrowserAndTitle()
+ {
+ var windows = new[]
+ {
+ Window(2, 200, "Edge Window", Tab("Zulu", false), Tab("Alpha", true)),
+ Window(1, 100, "Chrome Window", Tab("Bravo", false), Tab("Alpha", true)),
+ Window(5, 500, "Firefox Window", Tab("Alpha", false)),
+ };
+
+ var tabs = BrowserTabDiscoveryService.CreateTabs(windows, ProcessNameFor).ToArray();
+
+ Assert.Collection(
+ tabs,
+ tab => AssertTab(tab, "Chrome", "Alpha", isActive: true),
+ tab => AssertTab(tab, "Edge", "Alpha", isActive: true),
+ tab => AssertTab(tab, "Chrome", "Bravo", isActive: false),
+ tab => AssertTab(tab, "Edge", "Zulu", isActive: false),
+ tab => AssertTab(tab, "Firefox", "Alpha", isActive: false));
+ }
+
+ [Fact]
+ public void CreateTabsValidatesArguments()
+ {
+ Assert.Throws(() => BrowserTabDiscoveryService.CreateTabs(null!, ProcessNameFor));
+ Assert.Throws(() => BrowserTabDiscoveryService.CreateTabs([], null!));
+ }
+
+ [Fact]
+ public void RunInStaThreadReturnsValueFromStaThread()
+ {
+ var apartmentState = ApartmentState.Unknown;
+
+ var result = BrowserTabDiscoveryService.RunInStaThread(() =>
+ {
+ apartmentState = Thread.CurrentThread.GetApartmentState();
+ return 42;
+ });
+
+ Assert.Equal(42, result);
+ Assert.Equal(ApartmentState.STA, apartmentState);
+ }
+
+ [Fact]
+ public void RunInStaThreadRethrowsWorkerExceptions()
+ {
+ var exception = Assert.Throws(() =>
+ BrowserTabDiscoveryService.RunInStaThread(() => throw new InvalidOperationException("boom")));
+
+ Assert.Equal("boom", exception.Message);
+ }
+
+ [Fact]
+ public void TabActivationResultFactoriesSetExpectedState()
+ {
+ var success = TabActivationResult.Successful();
+ var failure = TabActivationResult.Failure("nope");
+
+ Assert.True(success.Success);
+ Assert.Empty(success.ErrorMessage);
+ Assert.False(failure.Success);
+ Assert.Equal("nope", failure.ErrorMessage);
+ }
+
+ private static string? ProcessNameFor(int processId)
+ {
+ return processId switch
+ {
+ 1 => "chrome",
+ 2 => "msedge",
+ 5 => "firefox",
+ _ => null,
+ };
+ }
+
+ private static BrowserTabDiscoveryService.DiscoveredBrowserWindow Window(
+ int processId,
+ nint windowHandle,
+ string windowTitle,
+ params BrowserTabDiscoveryService.DiscoveredBrowserTab[] tabs)
+ {
+ return new BrowserTabDiscoveryService.DiscoveredBrowserWindow(processId, windowHandle, windowTitle, tabs);
+ }
+
+ private static BrowserTabDiscoveryService.DiscoveredBrowserTab Tab(string? title, bool isActive)
+ {
+ return new BrowserTabDiscoveryService.DiscoveredBrowserTab(title, isActive);
+ }
+
+ private static void AssertTab(BrowserTab tab, string browser, string title, bool isActive)
+ {
+ Assert.Equal(browser, tab.Browser);
+ Assert.Equal(title, tab.TabTitle);
+ Assert.Equal(isActive, tab.IsActiveTab);
+ }
+}
diff --git a/src/extension/WcpBrowserTabs/WcpBrowserTabs.Tests/BrowserWindowActivatorTests.cs b/src/extension/WcpBrowserTabs/WcpBrowserTabs.Tests/BrowserWindowActivatorTests.cs
new file mode 100644
index 0000000..0abd070
--- /dev/null
+++ b/src/extension/WcpBrowserTabs/WcpBrowserTabs.Tests/BrowserWindowActivatorTests.cs
@@ -0,0 +1,12 @@
+using Xunit;
+
+namespace WcpBrowserTabs.Tests;
+
+public sealed class BrowserWindowActivatorTests
+{
+ [Fact]
+ public void TryBringToFrontReturnsFalseForZeroHandle()
+ {
+ Assert.False(BrowserWindowActivator.TryBringToFront(nint.Zero));
+ }
+}
diff --git a/src/extension/WcpBrowserTabs/WcpBrowserTabs.Tests/WcpBrowserTabs.Tests.csproj b/src/extension/WcpBrowserTabs/WcpBrowserTabs.Tests/WcpBrowserTabs.Tests.csproj
new file mode 100644
index 0000000..aec349a
--- /dev/null
+++ b/src/extension/WcpBrowserTabs/WcpBrowserTabs.Tests/WcpBrowserTabs.Tests.csproj
@@ -0,0 +1,37 @@
+
+
+ net9.0-windows10.0.22621.0
+ 10.0.19041.0
+ 10.0.19041.0
+ enable
+ enable
+ false
+ true
+ None
+ false
+ false
+ false
+ x64;ARM64
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/extension/WcpBrowserTabs/WcpBrowserTabs.Tests/WcpBrowserTabsPageTests.cs b/src/extension/WcpBrowserTabs/WcpBrowserTabs.Tests/WcpBrowserTabsPageTests.cs
new file mode 100644
index 0000000..a6ccafa
--- /dev/null
+++ b/src/extension/WcpBrowserTabs/WcpBrowserTabs.Tests/WcpBrowserTabsPageTests.cs
@@ -0,0 +1,90 @@
+using Microsoft.CommandPalette.Extensions;
+using Xunit;
+
+namespace WcpBrowserTabs.Tests;
+
+public sealed class WcpBrowserTabsPageTests
+{
+ [Fact]
+ public async Task GetItemsStartsDiscoveryWithoutBlocking()
+ {
+ var discoveryStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ using var discoveryMayFinish = new ManualResetEventSlim();
+ var page = new WcpBrowserTabsPage(() =>
+ {
+ discoveryStarted.SetResult();
+ discoveryMayFinish.Wait();
+ return [Tab("Chrome", "Chrome Window", "Alpha Docs")];
+ });
+
+ try
+ {
+ var initialItems = page.GetItems();
+
+ Assert.Empty(initialItems);
+ await discoveryStarted.Task.WaitAsync(TimeSpan.FromSeconds(1));
+
+ page.SearchText = "alpha";
+ Assert.Empty(page.GetItems());
+
+ discoveryMayFinish.Set();
+ var loadedItems = await WaitForItemsAsync(page, expectedCount: 1);
+
+ Assert.Equal("Alpha Docs", loadedItems[0].Title);
+ }
+ finally
+ {
+ discoveryMayFinish.Set();
+ }
+ }
+
+ [Fact]
+ public async Task SearchTextChangesDoNotRediscoverOrFilterInExtension()
+ {
+ var discoveryCalls = 0;
+ var page = new WcpBrowserTabsPage(() =>
+ {
+ Interlocked.Increment(ref discoveryCalls);
+ return
+ [
+ Tab("Chrome", "Research Window", "Alpha Docs"),
+ Tab("Edge", "Work Window", "Beta Notes"),
+ ];
+ });
+
+ var loadedItems = await WaitForItemsAsync(page, expectedCount: 2);
+ Assert.Equal(2, loadedItems.Length);
+ Assert.Equal(1, Volatile.Read(ref discoveryCalls));
+
+ page.SearchText = "edge";
+ var itemsAfterSearchChange = page.GetItems();
+
+ Assert.Equal(2, itemsAfterSearchChange.Length);
+ Assert.Contains(itemsAfterSearchChange, item => string.Equals(item.Title, "Alpha Docs", StringComparison.Ordinal));
+ Assert.Contains(itemsAfterSearchChange, item => string.Equals(item.Title, "Beta Notes", StringComparison.Ordinal));
+ Assert.Equal(1, Volatile.Read(ref discoveryCalls));
+ }
+
+ private static async Task WaitForItemsAsync(WcpBrowserTabsPage page, int expectedCount)
+ {
+ var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(2);
+
+ while (DateTimeOffset.UtcNow < deadline)
+ {
+ var items = page.GetItems();
+ if (items.Length == expectedCount)
+ {
+ return items;
+ }
+
+ await Task.Delay(20);
+ }
+
+ return page.GetItems();
+ }
+
+ private static BrowserTab Tab(string browser, string windowTitle, string tabTitle)
+ {
+ return new BrowserTab(browser, 1, 100, windowTitle, tabTitle, IsActiveTab: false);
+ }
+}
diff --git a/src/extension/WcpBrowserTabs/WcpBrowserTabs.Tests/coverage.runsettings b/src/extension/WcpBrowserTabs/WcpBrowserTabs.Tests/coverage.runsettings
new file mode 100644
index 0000000..14f5b83
--- /dev/null
+++ b/src/extension/WcpBrowserTabs/WcpBrowserTabs.Tests/coverage.runsettings
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+ cobertura
+ [WcpBrowserTabs]*
+ [WcpBrowserTabs.Tests]*
+ **/obj/**/*.cs
+ true
+
+
+
+
+
diff --git a/src/extension/WcpBrowserTabs/WcpBrowserTabs.UiTests/AssemblyInfo.cs b/src/extension/WcpBrowserTabs/WcpBrowserTabs.UiTests/AssemblyInfo.cs
new file mode 100644
index 0000000..8103446
--- /dev/null
+++ b/src/extension/WcpBrowserTabs/WcpBrowserTabs.UiTests/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using Xunit;
+
+[assembly: CollectionBehavior(DisableTestParallelization = true, MaxParallelThreads = 1)]
diff --git a/src/extension/WcpBrowserTabs/WcpBrowserTabs.UiTests/BrowserActivationUiTests.cs b/src/extension/WcpBrowserTabs/WcpBrowserTabs.UiTests/BrowserActivationUiTests.cs
new file mode 100644
index 0000000..62a1a08
--- /dev/null
+++ b/src/extension/WcpBrowserTabs/WcpBrowserTabs.UiTests/BrowserActivationUiTests.cs
@@ -0,0 +1,33 @@
+using Xunit;
+using Xunit.Abstractions;
+
+namespace WcpBrowserTabs.UiTests;
+
+public sealed class BrowserActivationUiTests
+{
+ private readonly ITestOutputHelper output;
+
+ public BrowserActivationUiTests(ITestOutputHelper output)
+ {
+ this.output = output;
+ }
+
+ [SkippableTheory]
+ [MemberData(nameof(BrowserDefinitions.AllTheoryData), MemberType = typeof(BrowserDefinitions))]
+ [Trait("Category", "Ui")]
+ public void ActivatesKnownTab(BrowserDefinition browser)
+ {
+ var executablePath = browser.FindExecutablePath();
+ Skip.If(executablePath is null, $"{browser.DisplayName} executable was not found.");
+
+ using var session = BrowserTestSession.Start(browser, output, executablePath);
+ var targetTab = session.WaitForInactiveExpectedTab();
+
+ _ = BrowserWindowActivator.TryBringToFront(targetTab.WindowHandle);
+ var result = BrowserTabDiscoveryService.TryActivateTab(targetTab);
+ Skip.If(!result.Success, $"{browser.DisplayName} tab activation was not available: {result.ErrorMessage}");
+
+ var activeTab = session.WaitForActiveExpectedTab(targetTab.TabTitle);
+ Assert.True(activeTab.IsActiveTab);
+ }
+}
diff --git a/src/extension/WcpBrowserTabs/WcpBrowserTabs.UiTests/BrowserDefinition.cs b/src/extension/WcpBrowserTabs/WcpBrowserTabs.UiTests/BrowserDefinition.cs
new file mode 100644
index 0000000..3aa64a2
--- /dev/null
+++ b/src/extension/WcpBrowserTabs/WcpBrowserTabs.UiTests/BrowserDefinition.cs
@@ -0,0 +1,113 @@
+using System.Diagnostics;
+using Microsoft.Win32;
+
+namespace WcpBrowserTabs.UiTests;
+
+public enum BrowserLaunchKind
+{
+ Chromium,
+ Firefox,
+}
+
+public sealed record BrowserDefinition(
+ string DisplayName,
+ string ProcessName,
+ string ExecutableName,
+ BrowserLaunchKind LaunchKind,
+ IReadOnlyList StandardExecutablePaths)
+{
+ public string? FindExecutablePath()
+ {
+ foreach (var candidate in StandardExecutablePaths)
+ {
+ if (File.Exists(candidate))
+ {
+ return candidate;
+ }
+ }
+
+ var appPath = FindRegisteredAppPath(ExecutableName);
+ if (appPath is not null)
+ {
+ return appPath;
+ }
+
+ return FindOnPath(ExecutableName).FirstOrDefault(File.Exists);
+ }
+
+ public void AddLaunchArguments(ProcessStartInfo startInfo, string profilePath, IReadOnlyList pageUrls)
+ {
+ if (LaunchKind == BrowserLaunchKind.Chromium)
+ {
+ startInfo.ArgumentList.Add($"--user-data-dir={profilePath}");
+ startInfo.ArgumentList.Add("--new-window");
+ startInfo.ArgumentList.Add("--no-first-run");
+ startInfo.ArgumentList.Add("--no-default-browser-check");
+ startInfo.ArgumentList.Add("--disable-default-apps");
+ startInfo.ArgumentList.Add("--disable-sync");
+ }
+ else
+ {
+ startInfo.ArgumentList.Add("-no-remote");
+ startInfo.ArgumentList.Add("-profile");
+ startInfo.ArgumentList.Add(profilePath);
+ }
+
+ foreach (var pageUrl in pageUrls)
+ {
+ startInfo.ArgumentList.Add(pageUrl);
+ }
+ }
+
+ private static string[] FindOnPath(string executableName)
+ {
+ try
+ {
+ using var whereProcess = Process.Start(new ProcessStartInfo("where.exe", executableName)
+ {
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ });
+
+ if (whereProcess is null)
+ {
+ return [];
+ }
+
+ var output = whereProcess.StandardOutput.ReadToEnd();
+ _ = whereProcess.StandardError.ReadToEnd();
+ if (!whereProcess.WaitForExit(5000))
+ {
+ return [];
+ }
+
+ if (whereProcess.ExitCode != 0)
+ {
+ return [];
+ }
+
+ return output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ }
+ catch
+ {
+ return [];
+ }
+ }
+
+ private static string? FindRegisteredAppPath(string executableName)
+ {
+ foreach (var root in new[] { Registry.CurrentUser, Registry.LocalMachine })
+ {
+ using var key = root.OpenSubKey($@"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\{executableName}");
+ var value = key?.GetValue(null) as string;
+ if (!string.IsNullOrWhiteSpace(value) && File.Exists(value))
+ {
+ return value;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/src/extension/WcpBrowserTabs/WcpBrowserTabs.UiTests/BrowserDefinitions.cs b/src/extension/WcpBrowserTabs/WcpBrowserTabs.UiTests/BrowserDefinitions.cs
new file mode 100644
index 0000000..b963330
--- /dev/null
+++ b/src/extension/WcpBrowserTabs/WcpBrowserTabs.UiTests/BrowserDefinitions.cs
@@ -0,0 +1,64 @@
+using Xunit;
+
+namespace WcpBrowserTabs.UiTests;
+
+public static class BrowserDefinitions
+{
+ public static readonly BrowserDefinition Chrome = new(
+ "Chrome",
+ "chrome",
+ "chrome.exe",
+ BrowserLaunchKind.Chromium,
+ [
+ InProgramFiles(@"Google\Chrome\Application\chrome.exe"),
+ InProgramFilesX86(@"Google\Chrome\Application\chrome.exe"),
+ InLocalAppData(@"Google\Chrome\Application\chrome.exe"),
+ ]);
+
+ public static readonly BrowserDefinition Edge = new(
+ "Edge",
+ "msedge",
+ "msedge.exe",
+ BrowserLaunchKind.Chromium,
+ [
+ InProgramFilesX86(@"Microsoft\Edge\Application\msedge.exe"),
+ InProgramFiles(@"Microsoft\Edge\Application\msedge.exe"),
+ InLocalAppData(@"Microsoft\Edge\Application\msedge.exe"),
+ ]);
+
+ public static readonly BrowserDefinition Firefox = new(
+ "Firefox",
+ "firefox",
+ "firefox.exe",
+ BrowserLaunchKind.Firefox,
+ [
+ InProgramFiles(@"Mozilla Firefox\firefox.exe"),
+ InProgramFilesX86(@"Mozilla Firefox\firefox.exe"),
+ InLocalAppData(@"Mozilla Firefox\firefox.exe"),
+ ]);
+
+ public static IReadOnlyList All { get; } = [Chrome, Edge, Firefox];
+
+ public static TheoryData AllTheoryData()
+ {
+ var data = new TheoryData();
+ foreach (var browser in All)
+ {
+ data.Add(browser);
+ }
+
+ return data;
+ }
+
+ private static string InProgramFiles(string relativePath) => InFolder(Environment.SpecialFolder.ProgramFiles, relativePath);
+
+ private static string InProgramFilesX86(string relativePath) => InFolder(Environment.SpecialFolder.ProgramFilesX86, relativePath);
+
+ private static string InLocalAppData(string relativePath) => InFolder(Environment.SpecialFolder.LocalApplicationData, relativePath);
+
+ private static string InFolder(Environment.SpecialFolder folder, string relativePath)
+ {
+ var root = Environment.GetFolderPath(folder);
+ return string.IsNullOrWhiteSpace(root) ? relativePath : Path.Combine(root, relativePath);
+ }
+}
diff --git a/src/extension/WcpBrowserTabs/WcpBrowserTabs.UiTests/BrowserDiscoveryUiTests.cs b/src/extension/WcpBrowserTabs/WcpBrowserTabs.UiTests/BrowserDiscoveryUiTests.cs
new file mode 100644
index 0000000..149a81e
--- /dev/null
+++ b/src/extension/WcpBrowserTabs/WcpBrowserTabs.UiTests/BrowserDiscoveryUiTests.cs
@@ -0,0 +1,80 @@
+using Xunit;
+using Xunit.Abstractions;
+
+namespace WcpBrowserTabs.UiTests;
+
+public sealed class BrowserDiscoveryUiTests
+{
+ private readonly ITestOutputHelper output;
+
+ public BrowserDiscoveryUiTests(ITestOutputHelper output)
+ {
+ this.output = output;
+ }
+
+ [SkippableTheory]
+ [MemberData(nameof(BrowserDefinitions.AllTheoryData), MemberType = typeof(BrowserDefinitions))]
+ [Trait("Category", "Ui")]
+ public void DiscoversKnownTabs(BrowserDefinition browser)
+ {
+ var executablePath = browser.FindExecutablePath();
+ Skip.If(executablePath is null, $"{browser.DisplayName} executable was not found.");
+
+ using var session = BrowserTestSession.Start(browser, output, executablePath);
+ var tabs = session.WaitForExpectedTabs();
+
+ Assert.All(session.ExpectedTitles, title =>
+ Assert.Contains(tabs, tab =>
+ string.Equals(tab.Browser, browser.DisplayName, StringComparison.Ordinal) &&
+ string.Equals(tab.TabTitle, title, StringComparison.Ordinal)));
+ }
+
+ [SkippableFact]
+ [Trait("Category", "Ui")]
+ public void DiscoversKnownTabsForAllInstalledSupportedBrowsers()
+ {
+ using var sessions = new BrowserTestSessionCollection();
+
+ foreach (var browser in BrowserDefinitions.All)
+ {
+ var executablePath = browser.FindExecutablePath();
+ if (executablePath is null)
+ {
+ output.WriteLine($"{browser.DisplayName} executable was not found; skipping in combined run.");
+ continue;
+ }
+
+ sessions.Add(BrowserTestSession.Start(browser, output, executablePath));
+ }
+
+ Skip.If(sessions.Count == 0, "No supported browser executable was found.");
+
+ foreach (var session in sessions)
+ {
+ var tabs = session.WaitForExpectedTabs();
+ Assert.All(session.ExpectedTitles, title =>
+ Assert.Contains(tabs, tab =>
+ string.Equals(tab.Browser, session.Browser.DisplayName, StringComparison.Ordinal) &&
+ string.Equals(tab.TabTitle, title, StringComparison.Ordinal)));
+ }
+ }
+
+ private sealed class BrowserTestSessionCollection : IDisposable
+ {
+ private readonly List sessions = [];
+
+ public int Count => sessions.Count;
+
+ public void Add(BrowserTestSession session) => sessions.Add(session);
+
+ public List.Enumerator GetEnumerator() => sessions.GetEnumerator();
+
+ public void Dispose()
+ {
+ foreach (var session in sessions.AsEnumerable().Reverse())
+ {
+ session.Dispose();
+ }
+ }
+ }
+}
diff --git a/src/extension/WcpBrowserTabs/WcpBrowserTabs.UiTests/BrowserTestSession.cs b/src/extension/WcpBrowserTabs/WcpBrowserTabs.UiTests/BrowserTestSession.cs
new file mode 100644
index 0000000..8f157fa
--- /dev/null
+++ b/src/extension/WcpBrowserTabs/WcpBrowserTabs.UiTests/BrowserTestSession.cs
@@ -0,0 +1,309 @@
+using System.Diagnostics;
+using System.Net;
+using Xunit;
+using Xunit.Sdk;
+using Xunit.Abstractions;
+
+namespace WcpBrowserTabs.UiTests;
+
+internal sealed class BrowserTestSession : IDisposable
+{
+ private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(300);
+ private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30);
+ private readonly ITestOutputHelper output;
+ private readonly string tempRoot;
+ private readonly Process browserProcess;
+ private bool disposed;
+
+ private BrowserTestSession(
+ BrowserDefinition browser,
+ string tempRoot,
+ IReadOnlyList expectedTitles,
+ Process browserProcess,
+ ITestOutputHelper output)
+ {
+ Browser = browser;
+ this.tempRoot = tempRoot;
+ this.browserProcess = browserProcess;
+ this.output = output;
+ ExpectedTitles = expectedTitles;
+ }
+
+ public BrowserDefinition Browser { get; }
+
+ public IReadOnlyList ExpectedTitles { get; }
+
+ public static BrowserTestSession Start(BrowserDefinition browser, ITestOutputHelper output, string? executablePath = null)
+ {
+ executablePath ??= browser.FindExecutablePath();
+ if (executablePath is null)
+ {
+ throw new InvalidOperationException($"{browser.DisplayName} executable was not found.");
+ }
+
+ var runId = Guid.NewGuid().ToString("N")[..8];
+ var tempRoot = Path.Combine(Path.GetTempPath(), "WcpBrowserTabs.UiTests", $"{browser.ProcessName}-{runId}");
+ var profilePath = Path.Combine(tempRoot, "profile");
+ var pagesPath = Path.Combine(tempRoot, "pages");
+
+ Directory.CreateDirectory(profilePath);
+ Directory.CreateDirectory(pagesPath);
+ WriteProfilePreferences(browser, profilePath);
+
+ var expectedTitles = new[]
+ {
+ $"WCP UI Test {browser.DisplayName} Alpha {runId}",
+ $"WCP UI Test {browser.DisplayName} Bravo {runId}",
+ };
+
+ var pageUrls = expectedTitles
+ .Select((title, index) => CreateTestPage(pagesPath, $"{index + 1}-{browser.ProcessName}.html", title))
+ .ToArray();
+
+ var startInfo = new ProcessStartInfo(executablePath)
+ {
+ CreateNoWindow = false,
+ UseShellExecute = false,
+ };
+ browser.AddLaunchArguments(startInfo, profilePath, pageUrls);
+
+ output.WriteLine($"Launching {browser.DisplayName}: {executablePath}");
+ var process = Process.Start(startInfo) ?? throw new InvalidOperationException($"Unable to launch {browser.DisplayName}.");
+ return new BrowserTestSession(browser, tempRoot, expectedTitles, process, output);
+ }
+
+ public IReadOnlyList WaitForExpectedTabs(TimeSpan? timeout = null)
+ {
+ IReadOnlyList lastMatches = [];
+ var deadline = DateTimeOffset.UtcNow + (timeout ?? DefaultTimeout);
+
+ while (DateTimeOffset.UtcNow < deadline)
+ {
+ lastMatches = GetExpectedTabs();
+ if (ExpectedTitles.All(title => lastMatches.Any(tab => string.Equals(tab.TabTitle, title, StringComparison.Ordinal))))
+ {
+ WriteTabs("Discovered expected tabs", lastMatches);
+ return lastMatches;
+ }
+
+ Thread.Sleep(PollInterval);
+ }
+
+ WriteTabs($"Timed out waiting for expected {Browser.DisplayName} tabs", lastMatches);
+ WriteTabs($"All discovered {Browser.DisplayName} tabs", GetBrowserTabs());
+ if (Browser.LaunchKind == BrowserLaunchKind.Firefox)
+ {
+ throw new Xunit.SkipException("Firefox did not expose the expected selectable UI Automation tab items. This Firefox build or profile configuration may not support the tab UIA surface used by Browser Tabs.");
+ }
+
+ throw new XunitException($"Timed out waiting for {Browser.DisplayName} to expose expected UI Automation tab items.");
+ }
+
+ public BrowserTab WaitForInactiveExpectedTab()
+ {
+ var tabs = WaitForExpectedTabs();
+ var inactiveTab = tabs.FirstOrDefault(tab => !tab.IsActiveTab);
+ if (inactiveTab is not null)
+ {
+ return inactiveTab;
+ }
+
+ WriteTabs($"{Browser.DisplayName} did not expose an inactive expected tab", tabs);
+ throw new Xunit.SkipException($"{Browser.DisplayName} did not expose a non-active tab through UI Automation.");
+ }
+
+ public BrowserTab WaitForActiveExpectedTab(string title, TimeSpan? timeout = null)
+ {
+ IReadOnlyList lastMatches = [];
+ var deadline = DateTimeOffset.UtcNow + (timeout ?? DefaultTimeout);
+
+ while (DateTimeOffset.UtcNow < deadline)
+ {
+ lastMatches = GetExpectedTabs();
+ var activeTab = lastMatches.FirstOrDefault(tab =>
+ tab.IsActiveTab &&
+ string.Equals(tab.TabTitle, title, StringComparison.Ordinal));
+
+ if (activeTab is not null)
+ {
+ WriteTabs($"Activated {Browser.DisplayName} tab", lastMatches);
+ return activeTab;
+ }
+
+ Thread.Sleep(PollInterval);
+ }
+
+ WriteTabs($"Timed out waiting for active {Browser.DisplayName} tab", lastMatches);
+ throw new XunitException($"Timed out waiting for '{title}' to become the active {Browser.DisplayName} tab.");
+ }
+
+ public void Dispose()
+ {
+ if (disposed)
+ {
+ return;
+ }
+
+ disposed = true;
+
+ try
+ {
+ if (!browserProcess.HasExited)
+ {
+ _ = browserProcess.CloseMainWindow();
+ if (!browserProcess.WaitForExit(3000))
+ {
+ browserProcess.Kill(entireProcessTree: true);
+ browserProcess.WaitForExit(5000);
+ }
+ }
+ }
+ catch (InvalidOperationException)
+ {
+ }
+ catch (Exception ex)
+ {
+ output.WriteLine($"Failed to stop {Browser.DisplayName}: {ex.Message}");
+ }
+ finally
+ {
+ browserProcess.Dispose();
+ StopBrowserWindowsForExpectedTitles();
+ DeleteTempRoot();
+ }
+ }
+
+ private static string CreateTestPage(string pagesPath, string fileName, string title)
+ {
+ var pagePath = Path.Combine(pagesPath, fileName);
+ var encodedTitle = WebUtility.HtmlEncode(title);
+ File.WriteAllText(
+ pagePath,
+ $"""
+
+
+
+
+ {encodedTitle}
+
+
+ {encodedTitle}
+
+
+ """);
+
+ return new Uri(pagePath).AbsoluteUri;
+ }
+
+ private static void WriteProfilePreferences(BrowserDefinition browser, string profilePath)
+ {
+ if (browser.LaunchKind != BrowserLaunchKind.Firefox)
+ {
+ return;
+ }
+
+ File.WriteAllLines(
+ Path.Combine(profilePath, "user.js"),
+ [
+ "user_pref(\"accessibility.force_disabled\", 0);",
+ "user_pref(\"browser.aboutwelcome.enabled\", false);",
+ "user_pref(\"browser.shell.checkDefaultBrowser\", false);",
+ "user_pref(\"browser.startup.homepage_override.mstone\", \"ignore\");",
+ "user_pref(\"browser.startup.page\", 0);",
+ "user_pref(\"datareporting.policy.dataSubmissionPolicyAcceptedVersion\", 2);",
+ "user_pref(\"datareporting.policy.dataSubmissionPolicyBypassNotification\", true);",
+ "user_pref(\"startup.homepage_welcome_url\", \"\");",
+ "user_pref(\"startup.homepage_welcome_url.additional\", \"\");",
+ "user_pref(\"toolkit.telemetry.reportingpolicy.firstRun\", false);",
+ ]);
+ }
+
+ private BrowserTab[] GetExpectedTabs()
+ {
+ return GetBrowserTabs()
+ .Where(tab =>
+ ExpectedTitles.Contains(tab.TabTitle, StringComparer.Ordinal))
+ .ToArray();
+ }
+
+ private BrowserTab[] GetBrowserTabs()
+ {
+ return BrowserTabDiscoveryService
+ .GetTabs()
+ .Where(tab => string.Equals(tab.Browser, Browser.DisplayName, StringComparison.Ordinal))
+ .ToArray();
+ }
+
+ private void WriteTabs(string heading, IReadOnlyList tabs)
+ {
+ output.WriteLine(heading);
+ if (tabs.Count == 0)
+ {
+ output.WriteLine(" No matching tabs found.");
+ return;
+ }
+
+ foreach (var tab in tabs)
+ {
+ output.WriteLine($" [{(tab.IsActiveTab ? "active" : "inactive")}] {tab.Browser}: {tab.TabTitle} ({tab.WindowTitle})");
+ }
+ }
+
+ private void DeleteTempRoot()
+ {
+ for (var attempt = 1; attempt <= 3; attempt++)
+ {
+ try
+ {
+ if (Directory.Exists(tempRoot))
+ {
+ Directory.Delete(tempRoot, recursive: true);
+ }
+
+ return;
+ }
+ catch (IOException) when (attempt < 3)
+ {
+ Thread.Sleep(500);
+ }
+ catch (UnauthorizedAccessException) when (attempt < 3)
+ {
+ Thread.Sleep(500);
+ }
+ catch (Exception ex)
+ {
+ output.WriteLine($"Failed to delete temporary browser profile '{tempRoot}': {ex.Message}");
+ return;
+ }
+ }
+ }
+
+ private void StopBrowserWindowsForExpectedTitles()
+ {
+ foreach (var process in Process.GetProcessesByName(Browser.ProcessName))
+ {
+ try
+ {
+ var title = process.MainWindowTitle;
+ if (string.IsNullOrWhiteSpace(title) || !ExpectedTitles.Any(expectedTitle => title.Contains(expectedTitle, StringComparison.Ordinal)))
+ {
+ continue;
+ }
+
+ process.Kill(entireProcessTree: true);
+ process.WaitForExit(5000);
+ }
+ catch (InvalidOperationException)
+ {
+ }
+ catch (Exception ex)
+ {
+ output.WriteLine($"Failed to stop {Browser.DisplayName} test window {process.Id}: {ex.Message}");
+ }
+ finally
+ {
+ process.Dispose();
+ }
+ }
+ }
+}
diff --git a/src/extension/WcpBrowserTabs/WcpBrowserTabs.UiTests/WcpBrowserTabs.UiTests.csproj b/src/extension/WcpBrowserTabs/WcpBrowserTabs.UiTests/WcpBrowserTabs.UiTests.csproj
new file mode 100644
index 0000000..a260256
--- /dev/null
+++ b/src/extension/WcpBrowserTabs/WcpBrowserTabs.UiTests/WcpBrowserTabs.UiTests.csproj
@@ -0,0 +1,39 @@
+
+
+ net9.0-windows10.0.22621.0
+ 10.0.19041.0
+ 10.0.19041.0
+ enable
+ enable
+ false
+ true
+ None
+ false
+ false
+ false
+ x64;ARM64
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/extension/WcpBrowserTabs/WcpBrowserTabs.UiTests/coverage.runsettings b/src/extension/WcpBrowserTabs/WcpBrowserTabs.UiTests/coverage.runsettings
new file mode 100644
index 0000000..c855e2c
--- /dev/null
+++ b/src/extension/WcpBrowserTabs/WcpBrowserTabs.UiTests/coverage.runsettings
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+ cobertura
+ [WcpBrowserTabs]*
+ [WcpBrowserTabs.UiTests]*
+ **/obj/**/*.cs
+ true
+
+
+
+
+
diff --git a/src/extension/WcpBrowserTabs/WcpBrowserTabs.UiTests/xunit.runner.json b/src/extension/WcpBrowserTabs/WcpBrowserTabs.UiTests/xunit.runner.json
new file mode 100644
index 0000000..8f5f105
--- /dev/null
+++ b/src/extension/WcpBrowserTabs/WcpBrowserTabs.UiTests/xunit.runner.json
@@ -0,0 +1,5 @@
+{
+ "parallelizeAssembly": false,
+ "parallelizeTestCollections": false,
+ "maxParallelThreads": 1
+}
diff --git a/src/extension/WcpBrowserTabs/WcpBrowserTabs.sln b/src/extension/WcpBrowserTabs/WcpBrowserTabs.sln
index 7f55ae3..68b6828 100644
--- a/src/extension/WcpBrowserTabs/WcpBrowserTabs.sln
+++ b/src/extension/WcpBrowserTabs/WcpBrowserTabs.sln
@@ -5,6 +5,10 @@ VisualStudioVersion = 17.13.35507.96 d17.13
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WcpBrowserTabs", "WcpBrowserTabs\WcpBrowserTabs.csproj", "{79F86DE5-70B1-4EC1-9832-DF428B55E466}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WcpBrowserTabs.UiTests", "WcpBrowserTabs.UiTests\WcpBrowserTabs.UiTests.csproj", "{3D75919E-8824-4989-8774-0D3023266895}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WcpBrowserTabs.Tests", "WcpBrowserTabs.Tests\WcpBrowserTabs.Tests.csproj", "{D3DC6569-4A5B-4888-86B0-4827EE9C79B3}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|ARM64 = Debug|ARM64
@@ -33,6 +37,30 @@ Global
{79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|x86.ActiveCfg = Release|x86
{79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|x86.Build.0 = Release|x86
{79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|x86.Deploy.0 = Release|x86
+ {3D75919E-8824-4989-8774-0D3023266895}.Debug|ARM64.ActiveCfg = Debug|ARM64
+ {3D75919E-8824-4989-8774-0D3023266895}.Debug|ARM64.Build.0 = Debug|ARM64
+ {3D75919E-8824-4989-8774-0D3023266895}.Debug|x64.ActiveCfg = Debug|x64
+ {3D75919E-8824-4989-8774-0D3023266895}.Debug|x64.Build.0 = Debug|x64
+ {3D75919E-8824-4989-8774-0D3023266895}.Debug|x86.ActiveCfg = Debug|x64
+ {3D75919E-8824-4989-8774-0D3023266895}.Debug|x86.Build.0 = Debug|x64
+ {3D75919E-8824-4989-8774-0D3023266895}.Release|ARM64.ActiveCfg = Release|ARM64
+ {3D75919E-8824-4989-8774-0D3023266895}.Release|ARM64.Build.0 = Release|ARM64
+ {3D75919E-8824-4989-8774-0D3023266895}.Release|x64.ActiveCfg = Release|x64
+ {3D75919E-8824-4989-8774-0D3023266895}.Release|x64.Build.0 = Release|x64
+ {3D75919E-8824-4989-8774-0D3023266895}.Release|x86.ActiveCfg = Release|x64
+ {3D75919E-8824-4989-8774-0D3023266895}.Release|x86.Build.0 = Release|x64
+ {D3DC6569-4A5B-4888-86B0-4827EE9C79B3}.Debug|ARM64.ActiveCfg = Debug|ARM64
+ {D3DC6569-4A5B-4888-86B0-4827EE9C79B3}.Debug|ARM64.Build.0 = Debug|ARM64
+ {D3DC6569-4A5B-4888-86B0-4827EE9C79B3}.Debug|x64.ActiveCfg = Debug|x64
+ {D3DC6569-4A5B-4888-86B0-4827EE9C79B3}.Debug|x64.Build.0 = Debug|x64
+ {D3DC6569-4A5B-4888-86B0-4827EE9C79B3}.Debug|x86.ActiveCfg = Debug|x64
+ {D3DC6569-4A5B-4888-86B0-4827EE9C79B3}.Debug|x86.Build.0 = Debug|x64
+ {D3DC6569-4A5B-4888-86B0-4827EE9C79B3}.Release|ARM64.ActiveCfg = Release|ARM64
+ {D3DC6569-4A5B-4888-86B0-4827EE9C79B3}.Release|ARM64.Build.0 = Release|ARM64
+ {D3DC6569-4A5B-4888-86B0-4827EE9C79B3}.Release|x64.ActiveCfg = Release|x64
+ {D3DC6569-4A5B-4888-86B0-4827EE9C79B3}.Release|x64.Build.0 = Release|x64
+ {D3DC6569-4A5B-4888-86B0-4827EE9C79B3}.Release|x86.ActiveCfg = Release|x64
+ {D3DC6569-4A5B-4888-86B0-4827EE9C79B3}.Release|x86.Build.0 = Release|x64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/src/extension/WcpBrowserTabs/WcpBrowserTabs/BrowserTabDiscoveryService.cs b/src/extension/WcpBrowserTabs/WcpBrowserTabs/BrowserTabDiscoveryService.cs
index b6ac766..130aa57 100644
--- a/src/extension/WcpBrowserTabs/WcpBrowserTabs/BrowserTabDiscoveryService.cs
+++ b/src/extension/WcpBrowserTabs/WcpBrowserTabs/BrowserTabDiscoveryService.cs
@@ -50,15 +50,15 @@ private static IReadOnlyList GetTabsWindows()
UIA_ControlTypeIds.UIA_TabItemControlTypeId);
var windows = root.FindAll(TreeScope.TreeScope_Children, windowCondition);
- var tabs = new List();
- var seenTabs = new HashSet(StringComparer.Ordinal);
+ var windowsSnapshot = new List();
for (var i = 0; i < windows.Length; i++)
{
try
{
var window = windows.GetElement(i);
- if (!TryGetBrowserName(window.CurrentProcessId, out var browser))
+ var processId = window.CurrentProcessId;
+ if (!TryGetBrowserName(GetProcessName(processId), out _))
{
continue;
}
@@ -71,41 +71,79 @@ private static IReadOnlyList GetTabsWindows()
var windowTitle = window.CurrentName?.Trim() ?? string.Empty;
var tabItems = window.FindAll(TreeScope.TreeScope_Descendants, tabCondition);
+ var tabs = new List();
for (var j = 0; j < tabItems.Length; j++)
{
try
{
var tab = tabItems.GetElement(j);
- var tabTitle = tab.CurrentName?.Trim();
- if (string.IsNullOrWhiteSpace(tabTitle))
- {
- continue;
- }
-
- var tabKey = $"{browser}\u001f{windowHandle}\u001f{tabTitle}";
- if (!seenTabs.Add(tabKey))
- {
- continue;
- }
-
- tabs.Add(new BrowserTab(
- browser,
- window.CurrentProcessId,
- windowHandle,
- windowTitle,
- tabTitle,
- IsActiveTab(tab)));
+ tabs.Add(new DiscoveredBrowserTab(tab.CurrentName, IsActiveTab(tab)));
}
catch
{
- // Ignore individual tab failures and continue building partial results.
+ // Ignore individual tab failures and continue building partial snapshots.
}
}
+
+ windowsSnapshot.Add(new DiscoveredBrowserWindow(
+ processId,
+ windowHandle,
+ windowTitle,
+ tabs));
}
catch
{
- // Ignore individual window failures and continue building partial results.
+ // Ignore individual window failures and continue building partial snapshots.
+ }
+ }
+
+ return CreateTabs(windowsSnapshot, GetProcessName);
+ }
+
+ internal static IReadOnlyList CreateTabs(
+ IEnumerable windows,
+ Func getProcessName)
+ {
+ ArgumentNullException.ThrowIfNull(windows);
+ ArgumentNullException.ThrowIfNull(getProcessName);
+
+ var tabs = new List();
+ var seenTabs = new HashSet(StringComparer.Ordinal);
+
+ foreach (var window in windows)
+ {
+ if (!TryGetBrowserName(getProcessName(window.ProcessId), out var browser))
+ {
+ continue;
+ }
+
+ if (window.WindowHandle == nint.Zero)
+ {
+ continue;
+ }
+
+ foreach (var tab in window.Tabs)
+ {
+ var tabTitle = tab.Title?.Trim();
+ if (string.IsNullOrWhiteSpace(tabTitle))
+ {
+ continue;
+ }
+
+ var tabKey = $"{browser}\u001f{window.WindowHandle}\u001f{tabTitle}";
+ if (!seenTabs.Add(tabKey))
+ {
+ continue;
+ }
+
+ tabs.Add(new BrowserTab(
+ browser,
+ window.ProcessId,
+ window.WindowHandle,
+ window.WindowTitle.Trim(),
+ tabTitle,
+ tab.IsActiveTab));
}
}
@@ -172,58 +210,60 @@ private static TabActivationResult TryActivateTabWindows(BrowserTab targetTab)
}
}
- private static bool TryGetBrowserName(int processId, out string browserName)
+ private static string? GetProcessName(int processId)
{
- browserName = string.Empty;
if (processId <= 0)
{
- return false;
+ return null;
}
try
{
- var processName = Process.GetProcessById(processId).ProcessName;
- if (processName.Equals("chrome", StringComparison.OrdinalIgnoreCase))
- {
- browserName = "Chrome";
- return true;
- }
-
- if (processName.Equals("msedge", StringComparison.OrdinalIgnoreCase))
- {
- browserName = "Edge";
- return true;
- }
-
- return false;
+ return Process.GetProcessById(processId).ProcessName;
}
catch (ArgumentException)
{
- return false;
+ return null;
}
catch (InvalidOperationException)
{
- return false;
+ return null;
}
}
- [SupportedOSPlatform("windows")]
- private static bool IsActiveTab(IUIAutomationElement tab)
+ internal static bool TryGetBrowserName(string? processName, out string browserName)
{
- try
+ browserName = string.Empty;
+ if (string.IsNullOrWhiteSpace(processName))
{
- var selectionPattern = tab.GetCurrentPattern(UIA_PatternIds.UIA_SelectionItemPatternId) as IUIAutomationSelectionItemPattern;
- return selectionPattern?.CurrentIsSelected == 1;
+ return false;
}
- catch
+
+ if (processName.Equals("chrome", StringComparison.OrdinalIgnoreCase))
{
- return false;
+ browserName = "Chrome";
+ return true;
+ }
+
+ if (processName.Equals("msedge", StringComparison.OrdinalIgnoreCase))
+ {
+ browserName = "Edge";
+ return true;
}
+
+ if (processName.Equals("firefox", StringComparison.OrdinalIgnoreCase))
+ {
+ browserName = "Firefox";
+ return true;
+ }
+
+ return false;
}
- [SupportedOSPlatform("windows")]
- private static T RunInStaThread(Func work)
+ internal static T RunInStaThread(Func work)
{
+ ArgumentNullException.ThrowIfNull(work);
+
T? result = default;
Exception? error = null;
@@ -250,6 +290,28 @@ private static T RunInStaThread(Func work)
return result!;
}
+
+ internal sealed record DiscoveredBrowserWindow(
+ int ProcessId,
+ nint WindowHandle,
+ string WindowTitle,
+ IReadOnlyList Tabs);
+
+ internal sealed record DiscoveredBrowserTab(string? Title, bool IsActiveTab);
+
+ [SupportedOSPlatform("windows")]
+ private static bool IsActiveTab(IUIAutomationElement tab)
+ {
+ try
+ {
+ var selectionPattern = tab.GetCurrentPattern(UIA_PatternIds.UIA_SelectionItemPatternId) as IUIAutomationSelectionItemPattern;
+ return selectionPattern?.CurrentIsSelected == 1;
+ }
+ catch
+ {
+ return false;
+ }
+ }
}
internal readonly record struct TabActivationResult(bool Success, string ErrorMessage)
diff --git a/src/extension/WcpBrowserTabs/WcpBrowserTabs/Pages/WcpBrowserTabsPage.cs b/src/extension/WcpBrowserTabs/WcpBrowserTabs/Pages/WcpBrowserTabsPage.cs
index 6540eb6..224a71f 100644
--- a/src/extension/WcpBrowserTabs/WcpBrowserTabs/Pages/WcpBrowserTabsPage.cs
+++ b/src/extension/WcpBrowserTabs/WcpBrowserTabs/Pages/WcpBrowserTabsPage.cs
@@ -7,42 +7,55 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using System.Threading.Tasks;
namespace WcpBrowserTabs;
-internal sealed partial class WcpBrowserTabsPage : DynamicListPage
+internal sealed partial class WcpBrowserTabsPage : ListPage
{
+ private static readonly TimeSpan MinimumRefreshInterval = TimeSpan.FromSeconds(2);
+
+ private readonly Func> getTabs;
+ private readonly object refreshLock = new();
+ private IReadOnlyList tabs = [];
+ private DateTimeOffset lastRefreshUtc = DateTimeOffset.MinValue;
+ private Task? refreshTask;
+
public WcpBrowserTabsPage()
+ : this(BrowserTabDiscoveryService.GetTabs)
+ {
+ }
+
+ internal WcpBrowserTabsPage(Func> getTabs)
{
+ ArgumentNullException.ThrowIfNull(getTabs);
+
+ this.getTabs = getTabs;
Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png");
Title = "Browser Tabs";
Name = "Open";
- PlaceholderText = "Search open Chrome and Edge tabs";
+ PlaceholderText = "Search open Chrome, Edge, and Firefox tabs";
EmptyContent = new CommandItem(new NoOpCommand())
{
Icon = Icon,
Title = "No browser tabs found",
- Subtitle = "Open Chrome or Edge tabs, then try again.",
+ Subtitle = "Open Chrome, Edge, or Firefox tabs, then try again.",
};
}
- public override void UpdateSearchText(string oldSearch, string newSearch) => RaiseItemsChanged();
-
public override IListItem[] GetItems()
{
- var searchText = SearchText?.Trim() ?? string.Empty;
- var matchingTabs = BrowserTabDiscoveryService
- .GetTabs()
- .Where(tab => MatchesSearch(tab, searchText))
- .ToArray();
+ EnsureRefreshStarted();
+
+ var currentTabs = GetTabsSnapshot();
- if (matchingTabs.Length == 0)
+ if (currentTabs.Length == 0)
{
return [];
}
- var items = new List(matchingTabs.Length);
- foreach (var tab in matchingTabs)
+ var items = new List(currentTabs.Length);
+ foreach (var tab in currentTabs)
{
items.Add(new ListItem(new ActivateBrowserTabCommand(tab))
{
@@ -55,16 +68,60 @@ public override IListItem[] GetItems()
return [.. items];
}
- private static bool MatchesSearch(BrowserTab tab, string searchText)
+ private BrowserTab[] GetTabsSnapshot()
{
- if (string.IsNullOrWhiteSpace(searchText))
+ lock (refreshLock)
{
- return true;
+ return [.. tabs];
+ }
+ }
+
+ private void EnsureRefreshStarted()
+ {
+ var now = DateTimeOffset.UtcNow;
+
+ lock (refreshLock)
+ {
+ if (refreshTask is { IsCompleted: false })
+ {
+ return;
+ }
+
+ if (lastRefreshUtc != DateTimeOffset.MinValue && now - lastRefreshUtc < MinimumRefreshInterval)
+ {
+ return;
+ }
+
+ IsLoading = true;
+ refreshTask = RefreshTabsAsync();
+ }
+ }
+
+ private async Task RefreshTabsAsync()
+ {
+ IReadOnlyList? refreshedTabs = null;
+
+ try
+ {
+ refreshedTabs = await Task.Run(getTabs).ConfigureAwait(false);
+ }
+ catch
+ {
+ // Keep the previous snapshot if discovery fails.
+ }
+
+ lock (refreshLock)
+ {
+ if (refreshedTabs is not null)
+ {
+ tabs = refreshedTabs;
+ }
+
+ lastRefreshUtc = DateTimeOffset.UtcNow;
}
- return tab.TabTitle.Contains(searchText, StringComparison.OrdinalIgnoreCase) ||
- tab.WindowTitle.Contains(searchText, StringComparison.OrdinalIgnoreCase) ||
- tab.Browser.Contains(searchText, StringComparison.OrdinalIgnoreCase);
+ IsLoading = false;
+ RaiseItemsChanged();
}
private static Tag[] GetTags(BrowserTab tab)
diff --git a/src/extension/WcpBrowserTabs/WcpBrowserTabs/Properties/AssemblyInfo.cs b/src/extension/WcpBrowserTabs/WcpBrowserTabs/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000..a9c2234
--- /dev/null
+++ b/src/extension/WcpBrowserTabs/WcpBrowserTabs/Properties/AssemblyInfo.cs
@@ -0,0 +1,8 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("WcpBrowserTabs.UiTests")]
+[assembly: InternalsVisibleTo("WcpBrowserTabs.Tests")]