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
9 changes: 9 additions & 0 deletions .github/workflows/desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,12 @@ jobs:
name: desktop-test-results
path: TestResults/**/*.trx
if-no-files-found: warn

portable-core:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
with:
dotnet-version: 8.0.x
- run: dotnet test OpenWrtStudio.Core.Tests/OpenWrtStudio.Core.Tests.csproj -c Release
30 changes: 21 additions & 9 deletions Models/ForkopModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,23 +38,35 @@ public partial class ForkopServerNode : ObservableObject
[ObservableProperty] private string _serverAddress = "";
[ObservableProperty] private int _serverPort;
[ObservableProperty] private string _groupTag = "";
[ObservableProperty] private int? _latencyMs;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(LatencyDisplay))]
private int? _latencyMs;
[ObservableProperty] private bool _isActive;
[ObservableProperty] private bool _isAutoGroup;
[ObservableProperty] private string _currentNode = "";
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanSelect))]
private string _selectionGroupTag = "";
[ObservableProperty] private System.Collections.Generic.List<string> _candidates = new();
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanSelect))]
private bool _hasLiveState;
[ObservableProperty] private bool _isOnActivePath;

public bool CanSelect => HasLiveState && !string.IsNullOrEmpty(SelectionGroupTag);
public string LatencyDisplay => LatencyMs.HasValue ? $"{LatencyMs.Value}ms" : "N/A";
}

public partial class ForkopDashboardStats : ObservableObject
{
[ObservableProperty] private string _inSpeed = "1.42 KB/s";
[ObservableProperty] private string _outSpeed = "0 B/s";
[ObservableProperty] private string _totalIn = "1.56 MB";
[ObservableProperty] private string _totalOut = "395 KB";
[ObservableProperty] private int _activeConnections = 35;
[ObservableProperty] private string _memoryUsage = "23.7 MB";
[ObservableProperty] private bool _isForkopRunning = true;
[ObservableProperty] private bool _isSingboxRunning = true;
[ObservableProperty] private string _inSpeed = "—";
[ObservableProperty] private string _outSpeed = "—";
[ObservableProperty] private string _totalIn = "—";
[ObservableProperty] private string _totalOut = "—";
[ObservableProperty] private int _activeConnections;
[ObservableProperty] private string _memoryUsage = "—";
[ObservableProperty] private bool _isForkopRunning;
[ObservableProperty] private bool _isSingboxRunning;
}

