diff --git a/frontend/src/test/__snapshots__/balancer.test.ts.snap b/frontend/src/test/__snapshots__/balancer.test.ts.snap
new file mode 100644
index 00000000..9558e5dd
--- /dev/null
+++ b/frontend/src/test/__snapshots__/balancer.test.ts.snap
@@ -0,0 +1,73 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`BalancerObjectSchema fixtures > parses leastload-full byte-stably 1`] = `
+{
+ "fallbackTag": "fallback-out",
+ "selector": [
+ "proxy-",
+ ],
+ "strategy": {
+ "settings": {
+ "baselines": [
+ "500ms",
+ "1s",
+ "2s",
+ ],
+ "costs": [
+ {
+ "match": "proxy-premium",
+ "regexp": false,
+ "value": 0.1,
+ },
+ {
+ "match": "^proxy-cheap-.+$",
+ "regexp": true,
+ "value": 5,
+ },
+ ],
+ "expected": 3,
+ "maxRTT": "1s",
+ "tolerance": 0.05,
+ },
+ "type": "leastLoad",
+ },
+ "tag": "balancer-load",
+}
+`;
+
+exports[`BalancerObjectSchema fixtures > parses leastping byte-stably 1`] = `
+{
+ "fallbackTag": "fallback-out",
+ "selector": [
+ "proxy-",
+ ],
+ "strategy": {
+ "type": "leastPing",
+ },
+ "tag": "balancer-ping",
+}
+`;
+
+exports[`BalancerObjectSchema fixtures > parses random-minimal byte-stably 1`] = `
+{
+ "selector": [
+ "proxy-",
+ ],
+ "tag": "balancer-random",
+}
+`;
+
+exports[`BalancerObjectSchema fixtures > parses roundrobin byte-stably 1`] = `
+{
+ "fallbackTag": "direct",
+ "selector": [
+ "proxy-a",
+ "proxy-b",
+ "proxy-c",
+ ],
+ "strategy": {
+ "type": "roundRobin",
+ },
+ "tag": "balancer-rr",
+}
+`;
diff --git a/frontend/src/test/__snapshots__/dns.test.ts.snap b/frontend/src/test/__snapshots__/dns.test.ts.snap
new file mode 100644
index 00000000..8ecafbb0
--- /dev/null
+++ b/frontend/src/test/__snapshots__/dns.test.ts.snap
@@ -0,0 +1,116 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`DnsObjectSchema fixtures > parses full byte-stably 1`] = `
+{
+ "clientIp": "1.2.3.4",
+ "disableCache": false,
+ "disableFallback": false,
+ "disableFallbackIfMatch": true,
+ "enableParallelQuery": true,
+ "hosts": {
+ "domain:example.com": [
+ "10.0.0.1",
+ "10.0.0.2",
+ ],
+ "full:dns.google": "8.8.8.8",
+ "geosite:category-ads-all": "127.0.0.1",
+ },
+ "queryStrategy": "UseIP",
+ "serveExpiredTTL": 300,
+ "serveStale": true,
+ "servers": [
+ "fakedns",
+ "localhost",
+ "https://dns.google/dns-query",
+ {
+ "address": "tcp://1.1.1.1",
+ "clientIP": "8.8.4.4",
+ "domains": [
+ "geosite:cn",
+ ],
+ "expectedIPs": [
+ "geoip:cn",
+ ],
+ "port": 53,
+ "queryStrategy": "UseIPv4",
+ "skipFallback": true,
+ "tag": "cn-dns",
+ "timeoutMs": 3000,
+ },
+ {
+ "address": "quic+local://dns.adguard.com",
+ "disableCache": true,
+ "finalQuery": true,
+ "port": 53,
+ "serveExpiredTTL": 60,
+ "serveStale": false,
+ "timeoutMs": 5000,
+ "unexpectedIPs": [
+ "geoip:private",
+ ],
+ },
+ ],
+ "tag": "dns_inbound",
+ "useSystemHosts": true,
+}
+`;
+
+exports[`DnsObjectSchema fixtures > parses minimal byte-stably 1`] = `
+{
+ "disableCache": false,
+ "disableFallback": false,
+ "disableFallbackIfMatch": false,
+ "enableParallelQuery": false,
+ "queryStrategy": "UseIP",
+ "serveExpiredTTL": 0,
+ "serveStale": false,
+ "servers": [
+ "8.8.8.8",
+ "1.1.1.1",
+ ],
+ "useSystemHosts": false,
+}
+`;
+
+exports[`DnsServerObjectSchema fixtures > parses full byte-stably 1`] = `
+{
+ "address": "https://dns.google/dns-query",
+ "clientIP": "9.9.9.9",
+ "disableCache": false,
+ "domains": [
+ "domain:google.com",
+ "domain:youtube.com",
+ "geosite:google",
+ ],
+ "expectedIPs": [
+ "geoip:us",
+ "1.2.3.0/24",
+ ],
+ "finalQuery": false,
+ "port": 443,
+ "queryStrategy": "UseIPv6",
+ "serveExpiredTTL": 600,
+ "serveStale": true,
+ "skipFallback": false,
+ "tag": "google-doh",
+ "timeoutMs": 4000,
+ "unexpectedIPs": [
+ "geoip:private",
+ ],
+}
+`;
+
+exports[`DnsServerObjectSchema fixtures > parses legacy-expectips byte-stably 1`] = `
+{
+ "address": "8.8.8.8",
+ "domains": [
+ "geosite:cn",
+ ],
+ "expectedIPs": [
+ "geoip:cn",
+ "10.0.0.0/8",
+ ],
+ "port": 53,
+ "timeoutMs": 4000,
+}
+`;
diff --git a/frontend/src/test/__snapshots__/rule.test.ts.snap b/frontend/src/test/__snapshots__/rule.test.ts.snap
new file mode 100644
index 00000000..cace97ed
--- /dev/null
+++ b/frontend/src/test/__snapshots__/rule.test.ts.snap
@@ -0,0 +1,91 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`RuleObjectSchema fixtures > parses balancer-routed byte-stably 1`] = `
+{
+ "balancerTag": "balancer-load",
+ "domain": [
+ "geosite:geolocation-!cn",
+ ],
+ "ruleTag": "outbound-load-balance",
+ "type": "field",
+}
+`;
+
+exports[`RuleObjectSchema fixtures > parses full byte-stably 1`] = `
+{
+ "attrs": {
+ "Host": "example.com",
+ "User-Agent": "regexp:^Mozilla.*",
+ },
+ "domain": [
+ "domain:google.com",
+ "full:example.com",
+ "keyword:cdn",
+ "regexp:^api\\.example\\.com$",
+ "geosite:cn",
+ ],
+ "inboundTag": [
+ "inbound-1",
+ "inbound-2",
+ ],
+ "ip": [
+ "10.0.0.0/8",
+ "geoip:cn",
+ "geoip:private",
+ "!geoip:cn",
+ ],
+ "localIP": [
+ "10.10.10.0/24",
+ ],
+ "localPort": "5353",
+ "network": "tcp,udp",
+ "outboundTag": "proxy-out",
+ "port": "80,443,1000-2000",
+ "process": [
+ "chrome.exe",
+ "curl",
+ "self/",
+ ],
+ "protocol": [
+ "http",
+ "tls",
+ "quic",
+ "bittorrent",
+ ],
+ "ruleTag": "main-policy-rule",
+ "sourceIP": [
+ "192.168.0.0/16",
+ "geoip:private",
+ ],
+ "sourcePort": "53",
+ "type": "field",
+ "user": [
+ "user@example.com",
+ "regexp:^.+@admin\\..+$",
+ ],
+ "vlessRoute": "443,8443",
+ "webhook": {
+ "deduplication": 30,
+ "headers": {
+ "X-Auth-Token": "secret",
+ },
+ "url": "https://hook.example.com/events",
+ },
+}
+`;
+
+exports[`RuleObjectSchema fixtures > parses minimal byte-stably 1`] = `
+{
+ "outboundTag": "direct",
+ "type": "field",
+}
+`;
+
+exports[`RuleObjectSchema fixtures > parses port-number byte-stably 1`] = `
+{
+ "network": "tcp",
+ "outboundTag": "tls-out",
+ "port": 443,
+ "type": "field",
+}
+`;
diff --git a/frontend/src/test/balancer.test.ts b/frontend/src/test/balancer.test.ts
new file mode 100644
index 00000000..4ab1c208
--- /dev/null
+++ b/frontend/src/test/balancer.test.ts
@@ -0,0 +1,26 @@
+///
+import { describe, expect, it } from 'vitest';
+
+import { BalancerObjectSchema } from '@/schemas/routing';
+
+const fixtures = import.meta.glob(
+ './golden/fixtures/balancer/*.json',
+ { eager: true, import: 'default' },
+);
+
+function fixtureName(path: string): string {
+ const file = path.split('/').pop() ?? path;
+ return file.replace(/\.json$/, '');
+}
+
+describe('BalancerObjectSchema fixtures', () => {
+ const entries = Object.entries(fixtures).sort(([a], [b]) => a.localeCompare(b));
+ expect(entries.length, 'expected at least one fixture under golden/fixtures/balancer').toBeGreaterThan(0);
+
+ for (const [path, raw] of entries) {
+ it(`parses ${fixtureName(path)} byte-stably`, () => {
+ const parsed = BalancerObjectSchema.parse(raw);
+ expect(parsed).toMatchSnapshot();
+ });
+ }
+});
diff --git a/frontend/src/test/dns.test.ts b/frontend/src/test/dns.test.ts
new file mode 100644
index 00000000..6d9b894f
--- /dev/null
+++ b/frontend/src/test/dns.test.ts
@@ -0,0 +1,43 @@
+///
+import { describe, expect, it } from 'vitest';
+
+import { DnsObjectSchema, DnsServerObjectSchema } from '@/schemas/dns';
+
+function fixtureName(path: string): string {
+ const file = path.split('/').pop() ?? path;
+ return file.replace(/\.json$/, '');
+}
+
+const dnsFixtures = import.meta.glob(
+ './golden/fixtures/dns/*.json',
+ { eager: true, import: 'default' },
+);
+
+const serverFixtures = import.meta.glob(
+ './golden/fixtures/dns-server/*.json',
+ { eager: true, import: 'default' },
+);
+
+describe('DnsObjectSchema fixtures', () => {
+ const entries = Object.entries(dnsFixtures).sort(([a], [b]) => a.localeCompare(b));
+ expect(entries.length, 'expected at least one fixture under golden/fixtures/dns').toBeGreaterThan(0);
+
+ for (const [path, raw] of entries) {
+ it(`parses ${fixtureName(path)} byte-stably`, () => {
+ const parsed = DnsObjectSchema.parse(raw);
+ expect(parsed).toMatchSnapshot();
+ });
+ }
+});
+
+describe('DnsServerObjectSchema fixtures', () => {
+ const entries = Object.entries(serverFixtures).sort(([a], [b]) => a.localeCompare(b));
+ expect(entries.length, 'expected at least one fixture under golden/fixtures/dns-server').toBeGreaterThan(0);
+
+ for (const [path, raw] of entries) {
+ it(`parses ${fixtureName(path)} byte-stably`, () => {
+ const parsed = DnsServerObjectSchema.parse(raw);
+ expect(parsed).toMatchSnapshot();
+ });
+ }
+});
diff --git a/frontend/src/test/golden/fixtures/balancer/leastload-full.json b/frontend/src/test/golden/fixtures/balancer/leastload-full.json
new file mode 100644
index 00000000..9cea1f67
--- /dev/null
+++ b/frontend/src/test/golden/fixtures/balancer/leastload-full.json
@@ -0,0 +1,18 @@
+{
+ "tag": "balancer-load",
+ "selector": ["proxy-"],
+ "fallbackTag": "fallback-out",
+ "strategy": {
+ "type": "leastLoad",
+ "settings": {
+ "expected": 3,
+ "maxRTT": "1s",
+ "tolerance": 0.05,
+ "baselines": ["500ms", "1s", "2s"],
+ "costs": [
+ { "regexp": false, "match": "proxy-premium", "value": 0.1 },
+ { "regexp": true, "match": "^proxy-cheap-.+$", "value": 5 }
+ ]
+ }
+ }
+}
diff --git a/frontend/src/test/golden/fixtures/balancer/leastping.json b/frontend/src/test/golden/fixtures/balancer/leastping.json
new file mode 100644
index 00000000..c7465c5b
--- /dev/null
+++ b/frontend/src/test/golden/fixtures/balancer/leastping.json
@@ -0,0 +1,8 @@
+{
+ "tag": "balancer-ping",
+ "selector": ["proxy-"],
+ "fallbackTag": "fallback-out",
+ "strategy": {
+ "type": "leastPing"
+ }
+}
diff --git a/frontend/src/test/golden/fixtures/balancer/random-minimal.json b/frontend/src/test/golden/fixtures/balancer/random-minimal.json
new file mode 100644
index 00000000..53a32f21
--- /dev/null
+++ b/frontend/src/test/golden/fixtures/balancer/random-minimal.json
@@ -0,0 +1,4 @@
+{
+ "tag": "balancer-random",
+ "selector": ["proxy-"]
+}
diff --git a/frontend/src/test/golden/fixtures/balancer/roundrobin.json b/frontend/src/test/golden/fixtures/balancer/roundrobin.json
new file mode 100644
index 00000000..f21aca1d
--- /dev/null
+++ b/frontend/src/test/golden/fixtures/balancer/roundrobin.json
@@ -0,0 +1,8 @@
+{
+ "tag": "balancer-rr",
+ "selector": ["proxy-a", "proxy-b", "proxy-c"],
+ "fallbackTag": "direct",
+ "strategy": {
+ "type": "roundRobin"
+ }
+}
diff --git a/frontend/src/test/golden/fixtures/dns-server/full.json b/frontend/src/test/golden/fixtures/dns-server/full.json
new file mode 100644
index 00000000..94efebb6
--- /dev/null
+++ b/frontend/src/test/golden/fixtures/dns-server/full.json
@@ -0,0 +1,25 @@
+{
+ "address": "https://dns.google/dns-query",
+ "port": 443,
+ "domains": [
+ "domain:google.com",
+ "domain:youtube.com",
+ "geosite:google"
+ ],
+ "expectedIPs": [
+ "geoip:us",
+ "1.2.3.0/24"
+ ],
+ "unexpectedIPs": [
+ "geoip:private"
+ ],
+ "skipFallback": false,
+ "finalQuery": false,
+ "tag": "google-doh",
+ "clientIP": "9.9.9.9",
+ "queryStrategy": "UseIPv6",
+ "disableCache": false,
+ "timeoutMs": 4000,
+ "serveStale": true,
+ "serveExpiredTTL": 600
+}
diff --git a/frontend/src/test/golden/fixtures/dns-server/legacy-expectips.json b/frontend/src/test/golden/fixtures/dns-server/legacy-expectips.json
new file mode 100644
index 00000000..07eb2e03
--- /dev/null
+++ b/frontend/src/test/golden/fixtures/dns-server/legacy-expectips.json
@@ -0,0 +1,6 @@
+{
+ "address": "8.8.8.8",
+ "port": 53,
+ "domains": ["geosite:cn"],
+ "expectIPs": ["geoip:cn", "10.0.0.0/8"]
+}
diff --git a/frontend/src/test/golden/fixtures/dns/full.json b/frontend/src/test/golden/fixtures/dns/full.json
new file mode 100644
index 00000000..a8a5cb4f
--- /dev/null
+++ b/frontend/src/test/golden/fixtures/dns/full.json
@@ -0,0 +1,42 @@
+{
+ "tag": "dns_inbound",
+ "clientIp": "1.2.3.4",
+ "queryStrategy": "UseIP",
+ "disableCache": false,
+ "disableFallback": false,
+ "disableFallbackIfMatch": true,
+ "enableParallelQuery": true,
+ "useSystemHosts": true,
+ "serveStale": true,
+ "serveExpiredTTL": 300,
+ "hosts": {
+ "geosite:category-ads-all": "127.0.0.1",
+ "domain:example.com": ["10.0.0.1", "10.0.0.2"],
+ "full:dns.google": "8.8.8.8"
+ },
+ "servers": [
+ "fakedns",
+ "localhost",
+ "https://dns.google/dns-query",
+ {
+ "address": "tcp://1.1.1.1",
+ "port": 53,
+ "domains": ["geosite:cn"],
+ "expectedIPs": ["geoip:cn"],
+ "queryStrategy": "UseIPv4",
+ "skipFallback": true,
+ "tag": "cn-dns",
+ "clientIP": "8.8.4.4",
+ "timeoutMs": 3000
+ },
+ {
+ "address": "quic+local://dns.adguard.com",
+ "unexpectedIPs": ["geoip:private"],
+ "finalQuery": true,
+ "disableCache": true,
+ "serveStale": false,
+ "serveExpiredTTL": 60,
+ "timeoutMs": 5000
+ }
+ ]
+}
diff --git a/frontend/src/test/golden/fixtures/dns/minimal.json b/frontend/src/test/golden/fixtures/dns/minimal.json
new file mode 100644
index 00000000..f1fda9d0
--- /dev/null
+++ b/frontend/src/test/golden/fixtures/dns/minimal.json
@@ -0,0 +1,6 @@
+{
+ "servers": [
+ "8.8.8.8",
+ "1.1.1.1"
+ ]
+}
diff --git a/frontend/src/test/golden/fixtures/rule/balancer-routed.json b/frontend/src/test/golden/fixtures/rule/balancer-routed.json
new file mode 100644
index 00000000..c6d1fed1
--- /dev/null
+++ b/frontend/src/test/golden/fixtures/rule/balancer-routed.json
@@ -0,0 +1,6 @@
+{
+ "type": "field",
+ "domain": ["geosite:geolocation-!cn"],
+ "balancerTag": "balancer-load",
+ "ruleTag": "outbound-load-balance"
+}
diff --git a/frontend/src/test/golden/fixtures/rule/full.json b/frontend/src/test/golden/fixtures/rule/full.json
new file mode 100644
index 00000000..6a92bdd0
--- /dev/null
+++ b/frontend/src/test/golden/fixtures/rule/full.json
@@ -0,0 +1,60 @@
+{
+ "type": "field",
+ "domain": [
+ "domain:google.com",
+ "full:example.com",
+ "keyword:cdn",
+ "regexp:^api\\.example\\.com$",
+ "geosite:cn"
+ ],
+ "ip": [
+ "10.0.0.0/8",
+ "geoip:cn",
+ "geoip:private",
+ "!geoip:cn"
+ ],
+ "port": "80,443,1000-2000",
+ "sourcePort": "53",
+ "localPort": "5353",
+ "network": "tcp,udp",
+ "sourceIP": [
+ "192.168.0.0/16",
+ "geoip:private"
+ ],
+ "localIP": [
+ "10.10.10.0/24"
+ ],
+ "user": [
+ "user@example.com",
+ "regexp:^.+@admin\\..+$"
+ ],
+ "vlessRoute": "443,8443",
+ "inboundTag": [
+ "inbound-1",
+ "inbound-2"
+ ],
+ "protocol": [
+ "http",
+ "tls",
+ "quic",
+ "bittorrent"
+ ],
+ "attrs": {
+ "User-Agent": "regexp:^Mozilla.*",
+ "Host": "example.com"
+ },
+ "process": [
+ "chrome.exe",
+ "curl",
+ "self/"
+ ],
+ "outboundTag": "proxy-out",
+ "ruleTag": "main-policy-rule",
+ "webhook": {
+ "url": "https://hook.example.com/events",
+ "deduplication": 30,
+ "headers": {
+ "X-Auth-Token": "secret"
+ }
+ }
+}
diff --git a/frontend/src/test/golden/fixtures/rule/minimal.json b/frontend/src/test/golden/fixtures/rule/minimal.json
new file mode 100644
index 00000000..f79896f6
--- /dev/null
+++ b/frontend/src/test/golden/fixtures/rule/minimal.json
@@ -0,0 +1,4 @@
+{
+ "type": "field",
+ "outboundTag": "direct"
+}
diff --git a/frontend/src/test/golden/fixtures/rule/port-number.json b/frontend/src/test/golden/fixtures/rule/port-number.json
new file mode 100644
index 00000000..7104df11
--- /dev/null
+++ b/frontend/src/test/golden/fixtures/rule/port-number.json
@@ -0,0 +1,6 @@
+{
+ "type": "field",
+ "port": 443,
+ "network": "tcp",
+ "outboundTag": "tls-out"
+}
diff --git a/frontend/src/test/rule.test.ts b/frontend/src/test/rule.test.ts
new file mode 100644
index 00000000..a4fa519c
--- /dev/null
+++ b/frontend/src/test/rule.test.ts
@@ -0,0 +1,26 @@
+///
+import { describe, expect, it } from 'vitest';
+
+import { RuleObjectSchema } from '@/schemas/routing';
+
+const fixtures = import.meta.glob(
+ './golden/fixtures/rule/*.json',
+ { eager: true, import: 'default' },
+);
+
+function fixtureName(path: string): string {
+ const file = path.split('/').pop() ?? path;
+ return file.replace(/\.json$/, '');
+}
+
+describe('RuleObjectSchema fixtures', () => {
+ const entries = Object.entries(fixtures).sort(([a], [b]) => a.localeCompare(b));
+ expect(entries.length, 'expected at least one fixture under golden/fixtures/rule').toBeGreaterThan(0);
+
+ for (const [path, raw] of entries) {
+ it(`parses ${fixtureName(path)} byte-stably`, () => {
+ const parsed = RuleObjectSchema.parse(raw);
+ expect(parsed).toMatchSnapshot();
+ });
+ }
+});