public partial class ForkopFeedInfo : ObservableObject
Expand Down
202 changes: 202 additions & 0 deletions OpenWrtStudio.Core.Tests/Fixtures/vpn_topology.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
{
"config": {
"route": {
"final": "direct-out"
},
"outbounds": [
{
"tag": "main-out",
"type": "selector",
"outbounds": [
"priority-route",
"auto-shrimp",
"auto-hella",
"node-a",
"node-b",
"direct-out"
]
},
{
"tag": "priority-route",
"type": "selector",
"outbounds": [
"auto-shrimp",
"auto-blanc"
]
},
{
"tag": "direct-out",
"type": "direct"
},
{
"tag": "node-a",
"type": "vless",
"server": "198.51.100.10",
"server_port": 443
},
{
"tag": "node-b",
"type": "hysteria2",
"server": "2001:db8::1",
"server_port": 8443
}
]
},
"cache": [
{
"servers": {
"node-a": "198.51.100.10",
"node-b": "2001:db8::1",
"node-b-plus": "198.51.100.11"
},
"outboundMetadata": {
"names": {
"node-a": "Blanc Amsterdam",
"node-b": "Shrimp Helsinki"
},
"protocols": {
"node-a": "vless",
"node-b": "hysteria2"
},
"countries": {
"node-a": "NL",
"node-b": "FI"
}
},
"urltestGroups": {
"auto-blanc": {
"name": "Авто Blanc (Запасной 2)"
},
"auto-shrimp": {
"name": "Авто Shrimp (Основной)"
},
"auto-hella": {
"name": "Авто Hella (Запасной 3)"
}
},
"priorityGroups": {
"priority-route": {
"name": "Приоритет: Shrimp -> Stealth -> Blanc -> Hella -> Free"
}
},
"section": "main"
},
{
"urltestGroups": {
"auto-stealth": {
"name": "Авто Stealth (Запасной 1)"
},
"auto-free": {
"name": "Авто Фри (Аварийный)"
}
}
}
],
"proxies": {
"GLOBAL": {
"type": "Selector",
"now": "direct-out",
"all": [
"main-out",
"direct-out"
]
},
"auto-blanc": {
"type": "URLTest",
"now": "node-a",
"all": [
"node-a"
],
"history": [
{
"delay": 999
},
{
"delay": 0
}
]
},
"node-a": {
"type": "VLESS",
"history": [
{
"delay": 210
}
]
},
"direct-out": {
"type": "Direct"
},
"bypass-out": {
"type": "Direct"
},
"REJECT": {
"type": "Reject"
},
"main-out": {
"type": "Selector",
"now": "priority-route",
"all": [
"priority-route",
"auto-shrimp",
"auto-hella",
"node-a",
"node-b",
"direct-out"
]
},
"priority-route": {
"type": "Selector",
"now": "auto-shrimp",
"all": [
"auto-shrimp",
"auto-blanc"
]
},
"auto-shrimp": {
"type": "URLTest",
"now": "node-b",
"all": [
"node-b"
]
},
"auto-hella": {
"type": "URLTest",
"now": "node-a",
"all": [
"node-a"
]
},
"auto-stealth": {
"type": "URLTest",
"now": "node-a",
"all": [
"node-a"
]
},
"auto-free": {
"type": "URLTest",
"now": "node-a",
"all": [
"node-a"
]
},
"node-b": {
"type": "Hysteria2",
"history": [
{
"delay": 167
}
]
},
"node-b-plus": {
"type": "VLESS",
"history": [
{
"delay": 0
}
]
}
},
"schemaSource": "ushan0v/forkop @ dd483297be0ac4e52bb8f482e2640238b75af532: section-cache outboundMetadata column maps and section selectors"
}
92 changes: 92 additions & 0 deletions OpenWrtStudio.Core.Tests/LiveVpnSelectionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using System.Text.Json;
using System.IO;
using OpenWrtStudio.Models;
using OpenWrtStudio.Services;
using Xunit;

namespace OpenWrtStudio.Core.Tests;

public sealed class LiveVpnSelectionFactAttribute : FactAttribute
{
public LiveVpnSelectionFactAttribute()
{
if (Environment.GetEnvironmentVariable("OPENWRT_LIVE_VPN_SWITCH") != "1" ||
string.IsNullOrEmpty(Environment.GetEnvironmentVariable("OPENWRT_TEST_HOST")) ||
string.IsNullOrEmpty(Environment.GetEnvironmentVariable("OPENWRT_TEST_PASSWORD")))
Skip = "Explicitly opt in with OPENWRT_LIVE_VPN_SWITCH=1 and router credentials; changes live VPN selection temporarily.";
}
}

public class LiveVpnSelectionTests
{
[LiveVpnSelectionFact]
[Trait("Category", "LiveVpnSwitch")]
public async Task PolicyAndManualNodeAreConfirmedByIndependentReadAndOriginalSelectionIsRestored()
{
using var ssh = new SshService();
var connection = await ssh.ConnectAsync(new ConnectionProfile
{
Host = Environment.GetEnvironmentVariable("OPENWRT_TEST_HOST")!,
Username = Environment.GetEnvironmentVariable("OPENWRT_TEST_USER") ?? "root",
Password = Environment.GetEnvironmentVariable("OPENWRT_TEST_PASSWORD")!,
Port = int.TryParse(Environment.GetEnvironmentVariable("OPENWRT_TEST_PORT"), out var port) ? port : 22
});
Assert.True(connection.Success, connection.Message);
async Task<Dictionary<string, string>> ReadSelectors()
{
var (code, output, _) = await ssh.ExecuteCommandAsync("/usr/bin/forkop clash_api get_proxies", 15);
Assert.Equal(0, code);
using var doc = JsonDocument.Parse(output);
return doc.RootElement.GetProperty("proxies").EnumerateObject()
.Where(p => p.Value.TryGetProperty("type", out var t) && t.GetString() == "Selector")
.ToDictionary(p => p.Name, p => p.Value.GetProperty("now").GetString()!, StringComparer.Ordinal);
}
var original = await ReadSelectors();
Assert.NotEmpty(original);
var theme = await ssh.ExecuteCommandAsync("uci -q get luci.main.mediaurlbase", 5);
Assert.Equal(0, theme.ExitCode);
var report = new List<object>();
try
{
var service = new ForkopService(ssh);
var nodes = await service.GetServersAsync();
Assert.DoesNotContain(nodes, n => new[] { "GLOBAL", "direct-out", "bypass-out", "REJECT" }.Contains(n.GroupTag));
var root = nodes.Single(n => n.GroupTag == "main-out");
Assert.True(root.HasLiveState);
var policy = nodes.First(n => n.Protocol == "URLTest" && n.SelectionGroupTag == root.GroupTag && n.GroupTag != root.CurrentNode);
var manual = nodes.First(n => !n.IsAutoGroup && n.SelectionGroupTag == root.GroupTag && n.GroupTag != root.CurrentNode);
foreach (var target in new[] { policy, manual })
{
var result = await service.SelectActiveServerAsync(target);
Assert.True(result.Success, result.Message);
var actual = await ReadSelectors();
Assert.Equal(target.GroupTag, actual[target.SelectionGroupTag]);
var refreshed = await service.GetServersAsync();
Assert.Contains(refreshed, n => n.GroupTag == target.GroupTag && n.IsOnActivePath && n.HasLiveState);
report.Add(new { Kind = target.IsAutoGroup ? "automatic-policy" : "manual-node", ConfirmedByRouter = true });
}
var beforeInvalid = await ReadSelectors();
Assert.False((await service.SelectActiveServerAsync(new ForkopServerNode { GroupTag = "studio-nonexistent-test-node" })).Success);
var afterInvalid = await ReadSelectors();
Assert.Equal(beforeInvalid.OrderBy(p => p.Key), afterInvalid.OrderBy(p => p.Key));
report.Add(new { Kind = "invalid-target", RejectedWithoutChangingSelectors = true });
}
finally
{
foreach (var pair in original.OrderBy(p => p.Key == "main-out" ? 1 : 0))
{
var result = await ssh.ExecuteCommandAsync($"/usr/bin/forkop clash_api set_group_proxy {ShellHelper.EscapeArg(pair.Key)} {ShellHelper.EscapeArg(pair.Value)}", 15);
Assert.Equal(0, result.ExitCode);
}
var restored = await ReadSelectors();
Assert.Equal(original.OrderBy(p => p.Key), restored.OrderBy(p => p.Key));
var finalTheme = await ssh.ExecuteCommandAsync("uci -q get luci.main.mediaurlbase", 5);
Assert.Equal(theme.Output.Trim(), finalTheme.Output.Trim());
report.Add(new { OriginalSelectorsRestored = true, OriginalThemeUnchanged = true });
var path = Environment.GetEnvironmentVariable("OPENWRT_VPN_REPORT_PATH");
if (!string.IsNullOrEmpty(path))
await File.WriteAllTextAsync(path, JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true }));
await ssh.DisconnectAsync();
}
}
}
17 changes: 17 additions & 0 deletions OpenWrtStudio.Core.Tests/OpenWrtStudio.Core.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><TargetFramework>net8.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable><IsTestProject>true</IsTestProject></PropertyGroup>
<ItemGroup>
<None Update="Fixtures/*.json" CopyToOutputDirectory="PreserveNewest" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="SSH.NET" Version="2026.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
<Compile Include="../Models/ForkopModels.cs" Link="ForkopModels.cs" />
<Compile Include="../Models/ConnectionProfile.cs" Link="ConnectionProfile.cs" />
<Compile Include="../Services/ForkopService.cs" Link="ForkopService.cs" />
<Compile Include="../Services/ForkopNodeParser.cs" Link="ForkopNodeParser.cs" />
<Compile Include="../Services/SshService.cs" Link="SshService.cs" />
<Compile Include="../Services/ShellHelper.cs" Link="ShellHelper.cs" />
</ItemGroup>
</Project>
Loading